21 Commits

Author SHA1 Message Date
Codex 64ba720121 feat(mobile): improve image viewer and install entry
Docker Image / Build and Push Docker Image (push) Successful in 1m58s
(cherry picked from commit 556bfd2919)
2026-07-24 14:54:57 +08:00
Codex b04ef58855 fix(pwa): 统一桌面安装图标
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
(cherry picked from commit 29abd746da)
(cherry picked from commit 8660fe7b7e)
2026-07-24 14:16:56 +08:00
Codex 606e6d60ff fix(chat): debounce post-send action menu
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
2026-07-23 20:03:26 +08:00
Codex c659c5fc3f test(e2e): align simplified splash expectations
Docker Image / Build and Push Docker Image (push) Successful in 3m4s
2026-07-23 19:33:08 +08:00
Codex 537a0a2c36 feat(chat): render commercial actions and persist greetings 2026-07-23 19:19:05 +08:00
Codex b1f52c68e8 feat(splash): simplify multi-character home layout 2026-07-23 19:19:05 +08:00
Codex 3c7b0c30e0 refactor(private-zone): use canonical product name 2026-07-23 19:19:05 +08:00
Codex 469512df18 feat(navigation): add favorite entry and Menu tab 2026-07-23 19:18:42 +08:00
Codex 318e4991be fix(ci): install pnpm from configured registry 2026-07-23 19:17:58 +08:00
Codex 2d432e5367 fix(ci): use reachable pnpm registry for Docker builds 2026-07-23 19:17:58 +08:00
Codex d6f104ee3f fix(private-zone): hide incomplete albums and refresh Maya cover
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-23 12:31:44 +08:00
Codex 9ca56c2a04 refactor(private-zone): use canonical product name 2026-07-23 12:31:44 +08:00
Codex 4639acf232 fix(multi-role): enable Maya and Nayeli commercial flows 2026-07-23 12:29:22 +08:00
Codex bdd53a6ea1 docs(private-zoom): remove legacy literal [skip ci] 2026-07-22 19:34:28 +08:00
Codex fb9e30cfd1 docs(private-zoom): record verified rollout [skip ci] 2026-07-22 19:32:16 +08:00
Codex 35939e7681 chore(release): update Private Zoom rollout guide
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
2026-07-22 18:52:48 +08:00
Codex a39d3c25ce chore(release): promote Private Zoom rename to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m34s
2026-07-22 18:31:36 +08:00
Codex a317a46e87 chore(release): promote E2E fixes to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-22 16:53:48 +08:00
Codex b531b02a85 chore(release): promote dev fixes to pre
Docker Image / Build and Push Docker Image (push) Successful in 4m0s
2026-07-22 15:40:23 +08:00
Codex adfb230908 chore(release): rename pre-release branch to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
2026-07-22 10:56:59 +08:00
admin 291f268cce chore(favicon): 使用预发布环境的图标
Docker Image / Build and Push Docker Image (push) Successful in 3m47s
2026-07-22 10:06:31 +08:00
252 changed files with 1706 additions and 10497 deletions
+13 -15
View File
@@ -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,9 +152,12 @@ 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 }}
@@ -163,18 +166,16 @@ jobs:
case "$DEPLOY_ENV" in case "$DEPLOY_ENV" in
test) test)
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test" DEPLOY_DIR="${TEST_DEPLOY_DIR:-/opt/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_ROOT="/home/lzf/apps/cozsweet-web-prod" DEPLOY_DIR="${PROD_DEPLOY_DIR:-/opt/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
@@ -182,11 +183,8 @@ 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="lzf" DEPLOY_USER="$SSH_USER"
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"
@@ -212,19 +210,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" \ ssh $SSH_OPTS "$SSH_TARGET" "mkdir -p '$DEPLOY_DIR'"
"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:$REMOTE_ENV_FILE" scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$DEPLOY_DIR/$NEXT_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' '$REMOTE_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' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
- name: Purge Cloudflare cache - name: Purge Cloudflare cache
env: env:
-138
View File
@@ -1,138 +0,0 @@
name: Deploy frontend to domestic test
on:
workflow_dispatch:
jobs:
deploy:
name: Build and deploy isolated domestic test
runs-on: gitea-label
container:
image: cozsweet-act-runner-node24-docker:latest
env:
NEXT_TELEMETRY_DISABLED: "1"
SENTRY_UPLOAD_SOURCEMAPS: "0"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate domestic SSH configuration
env:
SSH_HOST: ${{ secrets.DOMESTIC_TEST_SSH_HOST }}
SSH_PORT: ${{ secrets.DOMESTIC_TEST_SSH_PORT }}
SSH_PRIVATE_KEY_B64: ${{ secrets.DOMESTIC_TEST_SSH_PRIVATE_KEY_B64 }}
SSH_KNOWN_HOSTS: ${{ secrets.DOMESTIC_TEST_SSH_KNOWN_HOSTS }}
run: |
set -eu
if [ -z "$SSH_HOST" ] || [ -z "$SSH_PRIVATE_KEY_B64" ] || [ -z "$SSH_KNOWN_HOSTS" ]; then
echo "Missing domestic test SSH secrets" >&2
exit 1
fi
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
printf '%s' "$SSH_PRIVATE_KEY_B64" | base64 -d > "$HOME/.ssh/domestic_test_deploy_key"
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/domestic_test_deploy_key" "$HOME/.ssh/known_hosts"
- name: Prepare domestic test environment
env:
TEST_ENV_FILE_CONTENT: ${{ secrets.TEST_ENV_FILE }}
run: |
set -eu
if [ -z "$TEST_ENV_FILE_CONTENT" ]; then
echo "Missing existing secret: TEST_ENV_FILE" >&2
exit 1
fi
umask 077
printf '%s\n' "$TEST_ENV_FILE_CONTENT" > .env.domestic-test
replace_env() {
key="$1"
value="$2"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
$0 ~ "^" key "=" { print key "=" value; replaced = 1; next }
{ print }
END { if (!replaced) print key "=" value }
' .env.domestic-test > .env.domestic-test.next
mv .env.domestic-test.next .env.domestic-test
}
replace_env NEXT_PUBLIC_APP_ENV test
replace_env NEXT_PUBLIC_API_BASE_URL https://testapi.banlv-ai.com
replace_env NEXT_PUBLIC_WS_BASE_URL wss://testapi.banlv-ai.com/ws
replace_env NEXTAUTH_URL http://127.0.0.1:9135
replace_env NEXTAUTH_URL_INTERNAL http://127.0.0.1:3000
grep -Eq '^AUTH_SECRET=.+$' .env.domestic-test
grep -Eq '^NEXT_PUBLIC_API_BASE_URL=https://testapi\.banlv-ai\.com/?$' .env.domestic-test
grep -Eq '^NEXT_PUBLIC_WS_BASE_URL=wss://testapi\.banlv-ai\.com/ws/?$' .env.domestic-test
- name: Prepare image metadata
run: |
set -eu
SHORT_SHA="$(git rev-parse --short HEAD)"
IMAGE="cozsweet-frontend-domestic-test:test-$SHORT_SHA"
ARCHIVE="cozsweet-frontend-domestic-test-$SHORT_SHA.tar.gz"
{
echo "SHORT_SHA=$SHORT_SHA"
echo "IMAGE=$IMAGE"
echo "ARCHIVE=$ARCHIVE"
} >> "$GITHUB_ENV"
- name: Run frontend quality checks
run: |
set -eu
pnpm install --frozen-lockfile
pnpm lint
pnpm typecheck
pnpm test:contracts
- name: Build and archive runtime image
run: |
set -eu
DOCKER_BUILDKIT=1 docker build \
--secret id=next_env,src=.env.domestic-test \
--build-arg NEXT_ENV_FILE=.env.domestic-test \
-t "$IMAGE" \
.
docker save "$IMAGE" | gzip -1 > "$ARCHIVE"
test -s "$ARCHIVE"
- name: Deploy isolated domestic test container
env:
SSH_HOST: ${{ secrets.DOMESTIC_TEST_SSH_HOST }}
SSH_PORT: ${{ secrets.DOMESTIC_TEST_SSH_PORT }}
run: |
set -eu
DEPLOY_PORT="${SSH_PORT:-22}"
KEY_FILE="$HOME/.ssh/domestic_test_deploy_key"
SSH_TARGET="lzf@$SSH_HOST"
RUN_TOKEN="${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}"
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test"
REMOTE_TMP="$DEPLOY_ROOT/tmp/actions-$RUN_TOKEN"
REMOTE_ARCHIVE="$REMOTE_TMP/$ARCHIVE"
REMOTE_ENV_FILE="$DEPLOY_ROOT/shared/test.env"
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
cleanup_remote() {
ssh $SSH_OPTS "$SSH_TARGET" \
"rm -f '$REMOTE_ARCHIVE' && rmdir '$REMOTE_TMP' 2>/dev/null || true"
}
trap cleanup_remote EXIT
ssh $SSH_OPTS "$SSH_TARGET" \
"mkdir -p '$DEPLOY_ROOT/current' '$DEPLOY_ROOT/releases/docker' '$DEPLOY_ROOT/shared' '$REMOTE_TMP' && chmod 700 '$DEPLOY_ROOT/shared'"
scp $SCP_OPTS scripts/server/deploy-domestic-test-archive.sh \
"$SSH_TARGET:$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh"
scp $SCP_OPTS .env.domestic-test "$SSH_TARGET:$REMOTE_ENV_FILE"
scp $SCP_OPTS "$ARCHIVE" "$SSH_TARGET:$REMOTE_ARCHIVE"
ssh $SSH_OPTS "$SSH_TARGET" \
"chmod 600 '$REMOTE_ENV_FILE' && chmod 700 '$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh' && '$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh' '$REMOTE_ARCHIVE' '$IMAGE' '$REMOTE_ENV_FILE' 9135"
- name: Cleanup runner artifacts
if: always()
run: |
rm -f .env.domestic-test .env.domestic-test.next "${ARCHIVE:-}" "$HOME/.ssh/domestic_test_deploy_key"
+2 -2
View File
@@ -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
+8 -8
View File
@@ -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` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
## 后续优化建议 ## 后续优化建议
+7 -7
View File
@@ -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-*`
+66 -61
View File
@@ -1,11 +1,11 @@
# 外部入口接入说明 # 外部入口接入说明
Facebook 等外部应用统一通过 `/external-entry` 进入本应用。入口先保存外部身份和短期业务意图,再跳转到对应角色页面并清理地址栏中的入口查询参数。 外部应用通过 `/external-entry` 进入本应用。
| 环境 | APP HOST | | 环境 | APP HOST |
| --- | --- | | --- | --- |
| 预发环境 `pre` | `frontend-test.banlv-ai.com` | | 测试环境 | `frontend-test.banlv-ai.com` |
| 正式环境 `production` | `cozsweet.com` | | 正式环境 | `cozsweet.com` |
入口格式: 入口格式:
@@ -13,78 +13,83 @@ Facebook 等外部应用统一通过 `/external-entry` 进入本应用。入口
https://<APP_HOST>/external-entry?<参数> https://<APP_HOST>/external-entry?<参数>
``` ```
## 当前 Facebook 业务入口 ## 可选参数
当前正式投放固定为 4 类入口乘以 3 个角色,共 12 条链接。Facebook 业务名称与应用页面的对应关系如下: 所有参数均为可选参数。
| Facebook 入口 | 参数组合 | 落地行为 |
| --- | --- | --- |
| 私密空间 | `target=chat` | 进入普通聊天页,不插入任何锁定内容。 |
| 语音 | `target=chat&mode=promotion&promotion_type=voice` | 进入聊天页,并插入一条待解锁语音。 |
| 图片包 | `target=private-zone` | 进入角色的 `private-zone` 图片空间。 |
| 帮忙买咖啡 | `target=tip` | 进入角色的咖啡打赏页。 |
每条正式链接还必须显式传入:
- `character=elio``character=maya``character=nayeli`
- `psid=<PSID>`,其中 `<PSID>` 必须由 Facebook 发送端替换为当前用户经过 URL 编码的 Page-scoped User ID。
完整的 12 条正式发送模板见 [Facebook 12 条外部入口链接清单](./links.md)。
## 参数说明
| 参数 | 可选值或格式 | 用途 | | 参数 | 可选值或格式 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `target` | `chat``tip``private-zone` | 指定进入的页面;不传或值无效时进入聊天页。 | | `target` | `chat``tip``private-zone` | 指定进入的页面;不传或值无效时进入聊天页。 |
| `character` | `elio``maya``nayeli` | 指定角色;不传或值无效时使用 Elio。正式投放链接必须显式传入。 | | `character` | `elio``maya``nayeli` | 指定角色;不传或值无效时使用 Elio。 |
| `psid` | string | Facebook Page-scoped User ID。Facebook 正式投放链接必须动态传入。 | | `psid` | string | 传入 Facebook Page-scoped User ID。 |
| `mode` | `promotion` | 开启聊天促销模式。 | | `mode` | `promotion` | 开启聊天促销模式。 |
| `promotion_type` | `voice``image``private` | 指定促销消息类型,仅与 `mode=promotion` 一起使用。 | | `promotion_type` | `voice``image``private` | 指定促销消息类型,仅与 `mode=promotion` 一起使用。 |
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。当前 12 条正式投放链接只使用 `promotion_type=voice``image``private` 保留为兼容能力,不属于本次业务清单。 `mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
## 四类发送模板
发送前必须把 `<CHARACTER>` 替换为角色值,把 `<PSID>` 替换为当前 Facebook 用户的真实 PSID;不得把占位符原样发送给用户。
私密空间(普通聊天):
```text
https://<APP_HOST>/external-entry?target=chat&character=<CHARACTER>&psid=<PSID>
```
语音:
```text
https://<APP_HOST>/external-entry?target=chat&character=<CHARACTER>&mode=promotion&promotion_type=voice&psid=<PSID>
```
图片包:
```text
https://<APP_HOST>/external-entry?target=private-zone&character=<CHARACTER>&psid=<PSID>
```
帮忙买咖啡:
```text
https://<APP_HOST>/external-entry?target=tip&character=<CHARACTER>&psid=<PSID>
```
## PSID 与登录状态流程 ## PSID 与登录状态流程
`psid` 可以和任意有效入口参数组合使用。应用会先保存 PSID,再执行登录状态判断和 Facebook Identity 绑定。详细流程见 [PSID 与登录状态流程图](./psid-login-flow.md)。 PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
所有参数值都必须进行 URL 编码。入口中禁止传递登录 Token、Page Access Token、App Secret 或其他秘密。 ## 使用示例
## 兼容说明 普通聊天:
- 历史参数 `target=private-room` 继续兼容进入 `private-zone`,但不得用于新投放链接。 ```text
- `private_zone` 等其他拼写仍按无效目标处理并回退聊天页。 https://<APP_HOST>/external-entry?target=chat
- `promotion_type=image``promotion_type=private` 继续由现有代码支持,但不计入当前 12 条 Facebook 业务链接。 ```
- 无效或缺失的 `character` 继续回退 Elio;正式投放模板始终显式传入角色,不能依赖回退。
## 调试与正式清单 进入 Maya 聊天:
```text
https://<APP_HOST>/external-entry?target=chat&character=maya
```
携带 PSID 进入聊天,示例 PSID 为 `27511427698460020`
```text
https://<APP_HOST>/external-entry?target=chat&psid=27511427698460020
```
语音促销聊天:
```text
https://<APP_HOST>/external-entry?target=chat&mode=promotion&promotion_type=voice
```
图片促销聊天:
```text
https://<APP_HOST>/external-entry?target=chat&mode=promotion&promotion_type=image
```
私密文本促销聊天:
```text
https://<APP_HOST>/external-entry?target=chat&mode=promotion&promotion_type=private
```
咖啡打赏:
```text
https://<APP_HOST>/external-entry?target=tip
```
私密空间:
```text
https://<APP_HOST>/external-entry?target=private-zone
```
进入 Nayeli 私密空间:
```text
https://<APP_HOST>/external-entry?target=private-zone&character=nayeli
```
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
完整可点击示例:
- [本地调试链接清单](./debug-links.md) - [本地调试链接清单](./debug-links.md)
- [Facebook 12 条外部入口链接清单](./links.md) - [测试环境和正式环境链接清单](./links.md)
+36 -45
View File
@@ -1,52 +1,43 @@
# Facebook 12 条外部入口链接清单 # 外部入口链接清单
本清单是 Facebook 正式投放的唯一业务链接清单,共 4 类入口乘以 3 个角色,合计 12 条模板。 [返回外部入口接入说明](./README.md)
## 发送规则 以下链接可以直接点击打开。PSID 示例统一使用 `27511427698460020`
- 发送前必须把每条模板中的 `<PSID>` 替换为当前用户经过 URL 编码的 Facebook Page-scoped User ID;不得把占位符原样发送。 ## 测试环境
- 每条链接都显式传入 `character`,不得依赖缺省角色回退。
- Facebook 业务名称“私密空间”进入普通聊天页;业务名称“图片包”才进入应用的 `private-zone`
- 不要在入口中传递登录 Token、Page Access Token、App Secret 或其他秘密。
- 以下模板包含占位符,替换真实 PSID 后才能直接发送或点击。
## 正式投放模板 | 入口 | 链接 |
| --- | --- |
| 普通聊天 | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) |
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) |
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) |
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) |
| 语音促销 | [打开语音促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
| 图片促销 | [打开图片促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
| 私密文本促销 | [打开私密文本促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
| 咖啡打赏 | [打开咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip) |
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) |
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) |
| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone) |
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=maya) |
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=nayeli) |
| 编号 | Facebook 入口 | 角色 | 发送模板 | 预期落地与页面状态 | ## 正式环境
| --- | --- | --- | --- | --- |
| 1 | 私密空间 | Elio | `https://cozsweet.com/external-entry?target=chat&character=elio&psid=<PSID>` | `/characters/elio/chat`,不显示促销锁卡 |
| 2 | 私密空间 | Maya | `https://cozsweet.com/external-entry?target=chat&character=maya&psid=<PSID>` | `/characters/maya/chat`,不显示促销锁卡 |
| 3 | 私密空间 | Nayeli | `https://cozsweet.com/external-entry?target=chat&character=nayeli&psid=<PSID>` | `/characters/nayeli/chat`,不显示促销锁卡 |
| 4 | 语音 | Elio | `https://cozsweet.com/external-entry?target=chat&character=elio&mode=promotion&promotion_type=voice&psid=<PSID>` | `/characters/elio/chat`,显示一条锁定语音 |
| 5 | 语音 | Maya | `https://cozsweet.com/external-entry?target=chat&character=maya&mode=promotion&promotion_type=voice&psid=<PSID>` | `/characters/maya/chat`,显示一条锁定语音 |
| 6 | 语音 | Nayeli | `https://cozsweet.com/external-entry?target=chat&character=nayeli&mode=promotion&promotion_type=voice&psid=<PSID>` | `/characters/nayeli/chat`,显示一条锁定语音 |
| 7 | 图片包 | Elio | `https://cozsweet.com/external-entry?target=private-zone&character=elio&psid=<PSID>` | `/characters/elio/private-zone` |
| 8 | 图片包 | Maya | `https://cozsweet.com/external-entry?target=private-zone&character=maya&psid=<PSID>` | `/characters/maya/private-zone` |
| 9 | 图片包 | Nayeli | `https://cozsweet.com/external-entry?target=private-zone&character=nayeli&psid=<PSID>` | `/characters/nayeli/private-zone` |
| 10 | 帮忙买咖啡 | Elio | `https://cozsweet.com/external-entry?target=tip&character=elio&psid=<PSID>` | `/characters/elio/tip` |
| 11 | 帮忙买咖啡 | Maya | `https://cozsweet.com/external-entry?target=tip&character=maya&psid=<PSID>` | `/characters/maya/tip` |
| 12 | 帮忙买咖啡 | Nayeli | `https://cozsweet.com/external-entry?target=tip&character=nayeli&psid=<PSID>` | `/characters/nayeli/tip` |
## 预发验证 | 入口 | 链接 |
| --- | --- |
| 普通聊天 | [打开普通聊天](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-zone) |
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=maya) |
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=nayeli) |
预发验证不维护第二套业务清单。将上述 12 条模板的主机统一替换为 `frontend-test.banlv-ai.com`,其余路径和参数保持不变: 如需在其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`
```text
https://frontend-test.banlv-ai.com/external-entry?<与正式模板相同的查询参数>
```
预发环境是 `pre`,不是正式环境。预发验证通过不代表已经更新 `cozsweet.com` 或获得生产发布批准。
## 验收判断
- 最终浏览器地址必须是表格中的预期页面,且不再保留 `/external-entry` 的查询参数。
- `<PSID>` 替换后的真实值必须由应用保存。
- “私密空间”只进入普通聊天,不得出现锁定语音、锁定图片或私密文本促销卡。
- “语音”进入聊天后必须只注入一条锁定语音。
- “图片包”和“帮忙买咖啡”必须分别进入对应角色的 `private-zone``tip`,不能串到其他角色。
## 不属于当前 12 条清单的兼容能力
- 历史 `target=private-room` 继续兼容,但不得创建新的投放链接。
- `promotion_type=image``promotion_type=private` 和独立 PSID 示例不属于当前正式业务清单。
- 兼容能力的保留不改变本页“只有 12 条正式投放模板”的口径。
+7 -7
View File
@@ -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/
+2 -2
View File
@@ -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
+3 -3
View File
@@ -12,7 +12,7 @@ export interface MockCoreApisOptions {
paidImageFlow?: boolean; paidImageFlow?: boolean;
paidImageInsufficientCreditsFlow?: boolean; paidImageInsufficientCreditsFlow?: boolean;
paidVoiceInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean;
topUpHandoffFlow?: boolean; psidLoginFlow?: boolean;
} }
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
@@ -22,14 +22,14 @@ 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,
topUpHandoffFlow: options.topUpHandoffFlow ?? false, psidLoginFlow: options.psidLoginFlow ?? 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,
topUpHandoffFlow: chatOptions.topUpHandoffFlow, psidLoginFlow: chatOptions.psidLoginFlow,
}); });
await registerCharacterMocks(page); await registerCharacterMocks(page);
await registerUserMocks(page); await registerUserMocks(page);
+6 -5
View File
@@ -1,21 +1,22 @@
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, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth"; import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
export interface AuthMockState { export interface AuthMockState {
chatSendTokenRefreshFlow: boolean; chatSendTokenRefreshFlow: boolean;
isChatSendTokenExpired: () => boolean; isChatSendTokenExpired: () => boolean;
topUpHandoffFlow: boolean; psidLoginFlow: boolean;
} }
export async function registerAuthMocks(page: Page, state: AuthMockState) { export async function registerAuthMocks(page: Page, state: AuthMockState) {
await page.route("**/api/auth/handoff/topup/consume", async (route) => { await page.route("**/api/auth/login/facebook/psid", async (route) => {
if (!state.topUpHandoffFlow) { if (!state.psidLoginFlow) {
await route.continue(); await route.continue();
return; return;
} }
await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) });
await route.fulfill({ json: apiEnvelope(psidLoginResponse) });
}); });
await page.route("**/api/auth/guest", async (route) => { await page.route("**/api/auth/guest", async (route) => {
+1 -15
View File
@@ -61,21 +61,7 @@ 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 = message.includes("discount offer test") const response = message.includes("private album 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, ...chatSendResponse,
reply: "You always know how to make me smile.", reply: "You always know how to make me smile.",
-10
View File
@@ -4,16 +4,6 @@ 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) }));
+10 -12
View File
@@ -50,16 +50,14 @@ export const refreshedEmailLoginResponse = {
refreshToken: "e2e-refreshed-email-refresh-token", refreshToken: "e2e-refreshed-email-refresh-token",
}; };
export const topUpHandoffResponse = { export const psidLoginResponse = {
token: "e2e-messenger-token", token: "e2e-psid-guest-token",
refreshToken: "e2e-messenger-refresh-token", refreshToken: "",
loginStatus: "facebookMessenger", matchedBy: "psid",
merged: false, fbAsid: "",
user: { fbPsid: "e2e-facebook-psid",
...e2eEmailUser, hasCompleteFacebookIdentity: false,
id: "user_e2e_messenger", isGuest: true,
username: "E2E Messenger User", user: e2eUser,
email: "messenger_hash@messenger.cozsweet.invalid", userId: e2eUser.id,
loginProvider: "facebook_messenger",
},
}; };
+2 -2
View File
@@ -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: null }, image: { type: "elio_schedule", url: paidImageUrl },
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: unlocked ? paidImageUrl : null }, 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: paidImageUrl }, 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 -11
View File
@@ -1,19 +1,9 @@
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).toBeDisabled();
await page.getByRole("button", { name: /Monthly,/i }).click();
const renewalDialog = page.getByRole("dialog", {
name: "Automatic Renewal Confirmation",
});
await expect(renewalDialog).toBeVisible();
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
await expect(checkoutButton).toBeEnabled();
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 checkoutButton.click(); await page.getByRole("button", { name: /Pay and Activ/i }).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;
@@ -1,74 +1,49 @@
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 { clearBrowserState } from "@e2e/fixtures/test-helpers"; import {
clearBrowserState,
defaultCharacterChatUrl,
} from "@e2e/fixtures/test-helpers";
const handoffToken = "e2e-handoff-token-that-is-at-least-32-characters"; const psid = "e2e-facebook-psid";
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, { topUpHandoffFlow: true }); await mockCoreApis(page, { psidLoginFlow: true });
}); });
test("user consumes a top-up handoff and opens credit packages as a formal Messenger user", async ({ test("user enters from an external PSID link and becomes a guest", async ({
page, page,
}) => { }) => {
const handoffRequestPromise = page.waitForRequest( const psidLoginRequestPromise = page.waitForRequest(
"**/api/auth/handoff/topup/consume", "**/api/auth/login/facebook/psid",
); );
await page.goto( await page.goto(`/external-entry?target=chat&psid=${psid}`);
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
);
const handoffRequest = await handoffRequestPromise; const psidLoginRequest = await psidLoginRequestPromise;
expect(handoffRequest.method()).toBe("POST"); expect(psidLoginRequest.method()).toBe("POST");
expect(handoffRequest.postDataJSON()).toEqual({ handoffToken }); expect(psidLoginRequest.postDataJSON()).toMatchObject({
psid,
bindToGuest: true,
});
expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String));
await expect(page).toHaveURL("/subscription?type=topup"); await expect(page).toHaveURL(defaultCharacterChatUrl);
await expect( await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
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"),
loginToken: localStorage.getItem("cozsweet:login_token"), guestToken: localStorage.getItem("cozsweet:guest_token"),
refreshToken: localStorage.getItem("cozsweet:refresh_token"), psid: localStorage.getItem("cozsweet:psid"),
})), })),
) )
.toMatchObject({ .toMatchObject({
loginProvider: "facebookMessenger", loginProvider: "guest",
loginToken: "e2e-messenger-token", guestToken: "e2e-psid-guest-token",
refreshToken: "e2e-messenger-refresh-token", psid,
}); });
}); });
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");
});
@@ -29,21 +29,3 @@ test("renders a backend commercial action and opens the private zone", async ({
await expect(page).toHaveURL(/\/characters\/elio\/private-zone$/); 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/,
);
});
@@ -1,201 +0,0 @@
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 testPsid = "e2e-facebook-psid-27511427698460020";
const characters = [
{
id: "elio",
slug: "elio",
displayName: "Elio Silvestri",
shortName: "Elio",
},
{
id: "maya-tan",
slug: "maya",
displayName: "Maya Tan",
shortName: "Maya",
},
{
id: "nayeli-cervantes",
slug: "nayeli",
displayName: "Nayeli Cervantes",
shortName: "Nayeli",
},
] as const;
type Character = (typeof characters)[number];
type EntryKind = "private-space" | "voice" | "image-pack" | "coffee";
interface FacebookEntryCase {
character: Character;
kind: EntryKind;
target: "chat" | "private-zone" | "tip";
expectedPath: string;
promotionType?: "voice";
}
const entryCases: FacebookEntryCase[] = characters.flatMap((character) => [
{
character,
kind: "private-space",
target: "chat",
expectedPath: `/characters/${character.slug}/chat`,
},
{
character,
kind: "voice",
target: "chat",
expectedPath: `/characters/${character.slug}/chat`,
promotionType: "voice",
},
{
character,
kind: "image-pack",
target: "private-zone",
expectedPath: `/characters/${character.slug}/private-zone`,
},
{
character,
kind: "coffee",
target: "tip",
expectedPath: `/characters/${character.slug}/tip`,
},
]);
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
await registerEntryPageMocks(page);
});
for (const entryCase of entryCases) {
test(`${entryCase.character.displayName} opens Facebook ${entryCase.kind} entry`, async ({
page,
}) => {
await page.goto(buildEntryUrl(entryCase));
await expect(page).toHaveURL(entryCase.expectedPath, { timeout: 20_000 });
await expect
.poll(() =>
page.evaluate(() => localStorage.getItem("cozsweet:psid")),
)
.toBe(testPsid);
if (entryCase.kind === "private-space") {
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Unlock voice message" }),
).toHaveCount(0);
return;
}
if (entryCase.kind === "voice") {
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Unlock voice message" }),
).toHaveCount(1);
return;
}
if (entryCase.kind === "image-pack") {
await expect(
page.getByRole("heading", { name: "Private albums" }),
).toBeVisible();
await expect(
page.getByText(entryCase.character.displayName).last(),
).toBeVisible();
return;
}
await expect(
page.getByRole("heading", {
name: `Buy ${entryCase.character.shortName} a coffee`,
}),
).toBeVisible();
});
}
function buildEntryUrl(entryCase: FacebookEntryCase): string {
const params = new URLSearchParams({
target: entryCase.target,
character: entryCase.character.slug,
psid: testPsid,
});
if (entryCase.promotionType) {
params.set("mode", "promotion");
params.set("promotion_type", entryCase.promotionType);
}
return `/external-entry?${params.toString()}`;
}
async function registerEntryPageMocks(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) => {
await route.fulfill({
json: apiEnvelope({
creditBalance: 1000,
pendingImageCount: 0,
hasIncompleteContent: false,
items: [],
}),
});
});
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 -28
View File
@@ -89,13 +89,6 @@ for (const character of characters) {
test(`${character.displayName} can open a character-scoped Private Zone`, async ({ test(`${character.displayName} can open a character-scoped Private Zone`, async ({
page, 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 albumRequest = page.waitForRequest((request) => {
const url = new URL(request.url()); const url = new URL(request.url());
return ( return (
@@ -106,15 +99,10 @@ for (const character of characters) {
await page.goto(`/characters/${character.slug}/private-zone`); await page.goto(`/characters/${character.slug}/private-zone`);
await Promise.all([momentsRequest, albumRequest]); await albumRequest;
await expect( await expect(
page.getByRole("heading", { name: "Private albums" }), page.getByRole("heading", { name: "Private albums" }),
).toBeVisible(); ).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(); await expect(page.getByText(character.displayName).last()).toBeVisible();
const albumButton = page.getByRole("button", { const albumButton = page.getByRole("button", {
name: `View locked collection with 8 images from ${character.displayName}`, name: `View locked collection with 8 images from ${character.displayName}`,
@@ -169,21 +157,6 @@ test("a real Private Zone request failure still offers Retry", async ({
}); });
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> { 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) => { await page.route("**/api/private-zone/albums**", async (route) => {
const url = new URL(route.request().url()); const url = new URL(route.request().url());
const characterId = url.searchParams.get("characterId") ?? "elio"; const characterId = url.searchParams.get("characterId") ?? "elio";
+20 -8
View File
@@ -19,6 +19,10 @@ 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, {
@@ -44,18 +48,26 @@ test("guest can unlock a paid image after email login and subscription payment",
message: "给我发图片", message: "给我发图片",
}); });
const lockedImageCard = page.getByRole("group", { const paidImageButton = page.getByRole("button", {
name: "Locked private image", name: "Open image in fullscreen",
}).last(); }).last();
await expect(lockedImageCard).toBeVisible(); await expect(paidImageButton).toBeVisible();
await expect(lockedImageCard.locator("img")).toHaveCount(0);
await lockedImageCard await paidImageButton.click();
.getByRole("button", { name: "Unlock private image" }) await expect(page).toHaveURL(paidImageOverlayUrl);
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, `${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
); );
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
@@ -63,7 +75,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: new RegExp(`${defaultCharacterChatPath}$`), expectedUrl: paidImageOverlayUrl,
}); });
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
@@ -1,119 +0,0 @@
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);
}
@@ -1,422 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const idrPlans = {
plans: [
{
planId: "vip_monthly",
planName: "Monthly",
orderType: "vip_monthly",
amountCents: 19_590_000,
originalAmountCents: 19_590_000,
dailyPriceCents: 653_000,
currency: "IDR",
vipDays: 30,
dolAmount: null,
creditBalance: 3_000,
},
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 8_890_000,
originalAmountCents: null,
dailyPriceCents: null,
currency: "IDR",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
const idrGiftCatalog = {
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Velvet Espresso",
orderType: "tip",
tipType: "coffee_small",
category: "coffee",
characterId: "elio",
description: "Buy Elio a Velvet Espresso",
imageUrl: null,
amountCents: 8_929_900,
currency: "IDR",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
};
async function registerIndonesiaPaymentMocks(page: Page) {
const orderStatuses = new Map<string, "pending" | "paid">();
const orderPlans = new Map<string, { planId: string; orderType: string }>();
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(idrPlans) });
});
await page.route("**/api/payment/gift-products**", async (route) => {
await route.fulfill({ json: apiEnvelope(idrGiftCatalog) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
const body = route.request().postDataJSON() as {
planId: string;
payChannel: "ezpay" | "stripe";
};
const plan =
idrPlans.plans.find((item) => item.planId === body.planId) ??
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
const orderId = `order_${body.payChannel}_${body.planId}`;
orderStatuses.set(orderId, "pending");
orderPlans.set(orderId, {
planId: body.planId,
orderType: plan?.orderType ?? "dol",
});
await route.fulfill({
json: apiEnvelope({
orderId,
payParams:
body.payChannel === "stripe"
? {
provider: "stripe",
clientSecret: "pi_e2e_secret_mock",
}
: {
provider: "ezpay",
countryCode: "ID",
channelCode: "ID_QRIS_DYNAMIC_QR",
channelType: "QR",
payData: "00020101021226670016COM.NOBUBANK.WWW",
firstChargeAmountCents: plan?.amountCents ?? 0,
currency: "IDR",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
const url = new URL(route.request().url());
const orderId = url.searchParams.get("order_id") ?? "";
const plan = orderPlans.get(orderId);
await route.fulfill({
json: apiEnvelope({
orderId,
status: orderStatuses.get(orderId) ?? "pending",
orderType: plan?.orderType ?? "dol",
planId: plan?.planId ?? null,
creditsAdded: 0,
}),
});
});
await page.route("**/api/payment/tip-message", async (route) => {
const body = route.request().postDataJSON() as { orderId: string };
await route.fulfill({
json: apiEnvelope({
orderId: body.orderId,
characterId: "elio",
planId: "tip_coffee_usd_4_99",
productName: "Velvet Espresso",
tipCount: 1,
poolIndex: 0,
message: "Thank you. Your gift made me smile.",
}),
});
});
return {
getCreateOrderCount() {
return createOrderCount;
},
markPaid(orderId: string) {
orderStatuses.set(orderId, "paid");
},
};
}
async function prepareIndonesiaUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "ID" }),
);
});
}
async function expectQrisOrder(
page: Page,
buttonName: RegExp,
planId: string,
formattedAmount: RegExp,
) {
const requestPromise = page.waitForRequest("**/api/payment/create-order");
await page.getByRole("button", { name: buttonName }).click();
const request = await requestPromise;
expect(request.postDataJSON()).toMatchObject({
planId,
payChannel: "ezpay",
recipientCharacterId: "elio",
});
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(formattedAmount);
await expect(dialog).toContainText("Waiting for payment");
await expect(
dialog.getByRole("img", { name: "QRIS payment QR code" }),
).toBeVisible();
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 }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Indonesia VIP defaults to QRIS and completes after status polling", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
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(
"aria-pressed",
"true",
);
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(checkoutButton).toBeDisabled();
await expectCheckoutButtonLayout(page);
await page.getByRole("button", { name: /Monthly, 195900 IDR/i }).click();
const renewalDialog = page.getByRole("dialog", {
name: "Automatic Renewal Confirmation",
});
await expect(renewalDialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(0);
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
await expect(renewalDialog).toBeHidden();
await expect(checkoutButton).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(0);
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"vip_monthly",
/Rp\s*195[.,]900/,
);
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
const success = page.getByRole("alertdialog", { name: "Payment successful" });
await expect(success).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
await expectCheckoutButtonLayout(page);
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS restores payment selection and allows switching to Stripe", 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();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
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 ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
/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();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
@@ -1,153 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
-164
View File
@@ -1,164 +0,0 @@
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,16 +2,22 @@ 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, {
@@ -19,23 +25,30 @@ test.beforeEach(async ({ baseURL, context, page }) => {
}); });
}); });
test("user sees insufficient credits when unlocking from the locked image card and can navigate to subscription", async ({ test("user sees insufficient credits when unlocking a paid image from fullscreen and can navigate to subscription", async ({
page, page,
}) => { }) => {
await signInWithEmailAndOpenChat(page); await signInWithEmailAndOpenChat(page);
await dismissChatInterruptions(page); await dismissChatInterruptions(page);
const lockedImageCard = page.getByRole("group", { const paidImageButton = page.getByRole("button", {
name: "Locked private image", name: "Open image in fullscreen",
}).last(); }).last();
await expect(lockedImageCard).toBeVisible({ timeout: 10_000 }); await expect(paidImageButton).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 lockedImageCard await lockedImageDialog
.getByRole("button", { name: "Unlock private image" }) .getByRole("button", { name: "Unlock high-definition large image" })
.click(); .click();
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
-1
View File
@@ -5,7 +5,6 @@ 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
-1
View File
@@ -10,7 +10,6 @@ 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
-1
View File
@@ -50,7 +50,6 @@
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"ofetch": "^1.5.1", "ofetch": "^1.5.1",
"pino": "^10.3.1", "pino": "^10.3.1",
"qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
-12
View File
@@ -50,9 +50,6 @@ importers:
pino: pino:
specifier: ^10.3.1 specifier: ^10.3.1
version: 10.3.1 version: 10.3.1
qrcode.react:
specifier: ^4.2.0
version: 4.2.0(react@19.2.4)
react: react:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4 version: 19.2.4
@@ -3293,11 +3290,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qrcode.react@4.2.0:
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -7093,10 +7085,6 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qrcode.react@4.2.0(react@19.2.4):
dependencies:
react: 19.2.4
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {} quick-format-unescaped@4.0.4: {}
@@ -1,34 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const workflowUrl = new URL(
"../../../.gitea/workflows/domestic-test.yml",
import.meta.url,
);
const deployScriptUrl = new URL(
"../../server/deploy-domestic-test-archive.sh",
import.meta.url,
);
test("domestic frontend workflow is manual and registry-free", async () => {
const workflow = await readFile(workflowUrl, "utf8");
assert.match(workflow, /workflow_dispatch:/);
assert.doesNotMatch(workflow, /\bpush:/);
assert.doesNotMatch(workflow, /REGISTRY_IMAGE/);
assert.match(workflow, /docker save/);
assert.match(workflow, /DOMESTIC_TEST_SSH_PRIVATE_KEY_B64/);
assert.match(workflow, /base64 -d/);
});
test("domestic frontend workflow pins the China test API and isolated port", async () => {
const workflow = await readFile(workflowUrl, "utf8");
const deployScript = await readFile(deployScriptUrl, "utf8");
assert.match(workflow, /https:\/\/testapi\.banlv-ai\.com/);
assert.match(workflow, /wss:\/\/testapi\.banlv-ai\.com\/ws/);
assert.match(workflow, /9135/);
assert.match(deployScript, /127\.0\.0\.1:\$HOST_PORT/);
assert.match(deployScript, /cozsweet-frontend-domestic-test/);
});
+1 -1
View File
@@ -3,7 +3,7 @@
# 推送指定分支到指定远端。 # 推送指定分支到指定远端。
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。 # 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
# 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main" # 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main"
# deploy_web_test.shtest)→ push_to_remote "gitea" "test" # deploy_web_test.shpre)→ push_to_remote "gitea" "pre"
push_to_remote() { push_to_remote() {
local remote="$1" local remote="$1"
local branch="$2" local branch="$2"
+2 -2
View File
@@ -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
+8 -8
View File
@@ -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 "=== 预发布完成 ==="
+2
View File
@@ -49,6 +49,8 @@ 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
@@ -1,110 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ARCHIVE="${1:-}"
IMAGE="${2:-}"
ENV_FILE="${3:-}"
HOST_PORT="${4:-9135}"
CONTAINER_NAME="cozsweet-frontend-domestic-test"
INTERNAL_PORT="3000"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SHARED_ROOT="$SERVICE_ROOT/shared"
RELEASE_ROOT="$SERVICE_ROOT/releases/docker"
if [ -z "$ARCHIVE" ] || [ -z "$IMAGE" ] || [ -z "$ENV_FILE" ]; then
echo "Usage: $0 <image-archive> <image> <env-file> [host-port]" >&2
exit 64
fi
if [ "$HOST_PORT" != "9135" ]; then
echo "Domestic frontend test must use isolated host port 9135" >&2
exit 64
fi
case "$ENV_FILE" in
"$SHARED_ROOT"/*) ;;
*)
echo "Environment file must be stored under $SHARED_ROOT" >&2
exit 64
;;
esac
if [ ! -s "$ARCHIVE" ] || [ ! -s "$ENV_FILE" ]; then
echo "Image archive or environment file is missing" >&2
exit 66
fi
mkdir -p "$RELEASE_ROOT"
chmod 700 "$SHARED_ROOT"
gzip -dc "$ARCHIVE" | docker load >/dev/null
docker image inspect "$IMAGE" >/dev/null
old_image_id=""
old_image_ref=""
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
old_image_id="$(docker inspect -f '{{.Image}}' "$CONTAINER_NAME")"
old_image_ref="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")"
elif ss -ltnH "sport = :$HOST_PORT" 2>/dev/null | grep -q .; then
echo "Host port $HOST_PORT is already used by another service" >&2
exit 69
fi
run_container() {
local image_ref="$1"
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p "127.0.0.1:$HOST_PORT:$INTERNAL_PORT" \
--env-file "$ENV_FILE" \
-e "PORT=$INTERNAL_PORT" \
"$image_ref" >/dev/null
}
wait_healthy() {
local deadline=$((SECONDS + 180))
while [ "$SECONDS" -le "$deadline" ]; do
state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || true)"
status="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$HOST_PORT/" 2>/dev/null || true)"
if [[ "$status" =~ ^(200|204|301|302|303|307|308)$ ]] && { [ "$state" = "healthy" ] || [ "$state" = "running" ]; }; then
return 0
fi
sleep 2
done
return 1
}
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
deployment_ok="1"
if ! run_container "$IMAGE"; then
deployment_ok="0"
elif ! wait_healthy; then
deployment_ok="0"
fi
if [ "$deployment_ok" != "1" ]; then
docker logs --tail 120 "$CONTAINER_NAME" >&2 || true
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
if [ -n "$old_image_id" ] && docker image inspect "$old_image_id" >/dev/null 2>&1; then
echo "New container failed health checks; restoring previous image" >&2
run_container "$old_image_id"
wait_healthy || true
fi
exit 1
fi
created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
image_id="$(docker image inspect "$IMAGE" -f '{{.Id}}')"
record="{\"createdAt\":\"$created_at\",\"environment\":\"domestic-test\",\"image\":\"$IMAGE\",\"imageId\":\"$image_id\",\"container\":\"$CONTAINER_NAME\",\"hostPort\":$HOST_PORT}"
if [ -f "$RELEASE_ROOT/current.json" ]; then
cp "$RELEASE_ROOT/current.json" "$RELEASE_ROOT/previous.json"
fi
printf '%s\n' "$record" > "$RELEASE_ROOT/current.json"
printf '%s\n' "$record" >> "$RELEASE_ROOT/releases.jsonl"
echo "Domestic frontend test is healthy"
echo "container=$CONTAINER_NAME"
echo "host_port=$HOST_PORT"
echo "image=$IMAGE"
if [ -n "$old_image_ref" ]; then
echo "previous_image=$old_image_ref"
fi
@@ -1,119 +0,0 @@
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();
});
});
+1 -9
View File
@@ -20,10 +20,7 @@ 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({
@@ -35,10 +32,7 @@ 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;
@@ -69,11 +63,9 @@ export function ModalPortal({
onClick={handleScrimClick} onClick={handleScrimClick}
> >
<div <div
role={role} role="dialog"
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()}
@@ -1,47 +1,23 @@
import { act } from "react"; import { renderToStaticMarkup } from "react-dom/server";
import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentLaunchDialogs } from "../payment-launch-dialogs"; import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = { const hiddenLaunch = {
handleEzpayCancel: vi.fn(), handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(), handleEzpayConfirm: vi.fn(),
handleRegionalQrClose: vi.fn(),
handleStripeClose: vi.fn(), handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(), handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false, isConfirmingEzpay: false,
regionalQrErrorMessage: null,
regionalQrPayment: null,
regionalQrStatus: null,
shouldShowEzpayConfirmDialog: false, shouldShowEzpayConfirmDialog: false,
shouldShowRegionalQrDialog: false,
shouldShowStripeDialog: false, shouldShowStripeDialog: false,
stripeClientSecret: null, stripeClientSecret: null,
}; };
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", () => {
act(() => expect(
root.render( renderToStaticMarkup(
<PaymentLaunchDialogs <PaymentLaunchDialogs
currentOrderId={null} currentOrderId={null}
externalCheckoutAnalyticsKey="payment.external_checkout" externalCheckoutAnalyticsKey="payment.external_checkout"
@@ -49,15 +25,11 @@ describe("PaymentLaunchDialogs", () => {
launch={hiddenLaunch} launch={hiddenLaunch}
/>, />,
), ),
); ).toBe("");
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull();
}); });
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => { it("renders the shared Ezpay confirmation content", () => {
act(() => const html = renderToStaticMarkup(
root.render(
<PaymentLaunchDialogs <PaymentLaunchDialogs
currentOrderId="order-123" currentOrderId="order-123"
externalCheckoutAnalyticsKey="tip.external_checkout" externalCheckoutAnalyticsKey="tip.external_checkout"
@@ -68,126 +40,13 @@ describe("PaymentLaunchDialogs", () => {
shouldShowEzpayConfirmDialog: true, shouldShowEzpayConfirmDialog: true,
}} }}
/>, />,
),
); );
const dialog = document.body.querySelector('[role="alertdialog"]'); expect(html).toContain('role="alertdialog"');
expect(dialog).not.toBeNull(); expect(html).toContain("Continue to GCash?");
expect(container.contains(dialog)).toBe(false); expect(html).toContain("Your coffee order is ready.");
expect(dialog?.parentElement?.parentElement).toBe(document.body); expect(html).toContain("Order No. order-123");
expect(dialog?.textContent).toContain("Continue to payment?"); expect(html).toContain('data-analytics-key="tip.external_checkout"');
expect(dialog?.textContent).toContain("Your coffee order is ready."); expect(html).toContain("Opening...");
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("portals an accessible QRIS dialog with display cents and order status", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Scan to pay with QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"QRIS payment QR code",
);
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("labels a Philippine QR fallback as GCash / QR Ph", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-ph-qr"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Pay with GCash."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "000201010212ph-qr-payload",
orderId: "order-ph-qr",
amountCents: 49_990,
currency: "PHP",
experience: "gcashQrPh",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
expect(dialog?.textContent).not.toContain("QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"GCash / QR Ph payment QR code",
);
expect(dialog?.textContent).toContain("₱");
const closeButton = Array.from(
dialog?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Close");
act(() => closeButton?.click());
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
});
it("shows QRIS failure state and handles empty QR data safely", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
regionalQrErrorMessage: "Payment failed or was cancelled.",
regionalQrPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
regionalQrStatus: "failed",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("QRIS data is unavailable");
expect(dialog?.textContent).toContain("Payment failed or was cancelled.");
expect(dialog?.textContent).toContain("Close");
}); });
}); });
@@ -62,27 +62,6 @@ describe("PaymentMethodSelector", () => {
); );
}); });
it("labels Indonesia Ezpay as QRIS without the GCash logo", () => {
const html = renderToStaticMarkup(
<PaymentMethodSelector
config={{
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: true,
ezpayDisplayName: "QRIS",
}}
value="ezpay"
caption="QRIS by default in Indonesia"
analyticsKey="subscription.payment_method"
onChange={() => undefined}
/>,
);
expect(html).toContain('aria-label="QRIS"');
expect(html).toContain("QRIS by default in Indonesia");
expect(html).not.toContain("gcash-logo.svg");
});
it("renders compact controls without the caption", () => { it("renders compact controls without the caption", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PaymentMethodSelector <PaymentMethodSelector
@@ -1,63 +0,0 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
import { StripePaymentDialog } from "../stripe-payment-dialog";
describe("Stripe payment portal states", () => {
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("portals the unavailable state and keeps its explicit close action", () => {
const onClose = vi.fn();
act(() =>
root.render(
<StripePaymentDialog
clientSecret="client-secret"
onClose={onClose}
/>,
),
);
const dialog = document.body.querySelector('[role="alertdialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Payment unavailable");
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "OK",
);
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onClose).toHaveBeenCalledOnce();
});
it("portals the lazy loading state", () => {
act(() => root.render(<StripePaymentDialogLoading />));
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Preparing secure payment");
expect(dialog?.textContent).toContain("Loading payment methods...");
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
});
});
@@ -2,8 +2,6 @@
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";
@@ -22,28 +20,22 @@ export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
return <StripePaymentDialog {...props} />; return <StripePaymentDialog {...props} />;
} }
export function StripePaymentDialogLoading() { function StripePaymentDialogLoading() {
return ( return (
<ModalPortal <div
open className={styles.overlay}
persistent role="dialog"
onClose={() => undefined} aria-modal="true"
scrimClassName={styles.overlay} aria-labelledby="stripe-payment-loading-title"
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy="stripe-payment-loading-title"
ariaDescribedBy="stripe-payment-loading-description"
> >
<div aria-busy="true"> <div className={styles.dialog} 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 id="stripe-payment-loading-description" className={styles.content}> <p className={styles.content}>Loading payment methods...</p>
Loading payment methods... </div>
</p>
</div> </div>
</div> </div>
</ModalPortal>
); );
} }
@@ -1,9 +1,7 @@
"use client"; "use client";
import { useId, type ReactNode } from "react"; import { useId, type ReactNode } from "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";
@@ -13,15 +11,10 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow, PaymentLaunchFlow,
| "handleEzpayCancel" | "handleEzpayCancel"
| "handleEzpayConfirm" | "handleEzpayConfirm"
| "handleRegionalQrClose"
| "handleStripeClose" | "handleStripeClose"
| "handleStripeConfirmed" | "handleStripeConfirmed"
| "isConfirmingEzpay" | "isConfirmingEzpay"
| "regionalQrErrorMessage"
| "regionalQrPayment"
| "regionalQrStatus"
| "shouldShowEzpayConfirmDialog" | "shouldShowEzpayConfirmDialog"
| "shouldShowRegionalQrDialog"
| "shouldShowStripeDialog" | "shouldShowStripeDialog"
| "stripeClientSecret" | "stripeClientSecret"
>; >;
@@ -53,14 +46,6 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm} onConfirm={launch.handleEzpayConfirm}
/> />
) : null} ) : null}
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
<RegionalQrPaymentDialog
payment={launch.regionalQrPayment}
status={launch.regionalQrStatus}
errorMessage={launch.regionalQrErrorMessage}
onClose={launch.handleRegionalQrClose}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( {launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
<LazyStripePaymentDialog <LazyStripePaymentDialog
clientSecret={launch.stripeClientSecret} clientSecret={launch.stripeClientSecret}
@@ -73,151 +58,6 @@ export function PaymentLaunchDialogs({
); );
} }
interface RegionalQrPaymentDialogProps {
errorMessage: string | null;
onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
status: PaymentLaunchFlow["regionalQrStatus"];
}
function formatRegionalQrAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try {
return new Intl.NumberFormat(
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
{
style: "currency",
currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
},
).format(amountCents / 100);
} catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
}
}
function regionalQrStatusMessage(
status: PaymentLaunchFlow["regionalQrStatus"],
errorMessage: string | null,
paymentName: string,
): string {
if (status === "failed") {
return errorMessage || "Payment failed. Please try again.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
return "Waiting for payment";
}
function RegionalQrPaymentDialog({
errorMessage,
onClose,
payment,
status,
}: RegionalQrPaymentDialogProps) {
const titleId = useId();
const copy = regionalQrCopy(payment.experience);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
return (
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy={titleId}
>
<div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}>
{copy.title}
</h2>
<p className={styles.content}>
{copy.description}
</p>
</div>
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
{payment.qrData ? (
<QRCodeSVG
value={payment.qrData}
title={copy.qrTitle}
size={256}
level="M"
marginSize={2}
className="h-auto w-full max-w-64"
/>
) : (
<p className={styles.error} role="alert">
{copy.unavailableMessage}
</p>
)}
</div>
<div className="mb-4 flex flex-col gap-2 text-center">
<p className={styles.content}>Order No. {payment.orderId}</p>
<p className="m-0 text-xl font-bold text-text-foreground">
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
</p>
<p
className={status === "failed" || status === "expired" ? styles.error : styles.content}
role="status"
>
{statusMessage}
</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
>
Close
</button>
</div>
</ModalPortal>
);
}
function regionalQrCopy(
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
) {
if (experience === "gcashQrPh") {
return {
paymentName: "GCash / QR Ph",
title: "Pay with GCash / QR Ph",
description:
"Open GCash or another QR Ph-compatible app and scan this code.",
qrTitle: "GCash / QR Ph payment QR code",
unavailableMessage:
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
};
}
if (experience === "qris") {
return {
paymentName: "QRIS",
title: "Scan to pay with QRIS",
description:
"Open a QRIS-compatible banking or wallet app and scan this code.",
qrTitle: "QRIS payment QR code",
unavailableMessage:
"QRIS data is unavailable. Please close this dialog and try again.",
};
}
return {
paymentName: "payment QR",
title: "Scan to pay",
description: "Open a compatible banking or wallet app and scan this code.",
qrTitle: "Payment QR code",
unavailableMessage:
"Payment QR data is unavailable. Please close this dialog and try again.",
};
}
interface EzpayRedirectConfirmDialogProps { interface EzpayRedirectConfirmDialogProps {
description: ReactNode; description: ReactNode;
externalCheckoutAnalyticsKey: string; externalCheckoutAnalyticsKey: string;
@@ -238,19 +78,16 @@ function EzpayRedirectConfirmDialog({
const titleId = useId(); const titleId = useId();
return ( return (
<ModalPortal <div
open className={styles.overlay}
persistent
onClose={onCancel}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
role="alertdialog" role="alertdialog"
ariaLabelledBy={titleId} aria-modal="true"
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 GCash?
</h2> </h2>
<p className={styles.content}>{description}</p> <p className={styles.content}>{description}</p>
<p className={styles.content}>Order No. {orderId}</p> <p className={styles.content}>Order No. {orderId}</p>
@@ -275,6 +112,7 @@ function EzpayRedirectConfirmDialog({
{isConfirming ? "Opening..." : "Continue"} {isConfirming ? "Opening..." : "Continue"}
</button> </button>
</div> </div>
</ModalPortal> </div>
</div>
); );
} }
@@ -44,24 +44,13 @@ export function PaymentMethodSelector({
}: PaymentMethodSelectorProps) { }: PaymentMethodSelectorProps) {
if (!config.showPaymentMethodSelector) return null; if (!config.showPaymentMethodSelector) return null;
const isCompact = density === "compact"; const isCompact = density === "compact";
const ezpayDisplayName = config.ezpayDisplayName ?? "GCash";
const configuredPaymentMethods = PAYMENT_METHODS.map((method) =>
method.channel === "ezpay"
? {
...method,
title: ezpayDisplayName,
logoSrc:
ezpayDisplayName === "GCash" ? method.logoSrc : undefined,
}
: method,
);
const paymentMethods = const paymentMethods =
config.initialPayChannel === "ezpay" config.initialPayChannel === "ezpay"
? [...configuredPaymentMethods].sort((a, b) => ? [...PAYMENT_METHODS].sort((a, b) =>
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0, a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
) )
: configuredPaymentMethods; : PAYMENT_METHODS;
return ( return (
<section <section
@@ -2,7 +2,7 @@ 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 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)]", "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)]",
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",
@@ -4,7 +4,7 @@
* *
* PaymentIntent clientSecret * PaymentIntent clientSecret
*/ */
import { useId, useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { import {
Elements, Elements,
PaymentElement, PaymentElement,
@@ -13,7 +13,6 @@ import {
} from "@stripe/react-stripe-js"; } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js"; import { loadStripe } 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";
@@ -39,27 +38,13 @@ export function StripePaymentDialog({
onClose, onClose,
onConfirmed, onConfirmed,
}: StripePaymentDialogProps) { }: StripePaymentDialogProps) {
const titleId = useId();
const descriptionId = useId();
if (!stripePromise) { if (!stripePromise) {
return ( return (
<ModalPortal <div className={styles.overlay} role="alertdialog" aria-modal="true">
open <div className={styles.dialog}>
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 id={titleId} className={styles.title}> <h2 className={styles.title}>Payment unavailable</h2>
Payment unavailable <p className={styles.content}>
</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>
@@ -72,26 +57,24 @@ export function StripePaymentDialog({
OK OK
</button> </button>
</div> </div>
</ModalPortal> </div>
</div>
); );
} }
return ( return (
<ModalPortal <div
open className={styles.overlay}
persistent role="dialog"
onClose={onClose} aria-modal="true"
scrimClassName={styles.overlay} aria-labelledby="stripe-payment-title"
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy={titleId}
ariaDescribedBy={descriptionId}
> >
<div className={styles.dialog}>
<div className={styles.header}> <div className={styles.header}>
<h2 id={titleId} className={styles.title}> <h2 id="stripe-payment-title" className={styles.title}>
Complete payment Complete payment
</h2> </h2>
<p id={descriptionId} className={styles.content}> <p className={styles.content}>
Enter your payment details securely through Stripe. Enter your payment details securely through Stripe.
</p> </p>
</div> </div>
@@ -116,7 +99,8 @@ export function StripePaymentDialog({
onConfirmed={onConfirmed} onConfirmed={onConfirmed}
/> />
</Elements> </Elements>
</ModalPortal> </div>
</div>
); );
} }
+8 -161
View File
@@ -4,11 +4,9 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import { import {
getPaymentUrl, getPaymentUrl,
getPaymentUrlHostname,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
launchEzpayRedirect, launchEzpayRedirect,
resolveEzpayLaunchTarget,
} from "@/lib/payment/payment_launch"; } from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics"; import { behaviorAnalytics } from "@/lib/analytics";
import type { import type {
@@ -42,63 +40,21 @@ export interface UsePaymentLaunchFlowInput {
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
giftCategory?: string | null; giftCategory?: string | null;
giftPlanId?: string | null; giftPlanId?: string | null;
countryCode?: string | null;
} }
export interface PaymentLaunchFlow { export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null; ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void; handleEzpayCancel: () => void;
handleEzpayConfirm: () => void; handleEzpayConfirm: () => void;
handleRegionalQrClose: () => void;
handleStripeClose: () => void; handleStripeClose: () => void;
handleStripeConfirmed: () => void; handleStripeConfirmed: () => void;
isConfirmingEzpay: boolean; isConfirmingEzpay: boolean;
regionalQrErrorMessage: string | null;
regionalQrPayment: RegionalQrPaymentDetails | null;
regionalQrStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void; resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean; shouldShowEzpayConfirmDialog: boolean;
shouldShowRegionalQrDialog: boolean;
shouldShowStripeDialog: boolean; shouldShowStripeDialog: boolean;
stripeClientSecret: string | null; stripeClientSecret: string | null;
} }
export interface RegionalQrPaymentDetails {
amountCents: number;
currency: string;
experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string;
qrData: string;
}
function paymentParamString(
payParams: Record<string, unknown>,
...keys: string[]
): string | null {
for (const key of keys) {
const value = payParams[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function paymentParamAmountCents(
payParams: Record<string, unknown>,
): number | null {
for (const key of [
"firstChargeAmountCents",
"first_charge_amount_cents",
"amountCents",
"amount_cents",
]) {
const value = payParams[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
function trackPaymentCheckoutOpened( function trackPaymentCheckoutOpened(
payment: PaymentContextState, payment: PaymentContextState,
checkoutUrl: string, checkoutUrl: string,
@@ -156,7 +112,6 @@ export function usePaymentLaunchFlow({
subscriptionType, subscriptionType,
giftCategory, giftCategory,
giftPlanId, giftPlanId,
countryCode,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow { }: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
@@ -166,41 +121,8 @@ export function usePaymentLaunchFlow({
const stripeClientSecret = payment.payParams const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams) ? getStripeClientSecret(payment.payParams)
: null; : null;
const selectedPlan = payment.plans.find( const ezpayPaymentUrl = payment.payParams
(item) => item.planId === payment.selectedPlanId, ? getPaymentUrl(payment.payParams)
);
const paymentCurrency = payment.payParams
? paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
null
: selectedPlan?.currency ?? null;
const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams, {
countryCode,
currency: paymentCurrency,
})
: null;
const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl
: null;
const regionalQrPayment: RegionalQrPaymentDetails | null =
payment.payParams &&
payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr"
? {
amountCents:
paymentParamAmountCents(payment.payParams) ??
selectedPlan?.amountCents ??
0,
currency:
paymentCurrency ??
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
experience: ezpayLaunchTarget.experience,
orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData,
}
: null; : null;
useEffect(() => { useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -214,74 +136,20 @@ export function usePaymentLaunchFlow({
return; return;
} }
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams); const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) {
const target = resolveEzpayLaunchTarget(payment.payParams, {
countryCode,
currency: paymentCurrency,
});
const channelType = paymentParamString(
payment.payParams,
"channelType",
"channel_type",
);
const channelCode = paymentParamString(
payment.payParams,
"channelCode",
"channel_code",
);
const namedPaymentUrl = getPaymentUrl(payment.payParams);
log.debug(`[${logScope}] ezpay launch target resolved`, {
countryCode: countryCode?.trim().toUpperCase() ?? null,
currency: paymentCurrency?.trim().toUpperCase() ?? null,
channelType: channelType?.toUpperCase() ?? null,
channelCode,
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
hasQrData: target.kind === "qr",
paymentUrlHost:
target.kind === "url"
? getPaymentUrlHostname(target.paymentUrl)
: null,
});
if (target.kind === "error") {
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: target.errorMessage,
});
return;
}
if (target.kind === "qr") { if (!AppEnvUtil.isProduction() && isEzpay) {
if (!payment.currentOrderId) {
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Missing order id before showing payment QR code.",
});
return;
}
trackPaymentCheckoutOpened(
payment,
target.experience === "qris"
? "qris_embedded"
: target.experience === "gcashQrPh"
? "gcash_qrph_embedded"
: "payment_qr_embedded",
);
return;
}
const paymentUrl = target.paymentUrl;
if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, { log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrlHost: getPaymentUrlHostname(paymentUrl), paymentUrl,
subscriptionType, subscriptionType,
}); });
return; return;
} }
if (isEzpay) {
void launchEzpayRedirect({ void launchEzpayRedirect({
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl, paymentUrl,
@@ -299,10 +167,8 @@ export function usePaymentLaunchFlow({
return; return;
} }
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
try { try {
window.location.assign(paymentUrl); window.location.href = paymentUrl;
trackPaymentCheckoutOpened(payment, paymentUrl); trackPaymentCheckoutOpened(payment, paymentUrl);
} catch { } catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -323,7 +189,6 @@ export function usePaymentLaunchFlow({
log, log,
logScope, logScope,
characterSlug, characterSlug,
countryCode,
payment.currentOrderId, payment.currentOrderId,
payment, payment,
payment.launchNonce, payment.launchNonce,
@@ -332,7 +197,6 @@ export function usePaymentLaunchFlow({
payment.plans, payment.plans,
payment.selectedPlanId, payment.selectedPlanId,
paymentDispatch, paymentDispatch,
paymentCurrency,
returnTo, returnTo,
subscriptionType, subscriptionType,
giftCategory, giftCategory,
@@ -349,9 +213,6 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams, payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl, paymentUrl: ezpayPaymentUrl,
}); });
const shouldShowRegionalQrDialog = Boolean(
regionalQrPayment && !payment.isPaid,
);
function resetLaunchState(): void { function resetLaunchState(): void {
setIsConfirmingEzpay(false); setIsConfirmingEzpay(false);
@@ -402,29 +263,15 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
} }
function handleRegionalQrClose(): void {
log.debug(`[${logScope}] regional payment QR dialog closed`, {
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
experience: regionalQrPayment?.experience ?? null,
subscriptionType,
});
paymentDispatch({ type: "PaymentReset" });
}
return { return {
ezpayPaymentUrl, ezpayPaymentUrl,
handleEzpayCancel, handleEzpayCancel,
handleEzpayConfirm, handleEzpayConfirm,
handleRegionalQrClose,
handleStripeClose, handleStripeClose,
handleStripeConfirmed, handleStripeConfirmed,
isConfirmingEzpay, isConfirmingEzpay,
regionalQrErrorMessage: payment.errorMessage,
regionalQrPayment,
regionalQrStatus: payment.orderStatus,
resetLaunchState, resetLaunchState,
shouldShowEzpayConfirmDialog, shouldShowEzpayConfirmDialog,
shouldShowRegionalQrDialog,
shouldShowStripeDialog, shouldShowStripeDialog,
stripeClientSecret, stripeClientSecret,
}; };
-19
View File
@@ -22,9 +22,6 @@ export interface UsePaymentRouteFlowInput {
characterId?: string; characterId?: string;
initialCategory?: string | null; initialCategory?: string | null;
initialPlanId?: string | null; initialPlanId?: string | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
} }
export interface PaymentRouteFlow { export interface PaymentRouteFlow {
@@ -44,21 +41,15 @@ export function usePaymentRouteFlow({
characterId, characterId,
initialCategory = null, initialCategory = null,
initialPlanId = null, initialPlanId = 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 ?? "",
commercialOfferId ?? "",
chatActionId ?? "",
].join(":"); ].join(":");
usePendingPaymentOrderLifecycle({ usePendingPaymentOrderLifecycle({
@@ -78,26 +69,16 @@ export function usePaymentRouteFlow({
...(characterId ? { characterId } : {}), ...(characterId ? { characterId } : {}),
...(initialCategory ? { category: initialCategory } : {}), ...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}), ...(initialPlanId ? { planId: initialPlanId } : {}),
...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}),
}); });
}, [ }, [
catalog, catalog,
catalogKey, catalogKey,
characterId, characterId,
commercialOfferId,
chatActionId,
initialCategory, initialCategory,
initialPlanId, initialPlanId,
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 };
} }
@@ -1,57 +0,0 @@
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();
});
});
-8
View File
@@ -1,8 +0,0 @@
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);
}
-101
View File
@@ -1,101 +0,0 @@
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?.();
}
}
-47
View File
@@ -1,47 +0,0 @@
.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; }
}
-280
View File
@@ -1,280 +0,0 @@
"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>
);
}
@@ -1,5 +0,0 @@
import { VoiceCallScreen } from "@/app/call/voice-call-screen";
export default function CharacterCallPage() {
return <VoiceCallScreen />;
}
@@ -23,7 +23,6 @@ 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
@@ -31,7 +30,6 @@ export default async function CharacterTipPage({
initialPlanId={initialPlanId} initialPlanId={initialPlanId}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder} shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={paymentReturn.initialPayChannel} initialPayChannel={paymentReturn.initialPayChannel}
chatActionId={chatActionId}
/> />
); );
} }
@@ -1,110 +0,0 @@
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");
});
});
@@ -1,118 +0,0 @@
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("never invents a first-recharge discount and shows the active account offer", () => {
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: "commercialOffer",
label: "Support me · 30% OFF · 23:59:59",
commercialOfferId: "offer-1",
expiresAt: "2026-07-29T00:00:00.000Z",
});
});
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: true,
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");
});
it("shows the final 50% account offer with the same countdown", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-2",
characterId: "elio",
planId: "dol_2500_t3",
discountPercent: 50,
pricePercent: 50,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "expired_order",
message: "This is the last offer.",
scope: "account",
stage: "halfDiscount",
},
nowMs: now,
}),
).toMatchObject({
kind: "commercialOffer",
label: "Support me · 50% OFF · 23:59:59",
commercialOfferId: "offer-2",
});
});
});
-64
View File
@@ -1,64 +0,0 @@
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,
},
);
}
}
}
+12 -123
View File
@@ -17,14 +17,9 @@ 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, ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { getCharacterRoutes, ROUTES } from "@/router/routes";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; import type { CommercialAction } from "@/data/schemas/chat";
import { paymentApi } from "@/data/services/api";
import { behaviorAnalytics } from "@/lib/analytics"; 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";
@@ -33,8 +28,8 @@ import {
ChatHeader, ChatHeader,
ChatInputBar, ChatInputBar,
ChatInsufficientCreditsBanner, ChatInsufficientCreditsBanner,
ChatSupportButton,
ChatUnlockDialogs, ChatUnlockDialogs,
FirstRechargeOfferBanner,
FullscreenImageViewer, FullscreenImageViewer,
PwaInstallOverlay, PwaInstallOverlay,
} from "./components"; } from "./components";
@@ -46,14 +41,12 @@ import {
import { import {
deriveIsGuest, deriveIsGuest,
} from "./chat-screen.helpers"; } from "./chat-screen.helpers";
import { useChatSupportCta } from "./hooks/use-chat-support-cta"; import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
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)",
@@ -71,14 +64,6 @@ 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();
@@ -129,17 +114,11 @@ 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 supportCta = useChatSupportCta({ const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
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({
@@ -290,7 +269,7 @@ export function ChatScreen() {
router.push(characterRoutes.privateZone); router.push(characterRoutes.privateZone);
} }
async function handleCommercialAction(action: CommercialAction): Promise<void> { function handleCommercialAction(action: CommercialAction): void {
behaviorAnalytics.elementClick( behaviorAnalytics.elementClick(
"chat.commercial_action.open", "chat.commercial_action.open",
action.ctaLabel, action.ctaLabel,
@@ -302,29 +281,6 @@ export function ChatScreen() {
target: action.target, 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( router.push(
action.target === "giftCatalog" action.target === "giftCatalog"
? characterRoutes.tip ? characterRoutes.tip
@@ -345,69 +301,6 @@ export function ChatScreen() {
); );
} }
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 (
<MobileShell> <MobileShell>
<div <div
@@ -428,13 +321,13 @@ export function ChatScreen() {
<ChatHeader <ChatHeader
isGuest={isGuest} isGuest={isGuest}
offerBanner={ offerBanner={
supportCta.visible ? ( <FirstRechargeOfferBanner
<ChatSupportButton visible={firstRechargeOfferBanner.visible}
kind={supportCta.kind} discountPercent={firstRechargeOfferBanner.discountPercent}
label={supportCta.label} onClick={firstRechargeOfferBanner.claim}
onClick={supportCta.open} onClose={firstRechargeOfferBanner.close}
variant="compact"
/> />
) : null
} }
/> />
@@ -458,9 +351,6 @@ export function ChatScreen() {
onCharacterAvatarClick={handleOpenCharacterPrivateZone} onCharacterAvatarClick={handleOpenCharacterPrivateZone}
onCommercialAction={handleCommercialAction} onCommercialAction={handleCommercialAction}
onCommercialActionDismiss={handleCommercialActionDismiss} onCommercialActionDismiss={handleCommercialActionDismiss}
onChatActionViewed={handleChatActionViewed}
onChatAction={handleChatAction}
onChatActionDismiss={handleChatActionDismiss}
onLoadMoreHistory={() => { onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}} }}
@@ -471,7 +361,6 @@ 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}
/> />
) : ( ) : (
-52
View File
@@ -1,52 +0,0 @@
import type { CommercialOfferSummary } from "@/data/schemas/payment";
export type ChatSupportCtaKind =
| "support"
| "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 {
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(":");
}
@@ -1,67 +0,0 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { 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("");
});
});
@@ -23,23 +23,4 @@ describe("CommercialActionCard", () => {
expect(html).toContain("Open private zone"); expect(html).toContain("Open private zone");
expect(html).toContain('aria-label="Dismiss offer"'); 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");
});
}); });
@@ -15,7 +15,6 @@ 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";
@@ -224,19 +223,6 @@ 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",
}}
/>, />,
); );
@@ -246,56 +232,11 @@ 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 &amp; 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(
@@ -1,118 +0,0 @@
"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>
);
}
@@ -189,26 +189,6 @@
overflow-wrap: anywhere; 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));
+1 -18
View File
@@ -24,7 +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 type { CommercialAction } from "@/data/schemas/chat";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { import {
@@ -64,9 +64,6 @@ export interface ChatAreaProps {
onCharacterAvatarClick?: () => void; onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void; onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (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 = (
@@ -93,9 +90,6 @@ export function ChatArea({
onCharacterAvatarClick, onCharacterAvatarClick,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, 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);
@@ -270,9 +264,6 @@ export function ChatArea({
onCharacterAvatarClick, onCharacterAvatarClick,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
)} )}
{isReplyingAI ? ( {isReplyingAI ? (
@@ -313,9 +304,6 @@ function renderMessagesWithDateHeaders(
onCharacterAvatarClick?: () => void, onCharacterAvatarClick?: () => void,
onCommercialAction?: (action: CommercialAction) => void, onCommercialAction?: (action: CommercialAction) => void,
onCommercialActionDismiss?: (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" ? (
@@ -336,8 +324,6 @@ function renderMessagesWithDateHeaders(
lockedPrivate={item.message.lockedPrivate} lockedPrivate={item.message.lockedPrivate}
privateMessageHint={item.message.privateMessageHint} privateMessageHint={item.message.privateMessageHint}
commercialAction={item.message.commercialAction} 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
@@ -350,9 +336,6 @@ function renderMessagesWithDateHeaders(
onCharacterAvatarClick={onCharacterAvatarClick} onCharacterAvatarClick={onCharacterAvatarClick}
onCommercialAction={onCommercialAction} onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss} onCommercialActionDismiss={onCommercialActionDismiss}
onChatActionViewed={onChatActionViewed}
onChatAction={onChatAction}
onChatActionDismiss={onChatActionDismiss}
/> />
), ),
); );
+1 -16
View File
@@ -5,7 +5,7 @@
* *
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock, Phone } from "lucide-react"; import { Lock } from "lucide-react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { FavoriteEntryButton } from "@/app/_components/core"; import { FavoriteEntryButton } from "@/app/_components/core";
@@ -27,7 +27,6 @@ export function ChatHeader({
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const voiceCallEnabled = process.env.NEXT_PUBLIC_ENABLE_VOICE_CALL === "true";
return ( return (
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0"> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
@@ -59,25 +58,11 @@ export function ChatHeader({
<div className="flex min-w-0 justify-center">{offerBanner}</div> <div className="flex min-w-0 justify-center">{offerBanner}</div>
<div className="flex items-center gap-2">
{voiceCallEnabled ? (
<button
type="button"
className="inline-flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-white shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition hover:bg-[rgba(28,24,39,0.82)] active:scale-96"
aria-label={`Call ${character.shortName}`}
data-analytics-key="chat.start_voice_call"
data-analytics-label="Start voice call"
onClick={() => navigator.push(characterRoutes.call)}
>
<Phone size={19} strokeWidth={2.3} aria-hidden="true" />
</button>
) : null}
<FavoriteEntryButton <FavoriteEntryButton
characterSlug={character.slug} characterSlug={character.slug}
tone="dark" tone="dark"
/> />
</div> </div>
</div>
</header> </header>
); );
} }
@@ -17,13 +17,11 @@
* - state machinechat UI * - state machinechat UI
*/ */
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface ChatInsufficientCreditsBannerProps { export interface ChatInsufficientCreditsBannerProps {
title?: string; title?: string;
description?: string; description?: string;
ctaLabel?: string | null; ctaLabel?: string;
guidance?: PaymentGuidance | null;
/** /**
* *
* - mock * - mock
@@ -36,11 +34,9 @@ export function ChatInsufficientCreditsBanner({
title = "Insufficient credits\nTop up to continue chatting", title = "Insufficient credits\nTop up to continue chatting",
description, description,
ctaLabel = "Top up credits to continue", ctaLabel = "Top up credits to continue",
guidance = null,
onUnlock, onUnlock,
}: ChatInsufficientCreditsBannerProps) { }: ChatInsufficientCreditsBannerProps) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const activeGuidance = guidance;
const titleLines = title.split("\n"); const titleLines = title.split("\n");
const handleClick = () => { const handleClick = () => {
@@ -57,7 +53,6 @@ export function ChatInsufficientCreditsBanner({
role="status" role="status"
aria-live="polite" aria-live="polite"
data-testid="chat-insufficient-credits-banner" data-testid="chat-insufficient-credits-banner"
data-payment-guidance-id={activeGuidance?.guidanceId}
> >
<p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95"> <p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95">
{titleLines.map((line, index) => ( {titleLines.map((line, index) => (
@@ -72,12 +67,7 @@ export function ChatInsufficientCreditsBanner({
{description} {description}
</p> </p>
) : null} ) : null}
{activeGuidance?.copy ? ( <button
<p className="mx-auto mb-(--spacing-lg,16px) mt-0 max-w-[min(100%,360px)] text-(length:--responsive-caption,14px) font-semibold leading-[1.4] text-white">
{activeGuidance.copy}
</p>
) : null}
{ctaLabel ? <button
type="button" type="button"
data-analytics-key="chat.topup" data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits" data-analytics-label="Top up chat credits"
@@ -86,7 +76,7 @@ export function ChatInsufficientCreditsBanner({
aria-label={ctaLabel} aria-label={ctaLabel}
> >
{ctaLabel} {ctaLabel}
</button> : null} </button>
</div> </div>
); );
} }
@@ -1,32 +0,0 @@
"use client";
import { Heart, Sparkles } from "lucide-react";
import type { ChatSupportCtaKind } from "../chat-support-cta";
export interface ChatSupportButtonProps {
kind: ChatSupportCtaKind;
label: string;
onClick: () => void;
}
export function ChatSupportButton({
kind,
label,
onClick,
}: ChatSupportButtonProps) {
const Icon = kind === "support" ? Heart : Sparkles;
return (
<button
type="button"
data-analytics-key="chat.support_character"
data-support-state={kind}
className="inline-flex min-h-9 max-w-full items-center justify-center gap-1.5 rounded-full border border-[rgba(255,255,255,0.22)] bg-[linear-gradient(135deg,rgba(246,87,160,0.96),rgba(255,119,84,0.94))] px-3 py-1.5 text-center text-[12px] font-extrabold leading-tight text-white shadow-[0_8px_24px_rgba(246,87,160,0.3)] backdrop-blur-md transition active:scale-95"
aria-label={label}
onClick={onClick}
>
<Icon className="shrink-0" size={14} strokeWidth={2.5} aria-hidden="true" />
<span className="whitespace-normal text-balance">{label}</span>
</button>
);
}
@@ -19,7 +19,6 @@ export function ChatUnlockDialogs({
lockedCount={model.historyUnlock.lockedCount} lockedCount={model.historyUnlock.lockedCount}
isLoading={model.historyUnlock.isLoading} isLoading={model.historyUnlock.isLoading}
errorMessage={model.historyUnlock.errorMessage} errorMessage={model.historyUnlock.errorMessage}
guidance={model.historyUnlock.guidance}
onClose={model.closeHistoryUnlock} onClose={model.closeHistoryUnlock}
onConfirm={model.confirmHistoryUnlock} onConfirm={model.confirmHistoryUnlock}
/> />
@@ -28,7 +27,6 @@ export function ChatUnlockDialogs({
creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0} creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0} requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0} shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
guidance={model.unlockPaywallRequest?.paymentGuidance ?? null}
onClose={model.closePaywall} onClose={model.closePaywall}
onConfirm={model.confirmPaywall} onConfirm={model.confirmPaywall}
/> />
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ArrowRight, BadgePercent, Gift, Images, LoaderCircle, X } from "lucide-react"; import { ArrowRight, Gift, Images, X } from "lucide-react";
import type { CommercialAction } from "@/data/schemas/chat"; import type { CommercialAction } from "@/data/schemas/chat";
@@ -9,7 +9,7 @@ import styles from "./chat-area.module.css";
export interface CommercialActionCardProps { export interface CommercialActionCardProps {
action: CommercialAction; action: CommercialAction;
onActivate?: (action: CommercialAction) => void | Promise<void>; onActivate?: (action: CommercialAction) => void;
onDismiss?: (action: CommercialAction) => void; onDismiss?: (action: CommercialAction) => void;
} }
@@ -19,16 +19,9 @@ export function CommercialActionCard({
onDismiss, onDismiss,
}: CommercialActionCardProps) { }: CommercialActionCardProps) {
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const [activating, setActivating] = useState(false);
const [activationError, setActivationError] = useState<string | null>(null);
if (dismissed) return null; if (dismissed) return null;
const Icon = const Icon = action.type === "giftOffer" ? Gift : Images;
action.type === "giftOffer"
? Gift
: action.type === "discountOffer"
? BadgePercent
: Images;
return ( return (
<aside <aside
@@ -52,29 +45,13 @@ export function CommercialActionCard({
</button> </button>
</div> </div>
<p className={styles.commercialActionCopy}>{action.copy}</p> <p className={styles.commercialActionCopy}>{action.copy}</p>
{activationError ? (
<p className={styles.commercialActionError} role="alert">
{activationError}
</p>
) : null}
<button <button
type="button" type="button"
className={styles.commercialActionButton} className={styles.commercialActionButton}
disabled={activating} onClick={() => onActivate?.(action)}
onClick={() => {
setActivating(true);
setActivationError(null);
void Promise.resolve(onActivate?.(action))
.catch(() => setActivationError("This private offer is unavailable right now. Please try again."))
.finally(() => setActivating(false));
}}
> >
<span>{action.ctaLabel}</span> <span>{action.ctaLabel}</span>
{activating ? (
<LoaderCircle className={styles.commercialActionSpinner} size={17} aria-hidden="true" />
) : (
<ArrowRight size={17} aria-hidden="true" /> <ArrowRight size={17} aria-hidden="true" />
)}
</button> </button>
</aside> </aside>
); );
@@ -0,0 +1,223 @@
.banner {
position: relative;
flex: 0 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
margin:
clamp(5px, 1.111vw, 6px)
var(--chat-inline-padding, 16px)
clamp(8px, 1.852vw, 10px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: var(--responsive-card-radius-sm, 22px);
background:
radial-gradient(circle at 14% 0%, rgba(255, 255, 255, 0.72), transparent 34%),
linear-gradient(135deg, #fff0b8 0%, #ffd1df 45%, #ff61a7 100%);
box-shadow: 0 14px 34px rgba(114, 21, 63, 0.22);
color: #2c111d;
animation: firstRechargeBannerIn 260ms ease-out both;
}
.banner::after {
position: absolute;
right: clamp(-34px, -6.296vw, -28px);
bottom: clamp(-52px, -9.63vw, -42px);
width: clamp(104px, 23.333vw, 126px);
height: clamp(104px, 23.333vw, 126px);
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
content: "";
pointer-events: none;
}
.contentButton {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(8px, 1.852vw, 10px);
align-items: center;
min-width: 0;
padding:
clamp(10px, 2.222vw, 12px)
clamp(7px, 1.481vw, 8px)
clamp(10px, 2.222vw, 12px)
clamp(12px, 2.593vw, 14px);
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.iconWrap {
display: inline-flex;
width: clamp(30px, 6.296vw, 34px);
height: clamp(30px, 6.296vw, 34px);
align-items: center;
justify-content: center;
border-radius: clamp(12px, 2.593vw, 14px);
background: rgba(255, 255, 255, 0.52);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.42);
}
.icon {
color: #fb2f89;
}
.copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.eyebrow {
color: rgba(44, 17, 29, 0.74);
font-size: var(--responsive-micro, 11px);
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1;
text-transform: uppercase;
}
.title {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--responsive-inline-gap-xs, 5px);
align-items: baseline;
color: #241019;
font-size: var(--responsive-caption, 13px);
font-weight: 750;
line-height: 1.18;
}
.title strong {
color: #f90073;
font-size: var(--responsive-section-title, 20px);
font-weight: 950;
letter-spacing: -0.04em;
line-height: 1;
}
.arrow {
color: rgba(44, 17, 29, 0.58);
}
.closeButton {
position: relative;
z-index: 1;
display: flex;
width: var(--responsive-icon-button-size, 42px);
min-height: 100%;
align-items: center;
justify-content: center;
border: 0;
border-left: 1px solid rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.16);
color: rgba(44, 17, 29, 0.68);
cursor: pointer;
}
.compactButton {
display: grid;
width: min(100%, 240px);
min-width: 0;
max-width: 240px;
min-height: 50px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: 16px;
background:
radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.68), transparent 38%),
linear-gradient(135deg, #fff0b8 0%, #ffd0df 50%, #ff6cab 100%);
color: #2c111d;
cursor: pointer;
font: inherit;
text-align: left;
box-shadow: 0 9px 24px rgba(114, 21, 63, 0.2);
animation: firstRechargeBannerIn 260ms ease-out both;
}
.compactIcon {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
color: #f90073;
}
.compactCopy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
font-size: 11px;
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
}
.compactCopy span,
.compactCopy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactCopy span {
color: rgba(44, 17, 29, 0.68);
letter-spacing: 0.03em;
}
.compactCopy strong {
color: #ec006d;
font-size: 17px;
font-weight: 950;
letter-spacing: -0.02em;
}
.contentButton:focus-visible,
.closeButton:focus-visible,
.compactButton:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.94);
outline-offset: -3px;
}
@keyframes firstRechargeBannerIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 360px) {
.title {
flex-direction: column;
gap: 2px;
}
.compactButton {
gap: 5px;
padding-inline: 6px;
}
.compactIcon {
width: 26px;
height: 26px;
}
}
@@ -0,0 +1,78 @@
"use client";
import { ChevronRight, Sparkles, X } from "lucide-react";
import styles from "./first-recharge-offer-banner.module.css";
export interface FirstRechargeOfferBannerProps {
visible: boolean;
discountPercent: number;
onClick: () => void;
onClose: () => void;
variant?: "banner" | "compact";
}
export function FirstRechargeOfferBanner({
visible,
discountPercent,
onClick,
onClose,
variant = "banner",
}: FirstRechargeOfferBannerProps) {
if (!visible) return null;
if (variant === "compact") {
return (
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.compactButton}
aria-label={`First recharge offer, ${discountPercent}% off`}
onClick={onClick}
>
<span className={styles.compactIcon} aria-hidden="true">
<Sparkles size={14} strokeWidth={2.4} />
</span>
<span className={styles.compactCopy}>
<span>First recharge</span>
<strong>{discountPercent}% OFF</strong>
</span>
<ChevronRight size={14} strokeWidth={2.4} aria-hidden="true" />
</button>
);
}
return (
<section className={styles.banner} aria-label="First recharge offer">
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.contentButton}
onClick={onClick}
>
<span className={styles.iconWrap} aria-hidden="true">
<Sparkles className={styles.icon} size={18} />
</span>
<span className={styles.copy}>
<span className={styles.eyebrow}>First Recharge Offer</span>
<span className={styles.title}>
<strong>{discountPercent}% OFF</strong>
<span>Claim your discount now</span>
</span>
</span>
<ChevronRight className={styles.arrow} size={18} aria-hidden="true" />
</button>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Hide first recharge offer"
>
<X size={16} aria-hidden="true" />
</button>
</section>
);
}
@@ -1,13 +1,10 @@
"use client"; "use client";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface HistoryUnlockDialogProps { export interface HistoryUnlockDialogProps {
open: boolean; open: boolean;
lockedCount: number; lockedCount: number;
isLoading?: boolean; isLoading?: boolean;
errorMessage?: string | null; errorMessage?: string | null;
guidance?: PaymentGuidance | null;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
} }
@@ -17,15 +14,10 @@ export function HistoryUnlockDialog({
lockedCount, lockedCount,
isLoading = false, isLoading = false,
errorMessage, errorMessage,
guidance = null,
onClose, onClose,
onConfirm, onConfirm,
}: HistoryUnlockDialogProps) { }: HistoryUnlockDialogProps) {
if (!open) return null; if (!open) return null;
const activeGuidance = guidance;
const showAction =
!activeGuidance ||
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
return ( return (
<div <div
@@ -39,16 +31,11 @@ export function HistoryUnlockDialog({
id="history-unlock-title" id="history-unlock-title"
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground" className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
> >
{activeGuidance?.title ?? "Unlock previous messages?"} Unlock previous messages?
</h2> </h2>
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]"> <p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
{activeGuidance?.copy ?? ( We found {lockedCount} locked messages in your chat history. You can
<> unlock them now to continue the conversation with full context.
We found {lockedCount} locked messages in your chat history. You
can unlock them now to continue the conversation with full
context.
</>
)}
</p> </p>
{errorMessage ? ( {errorMessage ? (
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]"> <p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]">
@@ -64,7 +51,7 @@ export function HistoryUnlockDialog({
> >
Later Later
</button> </button>
{showAction ? <button <button
type="button" type="button"
data-analytics-key="chat.unlock_history" data-analytics-key="chat.unlock_history"
data-analytics-label="Unlock chat history" data-analytics-label="Unlock chat history"
@@ -72,10 +59,8 @@ export function HistoryUnlockDialog({
onClick={onConfirm} onClick={onConfirm}
disabled={isLoading} disabled={isLoading}
> >
{isLoading {isLoading ? "Unlocking..." : "Unlock now"}
? "Unlocking..." </button>
: activeGuidance?.ctaLabel ?? "Unlock now"}
</button> : null}
</div> </div>
</div> </div>
</div> </div>
+1 -3
View File
@@ -6,15 +6,14 @@ export * from "./ai-disclosure-banner";
export * from "./browser-hint-overlay"; export * from "./browser-hint-overlay";
export * from "./chat-area"; export * from "./chat-area";
export * from "./commercial-action-card"; export * from "./commercial-action-card";
export * from "./chat-action-card";
export * from "./chat-header"; export * from "./chat-header";
export * from "./chat-insufficient-credits-banner"; export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar"; export * from "./chat-input-bar";
export * from "./chat-input-text-field"; export * from "./chat-input-text-field";
export * from "./chat-send-button"; export * from "./chat-send-button";
export * from "./chat-support-button";
export * from "./chat-unlock-dialogs"; export * from "./chat-unlock-dialogs";
export * from "./date-header"; export * from "./date-header";
export * from "./first-recharge-offer-banner";
export * from "./fullscreen-image-viewer"; export * from "./fullscreen-image-viewer";
export * from "./history-unlock-dialog"; export * from "./history-unlock-dialog";
export * from "./image-bubble"; export * from "./image-bubble";
@@ -22,7 +21,6 @@ export * from "./insufficient-credits-dialog";
export * from "./message-avatar"; export * from "./message-avatar";
export * from "./message-bubble"; export * from "./message-bubble";
export * from "./message-content"; export * from "./message-content";
export * from "./payment-guidance-card";
export * from "./private-message-card"; export * from "./private-message-card";
export * from "./pwa-install-dialog"; export * from "./pwa-install-dialog";
export * from "./pwa-install-overlay"; export * from "./pwa-install-overlay";
@@ -1,13 +1,10 @@
"use client"; "use client";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface InsufficientCreditsDialogProps { export interface InsufficientCreditsDialogProps {
open: boolean; open: boolean;
creditBalance: number; creditBalance: number;
requiredCredits: number; requiredCredits: number;
shortfallCredits: number; shortfallCredits: number;
guidance?: PaymentGuidance | null;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
} }
@@ -17,17 +14,12 @@ export function InsufficientCreditsDialog({
creditBalance, creditBalance,
requiredCredits, requiredCredits,
shortfallCredits, shortfallCredits,
guidance = null,
onClose, onClose,
onConfirm, onConfirm,
}: InsufficientCreditsDialogProps) { }: InsufficientCreditsDialogProps) {
if (!open) return null; if (!open) return null;
const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0; const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0;
const activeGuidance = guidance;
const showPurchaseAction =
!activeGuidance ||
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
return ( return (
<div <div
@@ -41,11 +33,10 @@ export function InsufficientCreditsDialog({
id="insufficient-credits-title" id="insufficient-credits-title"
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground" className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
> >
{activeGuidance?.title ?? "Not enough credits"} Not enough credits
</h2> </h2>
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]"> <p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
{activeGuidance?.copy ?? Top up your credits to unlock this message and keep the moment going.
"Top up your credits to unlock this message and keep the moment going."}
</p> </p>
{showCreditDetail ? ( {showCreditDetail ? (
@@ -79,15 +70,15 @@ export function InsufficientCreditsDialog({
> >
Later Later
</button> </button>
{showPurchaseAction ? <button <button
type="button" type="button"
data-analytics-key="chat.topup" data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits" data-analytics-label="Top up chat credits"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]" className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={onConfirm} onClick={onConfirm}
> >
{activeGuidance?.ctaLabel ?? "Top up now"} Top up now
</button> : null} </button>
</div> </div>
</div> </div>
</div> </div>
+1 -16
View File
@@ -9,7 +9,7 @@
* - [ spacer] [Content] [Avatar] * - [ spacer] [Content] [Avatar]
*/ */
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
import type { ChatAction, CommercialAction, PaymentGuidance } from "@/data/schemas/chat"; import type { CommercialAction } from "@/data/schemas/chat";
import { MessageAvatar } from "./message-avatar"; import { MessageAvatar } from "./message-avatar";
import { MessageContent } from "./message-content"; import { MessageContent } from "./message-content";
@@ -29,8 +29,6 @@ export interface MessageBubbleProps {
lockedPrivate?: boolean | null; lockedPrivate?: boolean | null;
privateMessageHint?: string | null; privateMessageHint?: string | null;
commercialAction?: CommercialAction | null; commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
paymentGuidance?: PaymentGuidance | null;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
@@ -40,9 +38,6 @@ export interface MessageBubbleProps {
onCharacterAvatarClick?: () => void; onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void; onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (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 = (
@@ -64,8 +59,6 @@ export function MessageBubble({
lockedPrivate, lockedPrivate,
privateMessageHint, privateMessageHint,
commercialAction, commercialAction,
chatAction,
paymentGuidance,
isUnlockingMessage, isUnlockingMessage,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockVoiceMessage, onUnlockVoiceMessage,
@@ -75,9 +68,6 @@ export function MessageBubble({
onCharacterAvatarClick, onCharacterAvatarClick,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl); const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -107,8 +97,6 @@ export function MessageBubble({
lockedPrivate={lockedPrivate} lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint} privateMessageHint={privateMessageHint}
commercialAction={commercialAction} commercialAction={commercialAction}
chatAction={chatAction}
paymentGuidance={paymentGuidance}
isUnlockingMessage={isUnlockingMessage} isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
@@ -116,9 +104,6 @@ export function MessageBubble({
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
onCommercialAction={onCommercialAction} onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss} onCommercialActionDismiss={onCommercialActionDismiss}
onChatActionViewed={onChatActionViewed}
onChatAction={onChatAction}
onChatActionDismiss={onChatActionDismiss}
/> />
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" /> <div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div> </div>
+2 -27
View File
@@ -1,16 +1,10 @@
"use client"; "use client";
import type { import type { CommercialAction } from "@/data/schemas/chat";
ChatAction,
CommercialAction,
PaymentGuidance,
} from "@/data/schemas/chat";
import { ChatActionCard } from "./chat-action-card";
import { CommercialActionCard } from "./commercial-action-card"; import { CommercialActionCard } from "./commercial-action-card";
import { ImageBubble } from "./image-bubble"; import { ImageBubble } from "./image-bubble";
import { LockedImageMessageCard } from "./locked-image-message-card"; import { LockedImageMessageCard } from "./locked-image-message-card";
import { PrivateMessageCard } from "./private-message-card"; import { PrivateMessageCard } from "./private-message-card";
import { PaymentGuidanceCard } from "./payment-guidance-card";
import { TextBubble } from "./text-bubble"; import { TextBubble } from "./text-bubble";
import { VoiceBubble } from "./voice-bubble"; import { VoiceBubble } from "./voice-bubble";
import styles from "./chat-area.module.css"; import styles from "./chat-area.module.css";
@@ -29,8 +23,6 @@ export interface MessageContentProps {
lockedPrivate?: boolean | null; lockedPrivate?: boolean | null;
privateMessageHint?: string | null; privateMessageHint?: string | null;
commercialAction?: CommercialAction | null; commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
paymentGuidance?: PaymentGuidance | null;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
@@ -38,9 +30,6 @@ export interface MessageContentProps {
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (displayMessageId: string) => void;
onCommercialAction?: (action: CommercialAction) => void; onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (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 = (
@@ -64,8 +53,6 @@ export function MessageContent({
lockedPrivate, lockedPrivate,
privateMessageHint, privateMessageHint,
commercialAction, commercialAction,
chatAction,
paymentGuidance,
isUnlockingMessage, isUnlockingMessage,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockVoiceMessage, onUnlockVoiceMessage,
@@ -73,9 +60,6 @@ export function MessageContent({
onOpenImage, onOpenImage,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: MessageContentProps) { }: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0; const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0;
@@ -150,16 +134,7 @@ export function MessageContent({
) : null} ) : null}
</> </>
)} )}
{isFromAI && paymentGuidance ? ( {isFromAI && commercialAction ? (
<PaymentGuidanceCard guidance={paymentGuidance} />
) : isFromAI && chatAction ? (
<ChatActionCard
action={chatAction}
onViewed={onChatActionViewed}
onActivate={onChatAction}
onDismiss={onChatActionDismiss}
/>
) : isFromAI && commercialAction ? (
<CommercialActionCard <CommercialActionCard
action={commercialAction} action={commercialAction}
onActivate={onCommercialAction} onActivate={onCommercialAction}
@@ -1,118 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
import type { PaymentGuidance } from "@/data/schemas/chat";
import type { PaymentAnalyticsTriggerReason } from "@/lib/analytics/payment_analytics_context";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useActiveCharacter } from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import styles from "./chat-area.module.css";
export interface PaymentGuidanceCardProps {
guidance: PaymentGuidance;
}
function triggerReasonForGuidance(
scene: PaymentGuidance["scene"],
): PaymentAnalyticsTriggerReason {
if (scene === "supportCharacter" || scene === "vip" || scene === "purchaseOptions") {
return "vip_cta";
}
if (scene === "privateMessageLocked" || scene === "historyLocked") {
return "private_topic_limit";
}
return "insufficient_credits";
}
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
const character = useActiveCharacter();
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const [dismissed, setDismissed] = useState(false);
const viewedIdRef = useRef<string | null>(null);
const belongsToCurrentCharacter = guidance.characterId === character.id;
const hasPurchaseAction =
guidance.mode === "guide" &&
guidance.target === "subscription" &&
guidance.ctaLabel !== null &&
guidance.purchaseOptions.length > 0;
useEffect(() => {
if (!belongsToCurrentCharacter) return;
if (viewedIdRef.current === guidance.guidanceId) return;
viewedIdRef.current = guidance.guidanceId;
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"viewed",
).catch(() => undefined);
}, [belongsToCurrentCharacter, character.id, guidance.guidanceId]);
if (!belongsToCurrentCharacter || dismissed) return null;
const subscriptionType =
isVip || !guidance.purchaseOptions.includes("vip") ? "topup" : "vip";
return (
<aside
className={styles.commercialAction}
aria-label={guidance.title ?? guidance.characterName}
data-payment-guidance-id={guidance.guidanceId}
data-payment-guidance-mode={guidance.mode}
data-payment-guidance-scene={guidance.scene}
>
<div className={styles.commercialActionHeader}>
<CircleDollarSign size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss"
aria-label="Dismiss"
onClick={() => {
setDismissed(true);
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"dismissed",
).catch(() => undefined);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{guidance.title ? (
<strong>{guidance.title}</strong>
) : null}
<p className={styles.commercialActionCopy}>{guidance.copy}</p>
{hasPurchaseAction ? (
<button
type="button"
className={styles.commercialActionButton}
onClick={() => {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
navigator.openSubscription({
type: subscriptionType,
returnTo: "chat",
chatActionId: guidance.guidanceId,
analytics: {
entryPoint: "chat_input",
triggerReason: triggerReasonForGuidance(guidance.scene),
},
});
}}
>
<span>{guidance.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
</button>
) : null}
</aside>
);
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { shouldShowFirstRechargeOfferBanner } from "../use-first-recharge-offer-banner";
describe("shouldShowFirstRechargeOfferBanner", () => {
it("shows for authenticated users when first recharge offer is available", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "facebook",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(true);
});
it("does not show for guest users", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "guest",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
it("does not show unless isFirstRecharge is true", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "google",
isFirstRecharge: false,
dismissed: false,
}),
).toBe(false);
});
it("does not show after the user dismisses it", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "email",
isFirstRecharge: true,
dismissed: true,
}),
).toBe(false);
});
it("waits until chat history has loaded", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: false,
loginStatus: "email",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
});
@@ -1,44 +0,0 @@
"use client";
import { useEffect } from "react";
import { createChatWebSocket } from "@/core/net/chat-websocket";
import type { LoginStatus } from "@/data/schemas/auth";
import { getSessionAccessToken } from "@/lib/auth/auth_session";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { usePaymentDispatch } from "@/stores/payment/payment-context";
export function useChatCommercialMessages(input: {
characterId: string;
loginStatus: LoginStatus;
}) {
const chatDispatch = useChatDispatch();
const paymentDispatch = usePaymentDispatch();
useEffect(() => {
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return;
}
let disposed = false;
let socket: ReturnType<typeof createChatWebSocket> | null = null;
void getSessionAccessToken().then((token) => {
if (disposed || !token) return;
socket = createChatWebSocket(token);
socket.onCommercialMessage = (message) => {
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
});
if (message.characterId !== input.characterId) return;
chatDispatch({ type: "ChatCommercialMessageReceived", message });
};
socket.connect();
});
return () => {
disposed = true;
socket?.disconnect();
};
}, [chatDispatch, input.characterId, input.loginStatus, paymentDispatch]);
}
@@ -5,9 +5,6 @@ import { useEffect, useRef } from "react";
import { behaviorAnalytics } from "@/lib/analytics"; import { behaviorAnalytics } from "@/lib/analytics";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state"; import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useActiveCharacter } from "@/providers/character-provider";
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
import { import {
@@ -20,14 +17,10 @@ import {
export interface UseChatMessageLimitBannerInput { export interface UseChatMessageLimitBannerInput {
upgradePromptVisible: boolean; upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null; upgradeReason: ChatUpgradeReason | null;
paymentGuidance: PaymentGuidance | null;
} }
export interface ChatMessageLimitBannerView { export interface ChatMessageLimitBannerView
title: string; extends InsufficientCreditsMessageLimitView {
description: string;
ctaLabel: string | null;
guidance: PaymentGuidance | null;
visible: boolean; visible: boolean;
unlock: () => void; unlock: () => void;
} }
@@ -35,19 +28,10 @@ export interface ChatMessageLimitBannerView {
export function useChatMessageLimitBanner({ export function useChatMessageLimitBanner({
upgradePromptVisible, upgradePromptVisible,
upgradeReason, upgradeReason,
paymentGuidance,
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView { }: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const character = useActiveCharacter();
const isVip = useUserSelector((state) => state.context.isVip); const isVip = useUserSelector((state) => state.context.isVip);
const fallbackView = getInsufficientCreditsMessageLimitView(); const view = getInsufficientCreditsMessageLimitView();
const guidance =
paymentGuidance?.characterId === character.id ? paymentGuidance : null;
const view: InsufficientCreditsMessageLimitView = {
title: guidance?.title ?? fallbackView.title,
description: guidance?.copy ?? fallbackView.description,
ctaLabel: guidance?.ctaLabel ?? fallbackView.ctaLabel,
};
const visible = shouldShowMessageLimitBanner({ const visible = shouldShowMessageLimitBanner({
upgradePromptVisible, upgradePromptVisible,
upgradeReason, upgradeReason,
@@ -68,31 +52,12 @@ export function useChatMessageLimitBanner({
}, },
{ isVip }, { isVip },
); );
if (guidance) { }, [isVip, visible]);
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"viewed",
).catch(() => undefined);
}
}, [character.id, guidance, isVip, visible]);
function unlock(): void { function unlock(): void {
if (guidance?.mode !== "guide" && guidance !== null) return;
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({ navigator.openSubscription({
type: type: getInsufficientCreditsSubscriptionType(isVip),
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: getInsufficientCreditsSubscriptionType(isVip),
returnTo: "chat", returnTo: "chat",
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
analytics: { analytics: {
entryPoint: "chat_input", entryPoint: "chat_input",
triggerReason: "insufficient_credits", triggerReason: "insufficient_credits",
@@ -102,9 +67,6 @@ export function useChatMessageLimitBanner({
return { return {
...view, ...view,
ctaLabel:
guidance && guidance.mode !== "guide" ? null : view.ctaLabel,
guidance,
visible, visible,
unlock, unlock,
}; };
-124
View File
@@ -1,124 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { useUserSelector } from "@/stores/user/user-context";
import { deriveChatSupportCta } from "../chat-support-cta";
export function useChatSupportCta(input: {
characterId: string;
historyLoaded: boolean;
loginStatus: LoginStatus;
}) {
const navigator = useAppNavigator();
const paymentDispatch = usePaymentDispatch();
const initializedCharacterRef = useRef<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const payment = usePaymentSelector(
(state) => ({
commercialOffer: state.context.commercialOffer,
isFirstRecharge: state.context.isFirstRecharge,
supportCharacterId: state.context.supportCharacterId,
}),
shallowEqual,
);
const countryCode = useUserSelector(
(state) => state.context.currentUser?.countryCode,
);
const isAuthenticated =
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
const refreshCatalog = useCallback(() => {
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
payChannel: getDefaultPayChannelForCountryCode(countryCode),
});
}, [countryCode, input.characterId, paymentDispatch]);
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
refreshCatalog();
}, [input.characterId, isAuthenticated, refreshCatalog]);
useEffect(() => {
if (!payment.commercialOffer) return;
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [payment.commercialOffer]);
useEffect(() => {
if (!isAuthenticated) return;
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") refreshCatalog();
};
window.addEventListener("focus", refreshCatalog);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", refreshCatalog);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isAuthenticated, refreshCatalog]);
useEffect(() => {
if (!isAuthenticated || !payment.commercialOffer) return;
const expiresAtMs = Date.parse(payment.commercialOffer.expiresAt);
if (!Number.isFinite(expiresAtMs)) return;
const timer = window.setTimeout(() => {
setNowMs(Date.now());
refreshCatalog();
}, Math.max(0, expiresAtMs - Date.now()) + 250);
return () => window.clearTimeout(timer);
}, [isAuthenticated, payment.commercialOffer, refreshCatalog]);
const cta = useMemo(
() =>
deriveChatSupportCta({
isFirstRecharge:
isAuthenticated && payment.supportCharacterId === input.characterId
? payment.isFirstRecharge
: false,
commercialOffer:
isAuthenticated && payment.supportCharacterId === input.characterId
? payment.commercialOffer
: null,
nowMs,
}),
[
input.characterId,
isAuthenticated,
nowMs,
payment.commercialOffer,
payment.isFirstRecharge,
payment.supportCharacterId,
],
);
const open = () => {
navigator.openSubscription({
type: cta.planId?.startsWith("dol_") ? "topup" : "vip",
returnTo: "chat",
...(cta.planId ? { planId: cta.planId } : {}),
...(cta.commercialOfferId
? { commercialOfferId: cta.commercialOfferId }
: {}),
analytics: {
entryPoint: "chat_offer_banner",
triggerReason:
cta.kind === "support" ? "vip_cta" : "commercial_offer",
},
});
};
return { ...cta, visible: input.historyLoaded, open };
}
@@ -20,7 +20,6 @@ import {
import type { ChatPromotionState } from "@/stores/chat/helper/promotion"; import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import type { UiMessage } from "@/stores/chat/ui-message"; import type { UiMessage } from "@/stores/chat/ui-message";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
@@ -49,7 +48,6 @@ export interface ChatUnlockDialogModel {
lockedCount: number; lockedCount: number;
isLoading: boolean; isLoading: boolean;
errorMessage: string | null; errorMessage: string | null;
guidance: import("@/data/schemas/chat").PaymentGuidance | null;
}; };
closeHistoryUnlock: () => void; closeHistoryUnlock: () => void;
confirmHistoryUnlock: () => void; confirmHistoryUnlock: () => void;
@@ -89,8 +87,6 @@ export function useChatUnlockCoordinator({
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount, lockedHistoryCount: state.context.lockedHistoryCount,
unlockHistoryError: state.context.unlockHistoryError, unlockHistoryError: state.context.unlockHistoryError,
unlockHistoryPaymentGuidance:
state.context.unlockHistoryPaymentGuidance,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
unlockPaywallRequest: state.context.unlockPaywallRequest, unlockPaywallRequest: state.context.unlockPaywallRequest,
}), }),
@@ -101,20 +97,6 @@ export function useChatUnlockCoordinator({
? chatState.unlockPaywallRequest ? chatState.unlockPaywallRequest
: null; : null;
useEffect(() => {
const guidance = chatState.unlockHistoryPaymentGuidance;
if (!enabled || guidance?.characterId !== chatState.characterId) return;
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"viewed",
).catch(() => undefined);
}, [
chatState.characterId,
chatState.unlockHistoryPaymentGuidance,
enabled,
]);
useEffect(() => { useEffect(() => {
if (!unlockPaywallRequest) { if (!unlockPaywallRequest) {
trackedPaywallRef.current = null; trackedPaywallRef.current = null;
@@ -138,15 +120,7 @@ export function useChatUnlockCoordinator({
}, },
{ isVip }, { isVip },
); );
const guidance = unlockPaywallRequest.paymentGuidance; }, [isVip, unlockPaywallRequest]);
if (guidance?.characterId === chatState.characterId) {
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"viewed",
).catch(() => undefined);
}
}, [chatState.characterId, isVip, unlockPaywallRequest]);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
@@ -238,32 +212,6 @@ export function useChatUnlockCoordinator({
} }
function confirmHistoryUnlock(): void { function confirmHistoryUnlock(): void {
const guidance =
chatState.unlockHistoryPaymentGuidance?.characterId ===
chatState.characterId
? chatState.unlockHistoryPaymentGuidance
: null;
if (guidance) {
if (guidance.mode !== "guide") return;
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"opened",
).catch(() => undefined);
navigator.openSubscription({
type:
isVip || !guidance.purchaseOptions.includes("vip")
? "topup"
: "vip",
returnTo: "chat",
chatActionId: guidance.guidanceId,
analytics: {
entryPoint: "chat_unlock",
triggerReason: "insufficient_credits",
},
});
return;
}
chatDispatch({ type: "ChatUnlockHistoryConfirmed" }); chatDispatch({ type: "ChatUnlockHistoryConfirmed" });
} }
@@ -273,12 +221,6 @@ export function useChatUnlockCoordinator({
function confirmPaywall(): void { function confirmPaywall(): void {
if (!unlockPaywallRequest) return; if (!unlockPaywallRequest) return;
const guidance =
unlockPaywallRequest.paymentGuidance?.characterId ===
chatState.characterId
? unlockPaywallRequest.paymentGuidance
: null;
if (guidance && guidance.mode !== "guide") return;
const returnUrl = resolveChatUnlockReturnUrl( const returnUrl = resolveChatUnlockReturnUrl(
unlockPaywallRequest, unlockPaywallRequest,
@@ -289,13 +231,6 @@ export function useChatUnlockCoordinator({
}, },
); );
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"opened",
).catch(() => undefined);
}
navigator.openSubscriptionForPendingUnlock({ navigator.openSubscriptionForPendingUnlock({
displayMessageId: unlockPaywallRequest.displayMessageId, displayMessageId: unlockPaywallRequest.displayMessageId,
messageId: unlockPaywallRequest.messageId, messageId: unlockPaywallRequest.messageId,
@@ -304,17 +239,13 @@ export function useChatUnlockCoordinator({
clientLockId: unlockPaywallRequest.clientLockId, clientLockId: unlockPaywallRequest.clientLockId,
promotion: unlockPaywallRequest.promotion, promotion: unlockPaywallRequest.promotion,
returnUrl, returnUrl,
type: type: getInsufficientCreditsSubscriptionType(isVip),
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: getInsufficientCreditsSubscriptionType(isVip),
analytics: { analytics: {
entryPoint: "chat_unlock", entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion triggerReason: unlockPaywallRequest.promotion
? "ad_landing" ? "ad_landing"
: "insufficient_credits", : "insufficient_credits",
}, },
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
}); });
} }
@@ -327,7 +258,6 @@ export function useChatUnlockCoordinator({
lockedCount: chatState.lockedHistoryCount, lockedCount: chatState.lockedHistoryCount,
isLoading: chatState.isUnlockingHistory, isLoading: chatState.isUnlockingHistory,
errorMessage: chatState.unlockHistoryError, errorMessage: chatState.unlockHistoryError,
guidance: chatState.unlockHistoryPaymentGuidance,
}, },
closeHistoryUnlock, closeHistoryUnlock,
confirmHistoryUnlock, confirmHistoryUnlock,
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { useUserSelector } from "@/stores/user/user-context";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import {
getFirstRechargeOfferBannerDismissed,
recordFirstRechargeOfferBannerDismissed,
} from "@/lib/chat/first_recharge_offer_banner";
export interface FirstRechargeOfferBannerEligibility {
historyLoaded: boolean;
loginStatus: LoginStatus;
isFirstRecharge: boolean;
dismissed: boolean;
}
export interface UseFirstRechargeOfferBannerInput {
historyLoaded: boolean;
loginStatus: LoginStatus;
}
export interface UseFirstRechargeOfferBannerOutput {
visible: boolean;
discountPercent: number;
close: () => void;
claim: () => void;
}
interface DismissalState {
userId: string | null;
loaded: boolean;
dismissed: boolean;
}
const FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT = 50;
export function shouldShowFirstRechargeOfferBanner(
input: FirstRechargeOfferBannerEligibility,
): boolean {
if (!input.historyLoaded) return false;
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return false;
}
if (!input.isFirstRecharge) return false;
return !input.dismissed;
}
export function useFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
const navigator = useAppNavigator();
const payment = usePaymentSelector(
(state) => ({
isFirstRecharge: state.context.isFirstRecharge,
status: String(state.value),
}),
shallowEqual,
);
const paymentDispatch = usePaymentDispatch();
const user = useUserSelector(
(state) => ({
countryCode: state.context.currentUser?.countryCode,
id: state.context.currentUser?.id ?? null,
}),
shallowEqual,
);
const isAuthenticatedUser =
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
const userId = user.id;
const hasUserIdentity = !isAuthenticatedUser || Boolean(userId);
const [dismissalState, setDismissalState] = useState<DismissalState>(() => ({
userId,
loaded: false,
dismissed: false,
}));
const discountPercent = FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT;
useEffect(() => {
if (!isAuthenticatedUser) return;
if (payment.status !== "idle") return;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
user.countryCode,
);
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
}, [
payment.status,
paymentDispatch,
isAuthenticatedUser,
user.countryCode,
]);
useEffect(() => {
let cancelled = false;
const loadDismissal = async () => {
const dismissed = await getFirstRechargeOfferBannerDismissed(userId);
if (cancelled) return;
setDismissalState({
userId,
loaded: true,
dismissed,
});
};
void loadDismissal();
return () => {
cancelled = true;
};
}, [userId]);
const dismissalLoaded =
dismissalState.userId === userId && dismissalState.loaded;
const visible =
hasUserIdentity &&
dismissalLoaded &&
shouldShowFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
isFirstRecharge: payment.isFirstRecharge,
dismissed: dismissalState.dismissed,
});
function close(): void {
setDismissalState({
userId,
loaded: true,
dismissed: true,
});
void recordFirstRechargeOfferBannerDismissed(userId);
}
function claim(): void {
navigator.openSubscription({
type: "vip",
returnTo: "chat",
analytics: {
entryPoint: "chat_offer_banner",
triggerReason: "vip_cta",
},
});
}
return {
visible,
discountPercent,
close,
claim,
};
}
@@ -14,20 +14,9 @@ vi.mock("@/stores/auth/auth-context", () => ({
vi.mock("@/stores/user/user-context", () => ({ vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => vi.fn(), useUserDispatch: () => vi.fn(),
useUserState: () => ({ useUserState: () => ({
currentUser: null, currentUser: {
entitlements: { dailyFreeChatLimit: 30,
quotas: { dailyFreePrivateLimit: 2,
normalChatFreeDaily: 47,
privateUnlockFreeDaily: 5,
},
costs: {
normal_message: 3,
private_message: 9,
voice_message: 11,
photo: 13,
private_album_10: 101,
private_album_20: 181,
},
}, },
}), }),
})); }));
@@ -42,11 +31,5 @@ describe("CoinsRulesScreen", () => {
expect(html).toContain( expect(html).toContain(
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"', 'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
); );
expect(html).toContain("47 free messages daily");
expect(html).toContain("5 free private messages daily");
expect(html).toContain("3 coins / message");
expect(html).toContain("10 photos / 101 coins");
expect(html).not.toContain("30 free messages daily");
expect(html).not.toContain("2 free private messages daily");
}); });
}); });
+23 -59
View File
@@ -19,6 +19,10 @@ import {
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import {
FREE_PRIVATE_MESSAGE_DAILY_COUNT,
FREE_STANDARD_CHAT_DAILY_COUNT,
} from "./coins-rules.constants";
import styles from "./coins-rules-screen.module.css"; import styles from "./coins-rules-screen.module.css";
interface CoinRuleItem { interface CoinRuleItem {
@@ -31,44 +35,25 @@ interface CoinRuleItem {
const formatDailyFreeMessages = (count: number, label: string): string => const formatDailyFreeMessages = (count: number, label: string): string =>
`${Math.max(0, Math.trunc(count))} free ${label} daily`; `${Math.max(0, Math.trunc(count))} free ${label} daily`;
const formatCoinCost = (cost: number | null, unit: string): string =>
cost === null
? "Current rate shown at unlock"
: `${Math.max(0, Math.trunc(cost))} coins / ${unit}`;
const createCoinRules = ( const createCoinRules = (
dailyFreeChatLimit: number | null, dailyFreeChatLimit: number,
dailyFreePrivateLimit: number | null, dailyFreePrivateLimit: number,
costs: {
normalMessage: number | null;
privateMessage: number | null;
voiceMessage: number | null;
photo: number | null;
privateAlbum10: number | null;
privateAlbum20: number | null;
},
): readonly CoinRuleItem[] => [ ): readonly CoinRuleItem[] => [
{ {
title: "Standard Chat", title: "Standard Chat",
cost: formatCoinCost(costs.normalMessage, "message"), cost: "2 coins / message",
free: free: formatDailyFreeMessages(dailyFreeChatLimit, "messages"),
dailyFreeChatLimit === null
? "Current daily allowance loads from your account"
: formatDailyFreeMessages(dailyFreeChatLimit, "messages"),
icon: MessageCircle, icon: MessageCircle,
}, },
{ {
title: "Private Chat", title: "Private Chat",
cost: formatCoinCost(costs.privateMessage, "message"), cost: "10 coins / message",
free: free: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"),
dailyFreePrivateLimit === null
? "Current daily allowance loads from your account"
: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"),
icon: Sparkles, icon: Sparkles,
}, },
{ {
title: "Voice Message", title: "Voice Message",
cost: formatCoinCost(costs.voiceMessage, "message"), cost: "20 coins / message",
icon: Mic2, icon: Mic2,
}, },
// { // {
@@ -78,23 +63,17 @@ const createCoinRules = (
// }, // },
{ {
title: "Photo", title: "Photo",
cost: formatCoinCost(costs.photo, "photo"), cost: "40 coins / photo",
icon: ImageIcon, icon: ImageIcon,
}, },
{ {
title: "Private Album", title: "Private Album",
cost: cost: "10 photos / 300 coins",
costs.privateAlbum10 === null
? "Current price shown in Private Zone"
: `10 photos / ${Math.max(0, Math.trunc(costs.privateAlbum10))} coins`,
icon: ImageIcon, icon: ImageIcon,
}, },
{ {
title: "Private Album", title: "Private Album",
cost: cost: "20 photos / 600 coins",
costs.privateAlbum20 === null
? "Current price shown in Private Zone"
: `20 photos / ${Math.max(0, Math.trunc(costs.privateAlbum20))} coins`,
icon: ImageIcon, icon: ImageIcon,
}, },
] as const; ] as const;
@@ -108,35 +87,20 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
const authState = useAuthState(); const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const entitlements = userState.entitlements;
const dailyFreeChatLimit = const dailyFreeChatLimit =
entitlements?.quotas.normalChatFreeDaily ?? userState.currentUser?.dailyFreeChatLimit ?? FREE_STANDARD_CHAT_DAILY_COUNT;
userState.currentUser?.dailyFreeChatLimit ??
null;
const dailyFreePrivateLimit = const dailyFreePrivateLimit =
entitlements?.quotas.privateUnlockFreeDaily ??
userState.currentUser?.dailyFreePrivateLimit ?? userState.currentUser?.dailyFreePrivateLimit ??
null; FREE_PRIVATE_MESSAGE_DAILY_COUNT;
const standardChatLabel = const standardChatLabel = formatDailyFreeMessages(
dailyFreeChatLimit === null
? "the current daily allowance shown for your account"
: formatDailyFreeMessages(dailyFreeChatLimit, "messages");
const privateChatLabel =
dailyFreePrivateLimit === null
? "the current private allowance shown for your account"
: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages");
const coinRules = createCoinRules(
dailyFreeChatLimit, dailyFreeChatLimit,
dailyFreePrivateLimit, "messages",
{
normalMessage: entitlements?.costs.normal_message ?? null,
privateMessage: entitlements?.costs.private_message ?? null,
voiceMessage: entitlements?.costs.voice_message ?? null,
photo: entitlements?.costs.photo ?? null,
privateAlbum10: entitlements?.costs.private_album_10 ?? null,
privateAlbum20: entitlements?.costs.private_album_20 ?? null,
},
); );
const privateChatLabel = formatDailyFreeMessages(
dailyFreePrivateLimit,
"private messages",
);
const coinRules = createCoinRules(dailyFreeChatLimit, dailyFreePrivateLimit);
useEffect(() => { useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return; if (!authState.hasInitialized || authState.isLoading) return;
@@ -0,0 +1,2 @@
export const FREE_STANDARD_CHAT_DAILY_COUNT = 30;
export const FREE_PRIVATE_MESSAGE_DAILY_COUNT = 2;
@@ -21,8 +21,6 @@ import {
} from "@/data/constants/character"; } from "@/data/constants/character";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
import { Result } from "@/utils/result";
import { import {
isFavoriteEntryRequest, isFavoriteEntryRequest,
persistFavoriteEntryIntent, persistFavoriteEntryIntent,
@@ -40,7 +38,6 @@ interface ExternalEntryPersistProps {
mode: string | null; mode: string | null;
promotionType: string | null; promotionType: string | null;
favorite: string | null; favorite: string | null;
handoffToken: string | null;
} }
export default function ExternalEntryPersist({ export default function ExternalEntryPersist({
@@ -53,16 +50,13 @@ export default function ExternalEntryPersist({
mode, mode,
promotionType, promotionType,
favorite, favorite,
handoffToken,
}: ExternalEntryPersistProps) { }: ExternalEntryPersistProps) {
const router = useRouter(); const router = useRouter();
const authState = useAuthState(); const authState = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const [hasPersisted, setHasPersisted] = useState(false); const [hasPersisted, setHasPersisted] = useState(false);
const [handoffError, setHandoffError] = useState<string | null>(null);
const [handoffCompleted, setHandoffCompleted] = useState(false);
const hasNavigatedRef = useRef(false); const hasNavigatedRef = useRef(false);
const handoffStartedRef = useRef(false); const hasReinitializedForPsidRef = useRef(false);
const targetRoute = resolveExternalEntryTarget({ target }); const targetRoute = resolveExternalEntryTarget({ target });
const destination = resolveExternalEntryDestination({ target, character }); const destination = resolveExternalEntryDestination({ target, character });
const characterId = const characterId =
@@ -71,12 +65,6 @@ export default function ExternalEntryPersist({
mode, mode,
promotionType, promotionType,
}); });
const isTopUpHandoff = targetRoute === ROUTES.subscription;
const displayedHandoffError =
handoffError ??
(isTopUpHandoff && !hasValue(handoffToken)
? "This top-up link is invalid. Please request a new link in Messenger."
: null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -128,39 +116,12 @@ export default function ExternalEntryPersist({
useEffect(() => { useEffect(() => {
if (hasNavigatedRef.current || !hasPersisted) return; if (hasNavigatedRef.current || !hasPersisted) return;
if (isTopUpHandoff) { // AuthStatusChecker can initialize before this entry has persisted PSID.
// Re-run the check so PSID direct login is not skipped by that race.
if (hasValue(psid) && !hasReinitializedForPsidRef.current) {
if (!authState.hasInitialized || authState.isLoading) return; if (!authState.hasInitialized || authState.isLoading) return;
if (handoffCompleted) { hasReinitializedForPsidRef.current = true;
if (
authState.loginStatus === "notLoggedIn" ||
authState.loginStatus === "guest"
) {
return;
}
hasNavigatedRef.current = true;
router.replace(destination);
return;
}
if (handoffStartedRef.current) return;
if (!hasValue(handoffToken)) return;
handoffStartedRef.current = true;
void (async () => {
const result = await consumeTopUpHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=topup",
);
setHandoffError(
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
);
return;
}
setHandoffCompleted(true);
authDispatch({ type: "AuthInit" }); authDispatch({ type: "AuthInit" });
})();
return; return;
} }
@@ -189,12 +150,8 @@ export default function ExternalEntryPersist({
authState.hasInitialized, authState.hasInitialized,
authState.isLoading, authState.isLoading,
authState.loginStatus, authState.loginStatus,
character,
destination, destination,
hasPersisted, hasPersisted,
handoffCompleted,
handoffToken,
isTopUpHandoff,
psid, psid,
router, router,
]); ]);
@@ -204,26 +161,11 @@ export default function ExternalEntryPersist({
className="flex flex-1 items-center justify-center" className="flex flex-1 items-center justify-center"
style={{ minHeight: "100dvh" }} style={{ minHeight: "100dvh" }}
> >
{displayedHandoffError ? (
<div className="mx-6 max-w-sm text-center">
<p className="text-sm text-[var(--color-text-secondary)]">
{displayedHandoffError}
</p>
<button
className="mt-5 rounded-full bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white"
type="button"
onClick={() => router.replace(ROUTES.root)}
>
Back to Cozsweet
</button>
</div>
) : (
<div <div
aria-label="Redirecting" aria-label="Redirecting"
className="size-10 animate-spin rounded-full border-4 border-t-transparent" className="size-10 animate-spin rounded-full border-4 border-t-transparent"
style={{ borderColor: "var(--color-accent)" }} style={{ borderColor: "var(--color-accent)" }}
/> />
)}
</div> </div>
); );
} }
-3
View File
@@ -6,8 +6,6 @@
* `/external-entry?target=chat&mode=promotion&promotion_type=voice` * `/external-entry?target=chat&mode=promotion&promotion_type=voice`
* `/external-entry?target=chat&character=maya` * `/external-entry?target=chat&character=maya`
* `/external-entry?target=tip` * `/external-entry?target=tip`
* `/external-entry?target=private-zone&character=nayeli`
* `/external-entry?target=topup&handoffToken=<opaque-token>`
* *
* `redirect()` Client * `redirect()` Client
* `router.replace()` URL * `router.replace()` URL
@@ -37,7 +35,6 @@ export default async function ExternalEntryPage({
mode={pickParam(params.mode)} mode={pickParam(params.mode)}
promotionType={pickParam(params.promotion_type)} promotionType={pickParam(params.promotion_type)}
favorite={pickParam(params.favorite)} favorite={pickParam(params.favorite)}
handoffToken={pickParam(params.handoffToken)}
/> />
); );
} }
@@ -58,13 +58,13 @@ describe("Private Zone paywall navigation", () => {
container.remove(); container.remove();
}); });
it("opens VIP and credit options for a non-VIP user", () => { it("opens top up with a private zone return target", () => {
const roomDispatch = vi.fn<Dispatch<PrivateZoneEvent>>(); const roomDispatch = vi.fn<Dispatch<PrivateZoneEvent>>();
renderHarness(root, "email", roomDispatch); renderHarness(root, "email", roomDispatch);
expect(mocks.openSubscription).toHaveBeenCalledWith({ expect(mocks.openSubscription).toHaveBeenCalledWith({
type: "vip", type: "topup",
returnTo: "private-zone", returnTo: "private-zone",
analytics: { analytics: {
entryPoint: "private_album_unlock", entryPoint: "private_album_unlock",
@@ -115,7 +115,6 @@ function Harness({
loginStatus, loginStatus,
roomDispatch, roomDispatch,
unlockPaywallRequest, unlockPaywallRequest,
isVip: false,
}); });
return null; return null;
} }
@@ -1,67 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PrivateZoneVideoPostCard } from "../private-zone-video-post-card";
const basePost = {
postId: "11111111-1111-1111-1111-111111111111",
characterId: "maya-tan",
caption: "Only for you.",
posterUrl: "https://storage.example/poster.jpg?token=short",
mediaType: "video" as const,
videoUrl: null,
videoMimeType: "video/mp4",
videoSizeBytes: 1024,
durationSeconds: 18,
unlockCostCredits: 100,
currency: "credits" as const,
locked: true,
unlocked: false,
availableForNewUnlock: true,
publishedAt: "2026-07-24T10:00:00+00:00",
};
describe("PrivateZoneVideoPostCard", () => {
it("shows caption and poster but no video source while locked", () => {
const html = renderToStaticMarkup(
<PrivateZoneVideoPostCard
post={basePost}
displayName="Maya"
avatarUrl="/images/avatar.png"
index={0}
isUnlocking={false}
onUnlock={() => undefined}
onPlaybackError={() => undefined}
/>,
);
expect(html).toContain("Only for you.");
expect(html).toContain("poster.jpg");
expect(html).toContain("Unlock for 100 credits");
expect(html).not.toContain("<video");
});
it("renders an inline controlled video only after unlock", () => {
const html = renderToStaticMarkup(
<PrivateZoneVideoPostCard
post={{
...basePost,
locked: false,
unlocked: true,
videoUrl: "https://storage.example/video.mp4?token=short",
}}
displayName="Maya"
avatarUrl="/images/avatar.png"
index={0}
isUnlocking={false}
onUnlock={() => undefined}
onPlaybackError={() => undefined}
/>,
);
expect(html).toContain("<video");
expect(html).toContain("controls");
expect(html).toContain("playsInline");
expect(html).not.toContain("autoplay");
});
});
-2
View File
@@ -1,6 +1,4 @@
export * from "./private-album-card"; export * from "./private-album-card";
export * from "./private-album-gallery"; export * from "./private-album-gallery";
export * from "./private-zone-video-post-card";
export * from "./status-card"; export * from "./status-card";
export * from "./unlock-confirm-dialog"; export * from "./unlock-confirm-dialog";
export * from "./video-post-unlock-dialog";
@@ -1,125 +0,0 @@
import type { CSSProperties } from "react";
import { LockKeyhole, Play } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
import styles from "../private-zone-screen.module.css";
export interface PrivateZoneVideoPostCardProps {
post: PrivateZoneVideoPost;
displayName: string;
avatarUrl: string;
index: number;
isUnlocking: boolean;
onUnlock: () => void;
onPlaybackError: () => void;
}
export function PrivateZoneVideoPostCard({
post,
displayName,
avatarUrl,
index,
isUnlocking,
onUnlock,
onPlaybackError,
}: PrivateZoneVideoPostCardProps) {
return (
<article
className={styles.postCard}
style={{ "--reveal-index": index } as CSSProperties}
>
<header className={styles.postHeader}>
<div className={styles.postAuthor}>
<CharacterAvatar
src={avatarUrl}
alt=""
size={38}
imageSize={38}
className={styles.postAvatar}
/>
<div>
<p className={styles.authorName}>{displayName}</p>
<p className={styles.authorSubline}>Private moment</p>
</div>
</div>
<time className={styles.postTime}>{formatMomentTime(post.publishedAt)}</time>
</header>
{post.caption ? <p className={styles.postText}>{post.caption}</p> : null}
{post.unlocked && post.videoUrl ? (
<video
className={styles.momentVideo}
controls
playsInline
preload="metadata"
poster={post.posterUrl ?? undefined}
onError={onPlaybackError}
>
<source src={post.videoUrl} type={post.videoMimeType || "video/mp4"} />
Your browser cannot play this video.
</video>
) : (
<div className={styles.momentLockedMedia}>
{post.posterUrl ? (
// Signed private media URLs intentionally bypass Next image optimization.
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.momentPoster}
src={post.posterUrl}
alt="Private video cover"
/>
) : (
<div className={styles.momentPosterFallback}>
<Play size={34} aria-hidden="true" />
</div>
)}
<div className={styles.momentLockPanel}>
<span className={styles.momentLockIcon} aria-hidden="true">
<LockKeyhole size={18} />
</span>
<strong>Private video</strong>
<span>
{post.durationSeconds == null
? "Unlock to watch"
: `${formatDuration(post.durationSeconds)} · Unlock to watch`}
</span>
<button
type="button"
data-analytics-key="private_zone_video.unlock"
data-analytics-label="Unlock Private Zone video moment"
disabled={isUnlocking || !post.availableForNewUnlock}
onClick={onUnlock}
>
{isUnlocking
? "Unlocking..."
: post.availableForNewUnlock
? `Unlock for ${post.unlockCostCredits} credits`
: "No longer available"}
</button>
</div>
</div>
)}
</article>
);
}
function formatDuration(seconds: number): string {
const safe = Math.max(0, Math.round(seconds));
const minutes = Math.floor(safe / 60);
const remainder = String(safe % 60).padStart(2, "0");
return `${minutes}:${remainder}`;
}
function formatMomentTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Just now";
const seconds = Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000));
if (seconds < 60) return "Just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`;
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
@@ -1,54 +0,0 @@
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
import styles from "../private-zone-screen.module.css";
export interface VideoPostUnlockDialogProps {
post: PrivateZoneVideoPost;
isUnlocking: boolean;
errorMessage: string | null;
onCancel: () => void;
onConfirm: () => void;
}
export function VideoPostUnlockDialog({
post,
isUnlocking,
errorMessage,
onCancel,
onConfirm,
}: VideoPostUnlockDialogProps) {
return (
<div className={styles.dialogOverlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<h2 className={styles.dialogTitle}>Unlock this private video?</h2>
<p className={styles.dialogCopy}>
You&apos;ll use {post.unlockCostCredits} credits. The video stays in your
Private Zone after unlocking.
</p>
{errorMessage ? (
<p className={styles.dialogError}>{errorMessage}</p>
) : null}
<div className={styles.dialogActions}>
<button
type="button"
className={styles.dialogSecondary}
disabled={isUnlocking}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
data-analytics-key="private_zone_video.unlock_confirm"
data-analytics-label="Confirm Private Zone video unlock"
className={styles.dialogPrimary}
disabled={isUnlocking}
onClick={onConfirm}
>
{isUnlocking ? "Unlocking..." : "Unlock"}
</button>
</div>
</div>
</div>
);
}
@@ -215,38 +215,6 @@
box-shadow: 0 8px 18px rgba(131, 72, 85, 0.08); box-shadow: 0 8px 18px rgba(131, 72, 85, 0.08);
} }
.contentTabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-bottom: 18px;
padding: 5px;
border: 1px solid rgba(255, 116, 159, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 24px rgba(131, 72, 85, 0.07);
}
.contentTab {
min-height: 42px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: #8a7078;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 850;
transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.contentTabActive {
background: linear-gradient(135deg, #ff7aa9, #ffad79);
color: #ffffff;
box-shadow: 0 8px 18px rgba(248, 77, 150, 0.2);
}
.timeline { .timeline {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -295,118 +263,6 @@
margin: 0; margin: 0;
} }
.momentVideo,
.momentLockedMedia {
width: 100%;
margin-top: 14px;
border-radius: clamp(18px, 4.815vw, 26px);
background: #161316;
box-shadow: 0 16px 34px rgba(34, 25, 29, 0.16);
}
.momentVideo {
display: block;
max-height: 68vh;
aspect-ratio: 9 / 12;
object-fit: contain;
}
.momentLockedMedia {
position: relative;
aspect-ratio: 9 / 12;
overflow: hidden;
}
.momentPoster,
.momentPosterFallback {
display: block;
width: 100%;
height: 100%;
}
.momentPoster {
object-fit: cover;
}
.momentPosterFallback {
display: grid;
place-items: center;
background:
radial-gradient(circle at 70% 15%, rgba(255, 179, 109, 0.34), transparent 42%),
linear-gradient(145deg, #4f4249, #211b20);
color: rgba(255, 255, 255, 0.78);
}
.momentLockPanel {
position: absolute;
right: 14px;
bottom: 14px;
left: 14px;
display: flex;
min-height: 164px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 7px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 22px;
background: rgba(23, 19, 23, 0.7);
color: #ffffff;
text-align: center;
backdrop-filter: blur(14px);
}
.momentLockIcon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.momentLockPanel strong {
font-size: 20px;
font-weight: 900;
}
.momentLockPanel span:not(.momentLockIcon) {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 650;
}
.momentLockPanel button,
.loadMoreButton {
min-height: 44px;
padding: 0 22px;
border: 0;
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 850;
}
.momentLockPanel button {
margin-top: 9px;
background: #ffffff;
color: #2c2227;
}
.momentLockPanel button:disabled {
cursor: wait;
opacity: 0.7;
}
.loadMoreButton {
display: block;
margin: 16px auto 0;
background: rgba(255, 93, 149, 0.12);
color: #a94c64;
}
.authorName { .authorName {
color: #26191d; color: #26191d;
font-size: clamp(14px, 3.333vw, 17px); font-size: clamp(14px, 3.333vw, 17px);
@@ -920,9 +776,6 @@
} }
.primaryCta:focus-visible, .primaryCta:focus-visible,
.contentTab:focus-visible,
.momentLockPanel button:focus-visible,
.loadMoreButton:focus-visible,
.lockedPreviewCta:focus-visible, .lockedPreviewCta:focus-visible,
.mediaGridItem:focus-visible, .mediaGridItem:focus-visible,
.galleryClose:focus-visible, .galleryClose:focus-visible,
+7 -190
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
@@ -18,26 +18,17 @@ import {
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album"; import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album";
import { behaviorAnalytics } from "@/lib/analytics";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useUserSelector } from "@/stores/user/user-context";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { import {
usePrivateZoneDispatch, usePrivateZoneDispatch,
usePrivateZoneState, usePrivateZoneState,
} from "@/stores/private-zone"; } from "@/stores/private-zone";
import {
usePrivateZonePostDispatch,
usePrivateZonePostState,
} from "@/stores/private-zone-posts";
import { import {
PrivateAlbumCard, PrivateAlbumCard,
PrivateAlbumGallery, PrivateAlbumGallery,
PrivateZoneVideoPostCard,
StatusCard, StatusCard,
UnlockConfirmDialog, UnlockConfirmDialog,
VideoPostUnlockDialog,
} from "./components"; } from "./components";
import styles from "./private-zone-screen.module.css"; import styles from "./private-zone-screen.module.css";
import { import {
@@ -63,17 +54,11 @@ export function PrivateZoneScreen() {
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const state = usePrivateZoneState(); const state = usePrivateZoneState();
const dispatch = usePrivateZoneDispatch(); const dispatch = usePrivateZoneDispatch();
const postState = usePrivateZonePostState();
const postDispatch = usePrivateZonePostDispatch();
const isVip = useUserSelector((user) => user.context.isVip);
const [activeTab, setActiveTab] = useState<"moments" | "albums">("albums");
const openedGalleryInPageRef = useRef(false); const openedGalleryInPageRef = useRef(false);
const previousPostLoginStatusRef = useRef(authState.loginStatus);
const galleryState = useMemo( const galleryState = useMemo(
() => getPrivateAlbumGalleryState(searchParams), () => getPrivateAlbumGalleryState(searchParams),
[searchParams], [searchParams],
); );
const visibleTab = galleryState ? "albums" : activeTab;
usePrivateZoneBootstrapFlow({ usePrivateZoneBootstrapFlow({
authDispatch, authDispatch,
@@ -87,76 +72,8 @@ export function PrivateZoneScreen() {
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
roomDispatch: dispatch, roomDispatch: dispatch,
unlockPaywallRequest: state.unlockPaywallRequest, unlockPaywallRequest: state.unlockPaywallRequest,
isVip,
}); });
usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce); usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce);
usePrivateZoneUnlockSuccessRefresh(postState.unlockSuccessNonce);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus === "notLoggedIn") return;
const previous = previousPostLoginStatusRef.current;
previousPostLoginStatusRef.current = authState.loginStatus;
if (postState.status === "idle") {
postDispatch({ type: "PrivateZonePostInit" });
return;
}
if (previous !== authState.loginStatus && postState.status !== "loading") {
postDispatch({ type: "PrivateZonePostRefresh" });
}
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
postDispatch,
postState.status,
]);
useEffect(() => {
const request = postState.unlockPaywallRequest;
if (!request) return;
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
navigator.openAuth(characterRoutes.privateZone);
} else {
behaviorAnalytics.paywallShown({
entryPoint: "private_zone",
triggerReason: "insufficient_credits",
});
const guidance =
request.paymentGuidance?.characterId === character.id
? request.paymentGuidance
: null;
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({
type:
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: "vip",
returnTo: "private-zone",
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
analytics: {
entryPoint: "private_zone",
triggerReason: "insufficient_credits",
},
});
}
postDispatch({ type: "PrivateZonePostUnlockPaywallConsumed" });
}, [
authState.loginStatus,
characterRoutes.privateZone,
character.id,
isVip,
navigator,
postDispatch,
postState.unlockPaywallRequest,
]);
const displayName = character.displayName; const displayName = character.displayName;
const avatarUrl = character.assets.avatar; const avatarUrl = character.assets.avatar;
@@ -169,13 +86,6 @@ export function PrivateZoneScreen() {
) ?? null, ) ?? null,
[state.items, state.pendingConfirmAlbumId], [state.items, state.pendingConfirmAlbumId],
); );
const pendingPost = useMemo(
() =>
postState.items.find(
(post) => post.postId === postState.pendingConfirmPostId,
) ?? null,
[postState.items, postState.pendingConfirmPostId],
);
const galleryAlbum = useMemo( const galleryAlbum = useMemo(
() => () =>
galleryState galleryState
@@ -226,7 +136,7 @@ export function PrivateZoneScreen() {
return; return;
} }
navigator.openSubscription({ navigator.openSubscription({
type: isVip ? "topup" : "vip", type: "topup",
returnTo: "private-zone", returnTo: "private-zone",
analytics: { analytics: {
entryPoint: "private_zone", entryPoint: "private_zone",
@@ -247,14 +157,6 @@ export function PrivateZoneScreen() {
); );
}; };
const handlePostUnlock = (postId: string) => {
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
navigator.openAuth(characterRoutes.privateZone);
return;
}
postDispatch({ type: "PrivateZonePostUnlockRequested", postId });
};
const handleCloseGallery = () => { const handleCloseGallery = () => {
if (openedGalleryInPageRef.current) { if (openedGalleryInPageRef.current) {
openedGalleryInPageRef.current = false; openedGalleryInPageRef.current = false;
@@ -333,83 +235,16 @@ export function PrivateZoneScreen() {
</section> </section>
<section className={styles.postsSection} aria-labelledby="posts-title"> <section className={styles.postsSection} aria-labelledby="posts-title">
<div className={styles.contentTabs} role="tablist" aria-label="Private Zone content">
<button
type="button"
role="tab"
aria-selected={visibleTab === "albums"}
className={`${styles.contentTab} ${visibleTab === "albums" ? styles.contentTabActive : ""}`}
onClick={() => setActiveTab("albums")}
>
Albums
</button>
<button
type="button"
role="tab"
aria-selected={visibleTab === "moments"}
className={`${styles.contentTab} ${visibleTab === "moments" ? styles.contentTabActive : ""}`}
onClick={() => setActiveTab("moments")}
>
Moments
</button>
</div>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<div> <div>
<p className={styles.kicker}>{visibleTab === "moments" ? "Moments" : "Albums"}</p> <p className={styles.kicker}>Albums</p>
<h2 id="posts-title" className={styles.sectionTitle}> <h2 id="posts-title" className={styles.sectionTitle}>
{visibleTab === "moments" ? "Private moments" : "Private albums"} Private albums
</h2> </h2>
</div> </div>
<span className={styles.postCount}> <span className={styles.postCount}>{state.items.length}</span>
{visibleTab === "moments" ? postState.items.length : state.items.length}
</span>
</div> </div>
{visibleTab === "moments" ? (
<>
{postState.errorMessage ? (
<StatusCard
message={postState.errorMessage}
actionLabel="Retry"
onAction={() => postDispatch({ type: "PrivateZonePostRefresh" })}
/>
) : null}
{postState.isLoading && postState.items.length === 0 ? (
<StatusCard message="Loading private moments..." />
) : null}
{!postState.isLoading && !postState.errorMessage && postState.items.length === 0 ? (
<StatusCard message="No private moments yet." />
) : null}
<div className={styles.timeline}>
{postState.items.map((post, index) => (
<PrivateZoneVideoPostCard
key={post.postId}
post={post}
displayName={displayName}
avatarUrl={avatarUrl}
index={index}
isUnlocking={postState.unlockingPostId === post.postId}
onUnlock={() => handlePostUnlock(post.postId)}
onPlaybackError={() =>
postDispatch({ type: "PrivateZonePostRefresh" })
}
/>
))}
</div>
{postState.hasMore ? (
<button
type="button"
className={styles.loadMoreButton}
disabled={postState.isLoadingMore}
onClick={() => postDispatch({ type: "PrivateZonePostLoadMore" })}
>
{postState.isLoadingMore ? "Loading..." : "Load more"}
</button>
) : null}
</>
) : (
<>
{state.errorMessage ? ( {state.errorMessage ? (
<StatusCard <StatusCard
message={state.errorMessage} message={state.errorMessage}
@@ -417,9 +252,11 @@ export function PrivateZoneScreen() {
onAction={() => dispatch({ type: "PrivateZoneRefresh" })} onAction={() => dispatch({ type: "PrivateZoneRefresh" })}
/> />
) : null} ) : null}
{state.isLoading && state.items.length === 0 ? ( {state.isLoading && state.items.length === 0 ? (
<StatusCard message="Loading private albums..." /> <StatusCard message="Loading private albums..." />
) : null} ) : null}
<div className={styles.timeline}> <div className={styles.timeline}>
{state.items.map((album, index) => ( {state.items.map((album, index) => (
<PrivateAlbumCard <PrivateAlbumCard
@@ -441,8 +278,6 @@ export function PrivateZoneScreen() {
/> />
))} ))}
</div> </div>
</>
)}
</section> </section>
<AppBottomNav <AppBottomNav
@@ -474,24 +309,6 @@ export function PrivateZoneScreen() {
</div> </div>
) : null} ) : null}
{pendingPost ? (
<VideoPostUnlockDialog
post={pendingPost}
isUnlocking={postState.isUnlocking}
errorMessage={postState.unlockErrorMessage}
onCancel={() =>
postDispatch({ type: "PrivateZonePostUnlockCancelled" })
}
onConfirm={() =>
postDispatch({ type: "PrivateZonePostUnlockConfirmed" })
}
/>
) : postState.unlockErrorMessage ? (
<div className={styles.toast} role="status">
{postState.unlockErrorMessage}
</div>
) : null}
{galleryAlbum && {galleryAlbum &&
galleryState && galleryState &&
!isPrivateAlbumLocked(galleryAlbum) && !isPrivateAlbumLocked(galleryAlbum) &&
+2 -28
View File
@@ -5,16 +5,12 @@ import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap"; import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics"; import { behaviorAnalytics } from "@/lib/analytics";
import { import { useActiveCharacterRoutes } from "@/providers/character-provider";
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events"; import type { AuthEvent } from "@/stores/auth/auth-events";
import type { PrivateZoneEvent } from "@/stores/private-zone"; import type { PrivateZoneEvent } from "@/stores/private-zone";
import type { PrivateZoneUnlockPaywallRequest } from "@/stores/private-zone/private-zone-state"; import type { PrivateZoneUnlockPaywallRequest } from "@/stores/private-zone/private-zone-state";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
export interface UsePrivateZoneBootstrapFlowInput { export interface UsePrivateZoneBootstrapFlowInput {
authDispatch: Dispatch<AuthEvent>; authDispatch: Dispatch<AuthEvent>;
@@ -29,7 +25,6 @@ export interface UsePrivateZoneUnlockPaywallNavigationInput {
loginStatus: LoginStatus; loginStatus: LoginStatus;
roomDispatch: Dispatch<PrivateZoneEvent>; roomDispatch: Dispatch<PrivateZoneEvent>;
unlockPaywallRequest: PrivateZoneUnlockPaywallRequest | null; unlockPaywallRequest: PrivateZoneUnlockPaywallRequest | null;
isVip: boolean;
} }
export function isPrivateZoneAuthRequired(loginStatus: LoginStatus): boolean { export function isPrivateZoneAuthRequired(loginStatus: LoginStatus): boolean {
@@ -81,10 +76,8 @@ export function usePrivateZoneUnlockPaywallNavigation({
loginStatus, loginStatus,
roomDispatch, roomDispatch,
unlockPaywallRequest, unlockPaywallRequest,
isVip,
}: UsePrivateZoneUnlockPaywallNavigationInput): void { }: UsePrivateZoneUnlockPaywallNavigationInput): void {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
useEffect(() => { useEffect(() => {
@@ -97,26 +90,9 @@ export function usePrivateZoneUnlockPaywallNavigation({
entryPoint: "private_album_unlock", entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits", triggerReason: "insufficient_credits",
}); });
const guidance = unlockPaywallRequest.paymentGuidance;
const activeGuidance =
guidance?.characterId === character.id ? guidance : null;
if (activeGuidance) {
void recordChatActionEventById(
activeGuidance.guidanceId,
activeGuidance.characterId,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({ navigator.openSubscription({
type: type: "topup",
isVip ||
(activeGuidance && !activeGuidance.purchaseOptions.includes("vip"))
? "topup"
: "vip",
returnTo: "private-zone", returnTo: "private-zone",
...(activeGuidance
? { chatActionId: activeGuidance.guidanceId }
: {}),
analytics: { analytics: {
entryPoint: "private_album_unlock", entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits", triggerReason: "insufficient_credits",
@@ -126,9 +102,7 @@ export function usePrivateZoneUnlockPaywallNavigation({
roomDispatch({ type: "PrivateZoneUnlockPaywallConsumed" }); roomDispatch({ type: "PrivateZoneUnlockPaywallConsumed" });
}, [ }, [
characterRoutes.privateZone, characterRoutes.privateZone,
character.id,
loginStatus, loginStatus,
isVip,
navigator, navigator,
roomDispatch, roomDispatch,
unlockPaywallRequest, unlockPaywallRequest,
@@ -34,14 +34,14 @@ describe("SubscriptionPage", () => {
); );
}); });
it("keeps the recipient empty when source navigation has no character", async () => { it("uses Elio for missing source navigation context", async () => {
const page = await SubscriptionPage({ const page = await SubscriptionPage({
searchParams: Promise.resolve({}), searchParams: Promise.resolve({}),
}); });
renderToStaticMarkup(page); renderToStaticMarkup(page);
expect(mocks.screenProps).toHaveBeenLastCalledWith( expect(mocks.screenProps).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: null }), expect.objectContaining({ sourceCharacterSlug: "elio" }),
); );
}); });
}); });
@@ -1,466 +0,0 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommercialOfferSummary } from "@/data/schemas/payment";
const mocks = vi.hoisted(() => {
const characters = [
{ id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio" },
{ id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya" },
{
id: "nayeli-cervantes",
slug: "nayeli",
displayName: "Nayeli Cervantes",
shortName: "Nayeli",
},
];
const payment = {
status: "ready",
plans: [
{
planId: "vip_monthly",
planName: "Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
amountCents: 1990,
originalAmountCents: 3990,
currency: "USD",
},
{
planId: "vip_quarterly",
planName: "Quarterly",
orderType: "vip_quarterly",
vipDays: 90,
dolAmount: null,
amountCents: 4990,
originalAmountCents: 6990,
currency: "USD",
},
{
planId: "coin_1000",
planName: "1000 Coins",
orderType: "coins_1000",
vipDays: null,
dolAmount: 1000,
amountCents: 990,
originalAmountCents: null,
currency: "USD",
},
],
isFirstRecharge: false,
commercialOffer: null as CommercialOfferSummary | null,
selectedPlanId: "vip_monthly",
payChannel: "stripe" as const,
agreed: true,
currentOrderId: null,
isCreatingOrder: false,
isPollingOrder: false,
isLoadingPlans: false,
};
const paymentDispatch = vi.fn((event: { type: string; planId?: string }) => {
if (event.type === "PaymentPlanSelected" && event.planId) {
payment.selectedPlanId = event.planId;
}
});
return {
characterCatalog: {
characters,
getBySlug: (slug: string | null) =>
characters.find((character) => character.slug === slug) ?? null,
},
payment,
paymentDispatch,
paymentFlowInput: vi.fn(),
planClick: vi.fn(),
};
});
vi.mock("@/app/_components", () => ({
BackButton: () => <button type="button">Back</button>,
}));
vi.mock("@/app/_components/core", () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/app/_components/payment/payment-method-selector", () => ({
PaymentMethodSelector: () => <div>Payment methods</div>,
}));
vi.mock("@/app/_hooks/use-payment-method-selection", () => ({
usePaymentMethodSelection: () => undefined,
}));
vi.mock("@/app/_hooks/use-payment-plan-analytics", () => ({
usePaymentPlanAnalytics: () => undefined,
}));
vi.mock("@/hooks/use-has-hydrated", () => ({
useHasHydrated: () => true,
}));
vi.mock("@/stores/user/user-context", () => ({
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
}));
vi.mock("@/providers/character-catalog-provider", () => ({
useCharacterCatalog: () => mocks.characterCatalog,
}));
vi.mock("@/lib/payment/payment_method", () => ({
getPaymentMethodConfig: () => ({
initialPayChannel: "stripe",
showPaymentMethodSelector: true,
ezpayDisplayName: "QRIS",
}),
}));
vi.mock("@/lib/analytics", () => ({
behaviorAnalytics: { planClick: mocks.planClick },
getDefaultPaymentAnalyticsContext: () => ({}),
}));
vi.mock("../use-subscription-payment-flow", () => ({
useSubscriptionPaymentFlow: (input: unknown) => {
mocks.paymentFlowInput(input);
return {
payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch,
showPaymentSuccessDialog: false,
handleBackClick: vi.fn(),
handlePaymentSuccessClose: vi.fn(),
};
},
}));
vi.mock("../components", () => ({
SubscriptionVipOfferSection: ({
plans,
onSelectPlan,
}: {
plans: Array<{ id: string }>;
onSelectPlan: (id: string) => void;
}) => (
<div>
{plans.map((plan) => (
<button key={plan.id} type="button" onClick={() => onSelectPlan(plan.id)}>
{plan.id}
</button>
))}
</div>
),
SubscriptionCoinsOfferSection: ({
plans,
onSelectPlan,
}: {
plans: Array<{ id: string }>;
onSelectPlan: (id: string) => void;
}) => (
<div>
{plans.map((plan) => (
<button key={plan.id} type="button" onClick={() => onSelectPlan(plan.id)}>
{plan.id}
</button>
))}
</div>
),
SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => (
<button type="button" data-testid="checkout" disabled={disabled}>
Pay and Top Up
</button>
),
SubscriptionRenewalConfirmationDialog: ({
open,
onCancel,
onConfirm,
}: {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}) =>
open ? (
<div role="dialog">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
) : null,
SubscriptionPaymentIssueDialog: () => null,
SubscriptionPaymentSuccessDialog: () => null,
}));
import { SubscriptionScreen } from "../subscription-screen";
describe("SubscriptionScreen payment selection flow", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.payment.isFirstRecharge = false;
mocks.payment.commercialOffer = null;
for (const plan of mocks.payment.plans) {
delete (plan as Record<string, unknown>).isFirstRechargeOffer;
delete (plan as Record<string, unknown>).firstRechargeDiscountPercent;
delete (plan as Record<string, unknown>).promotionType;
}
mocks.payment.selectedPlanId = "vip_monthly";
mocks.paymentDispatch.mockClear();
mocks.paymentFlowInput.mockClear();
mocks.planClick.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it("confirms VIP selection before enabling the separate checkout button", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(checkout?.disabled).toBe(true);
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
act(() => clickButton(container, "Confirm"));
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "vip_monthly",
});
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
expect(checkout?.disabled).toBe(false);
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
});
it("keeps the original selection when VIP confirmation is cancelled", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
act(() => clickButton(container, "Cancel"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "vip_quarterly",
});
});
it("requires VIP confirmation again after the page is remounted", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_monthly"));
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
});
it("selects a coin package without showing the renewal dialog", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "coin_1000"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "coin_1000",
});
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
});
it("keeps payment guidance bound to the source character", () => {
act(() =>
root.render(
<SubscriptionScreen
sourceCharacterSlug="maya"
chatActionId="019c8f8d-17d3-7a42-b1cc-b6927b1927d5"
/>,
),
);
const guidance = container.querySelector(
'[data-payment-guidance-character="maya-tan"]',
);
expect(guidance?.textContent).toContain("Keep talking with Maya");
expect(guidance?.textContent).toContain("support Maya");
expect(guidance?.textContent).toContain("cannot continue");
expect(guidance?.textContent).not.toContain("Elio");
});
it("does not render the first recharge banner on the subscription page", () => {
mocks.payment.isFirstRecharge = true;
Object.assign(mocks.payment.plans[0] as Record<string, unknown>, {
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
promotionType: "first_recharge_half_price",
});
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
expect(container.textContent).not.toContain("First Recharge Offer");
expect(container.textContent).not.toContain(
"Your first recharge price is already applied",
);
expect(container.textContent).not.toContain("50% OFF");
});
it("refreshes the catalog without a stale offer id when the page regains focus", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
mocks.paymentDispatch.mockClear();
act(() => window.dispatchEvent(new Event("focus")));
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentInit",
characterId: "elio",
payChannel: "stripe",
planId: "vip_monthly",
commercialOfferId: null,
});
});
it("describes an account offer without attributing it to the current role", () => {
mocks.payment.commercialOffer = {
enabled: true,
commercialOfferId: "offer-account-1",
characterId: "elio",
planId: "vip_monthly",
discountPercent: 50,
pricePercent: 50,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
triggerReason: "expired_order",
message: "Everything in top-up and VIP is 50% OFF.",
scope: "account",
stage: "halfDiscount",
};
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="maya" />));
expect(container.textContent).toContain("Private offer · 50% OFF");
expect(container.textContent).toContain("Your private price is active");
expect(container.textContent).not.toContain("Maya got this price for you");
});
it("refreshes the catalog when the active offer expires", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-30T08:00:00Z"));
mocks.payment.commercialOffer = {
enabled: true,
commercialOfferId: "offer-expiring-1",
characterId: "elio",
planId: "vip_monthly",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-30T08:00:01Z",
triggerReason: "expired_order",
message: "30% OFF",
scope: "account",
stage: "sevenDiscount",
};
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
mocks.paymentDispatch.mockClear();
act(() => vi.advanceTimersByTime(1_250));
expect(mocks.paymentDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "PaymentInit",
characterId: "elio",
commercialOfferId: null,
}),
);
});
it.each(["elio", "maya", "nayeli"])(
"inherits the %s chat character without rendering a recipient selector",
(sourceCharacterSlug) => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
root.render(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={sourceCharacterSlug}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).not.toContain("Supporting ");
expect(container.textContent).not.toContain(
"Choose who you want to support",
);
expect(container.textContent).not.toContain(
"Your VIP or credit purchase supports the character you choose",
);
expect(checkout?.disabled).toBe(false);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug }),
);
},
);
it("does not invent a recipient when opened without a source character", () => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
root.render(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={null}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).not.toContain("Supporting ");
expect(container.textContent).not.toContain(
"Choose who you want to support",
);
expect(container.textContent).not.toContain(
"Your VIP or credit purchase supports the character you choose",
);
expect(checkout?.disabled).toBe(true);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: null }),
);
});
});
function clickButton(root: ParentNode, label: string): void {
const button = Array.from(root.querySelectorAll("button")).find(
(item) => item.textContent?.trim() === label,
);
if (!button) throw new Error(`Expected ${label} button`);
button.click();
}

Some files were not shown because too many files have changed in this diff Show More