Compare commits
38 Commits
dae04f75dc
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3e256a8c | |||
| 3547f49bd9 | |||
| ee6ab77f4d | |||
| ce3f0af82d | |||
| 638fc054b1 | |||
| 897bce11db | |||
| 1466cb01d2 | |||
| f6c75dcb86 | |||
| 187de82eaf | |||
| d372541925 | |||
| ef9b79bc83 | |||
| aee35353a3 | |||
| fcb2492118 | |||
| cab2ba868f | |||
| dab9e2e45a | |||
| 54ce485f41 | |||
| e36f94add3 | |||
| ca9cac21f1 | |||
| 662e5e67ea | |||
| f403c47052 | |||
| ac36837add | |||
| fe9d31146b | |||
| 01c55f5630 | |||
| b21ef2f6ec | |||
| a44fc4860e | |||
| ff2759cf0f | |||
| d746d04503 | |||
| 2a90b4d9ec | |||
| 9094bcbab0 | |||
| 29abd746da | |||
| 556bfd2919 | |||
| e3ef289b53 | |||
| 46588bd98c | |||
| 05f625dd0b | |||
| 3790fb813f | |||
| a55a59bff4 | |||
| d2a46ff6b9 | |||
| a4c4ca6666 |
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- pre
|
||||
- test
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
DEPLOY_ENV="prod"
|
||||
NEXT_ENV_FILE=".env.production"
|
||||
;;
|
||||
pre)
|
||||
test)
|
||||
DEPLOY_ENV="test"
|
||||
NEXT_ENV_FILE=".env.local"
|
||||
;;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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"
|
||||
@@ -72,12 +72,12 @@ copy_env_by_branch() {
|
||||
START_PORT=9185
|
||||
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
||||
;;
|
||||
pre)
|
||||
test)
|
||||
cp -f env-example/.env.local.example .env.local
|
||||
ENV_FILE=".env.local"
|
||||
DEPLOY_ENV="test"
|
||||
START_PORT=9135
|
||||
echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
|
||||
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
||||
;;
|
||||
dev)
|
||||
cp -f env-example/.env.development.example .env.local
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
||||
|
||||
当前 remote 配置示例:
|
||||
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
||||
|
||||
## 本地发布流程
|
||||
|
||||
旧预发环境发布流程(脚本迁移前):
|
||||
旧测试环境发布流程(脚本迁移前):
|
||||
|
||||
1. 进入 pre worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
|
||||
1. 进入 test worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test`
|
||||
2. 确认当前分支。
|
||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
|
||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。
|
||||
4. 复制测试环境图标到 `public/`。
|
||||
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
||||
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
|
||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。
|
||||
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>` |
|
||||
| `pre` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||
| `test` | `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>` |
|
||||
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
||||
|
||||
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
|
||||
|
||||
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
||||
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
||||
3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
|
||||
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
||||
4. 生产推送当前使用 `git push --force production main`。
|
||||
5. 构建失败时不会重启容器,旧容器继续运行。
|
||||
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
||||
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
||||
8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
| Workflow | 触发时机 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||
| `.gitea/workflows/docker-image.yml` | `main`、`pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||
| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||
|
||||
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
||||
|
||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
|
||||
分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test`
|
||||
分支为快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||
`ci.yml` 会同时执行完整检查并提供测试结果。
|
||||
|
||||
## 必需 Secrets
|
||||
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
|
||||
|
||||
## 镜像 Tag 规则
|
||||
|
||||
`pre` 分支发布时继续使用现有 `test-*` 运行镜像标签:
|
||||
`test` 分支发布:
|
||||
|
||||
```bash
|
||||
REGISTRY_IMAGE:test-<short_sha>
|
||||
@@ -81,7 +81,7 @@ volumes:
|
||||
|
||||
## 验证方式
|
||||
|
||||
推送到 `pre` 分支后,检查 Actions 日志中是否出现:
|
||||
推送到 `test` 分支后,检查 Actions 日志中是否出现:
|
||||
|
||||
```text
|
||||
Build Docker image
|
||||
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
|
||||
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
||||
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
||||
|
||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 外部入口接入说明
|
||||
|
||||
外部应用通过 `/external-entry` 进入本应用。
|
||||
Facebook 等外部应用统一通过 `/external-entry` 进入本应用。入口先保存外部身份和短期业务意图,再跳转到对应角色页面并清理地址栏中的入口查询参数。
|
||||
|
||||
| 环境 | APP HOST |
|
||||
| --- | --- |
|
||||
| 测试环境 | `frontend-test.banlv-ai.com` |
|
||||
| 正式环境 | `cozsweet.com` |
|
||||
| 预发环境 `pre` | `frontend-test.banlv-ai.com` |
|
||||
| 正式环境 `production` | `cozsweet.com` |
|
||||
|
||||
入口格式:
|
||||
|
||||
@@ -13,85 +13,78 @@
|
||||
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` | 指定进入的页面;不传或值无效时进入聊天页。历史值 `private-room` 兼容进入 `private-zone`,新链接不得继续使用。 |
|
||||
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
||||
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
||||
| `target` | `chat`、`tip`、`private-zone` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
||||
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。正式投放链接必须显式传入。 |
|
||||
| `psid` | string | Facebook Page-scoped User ID。Facebook 正式投放链接必须动态传入。 |
|
||||
| `mode` | `promotion` | 开启聊天促销模式。 |
|
||||
| `promotion_type` | `voice`、`image`、`private` | 指定促销消息类型,仅与 `mode=promotion` 一起使用。 |
|
||||
|
||||
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
|
||||
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。当前 12 条正式投放链接只使用 `promotion_type=voice`;`image` 和 `private` 保留为兼容能力,不属于本次业务清单。
|
||||
|
||||
标准私密空间参数是 `target=private-zone`。前端仅为已经发布的旧链接保留 `target=private-room` 兼容;`private_zone` 等其他拼写仍按无效目标处理并回退聊天页。
|
||||
## 四类发送模板
|
||||
|
||||
发送前必须把 `<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 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
||||
`psid` 可以和任意有效入口参数组合使用。应用会先保存 PSID,再执行登录状态判断和 Facebook Identity 绑定。详细流程见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
||||
|
||||
## 使用示例
|
||||
所有参数值都必须进行 URL 编码。入口中禁止传递登录 Token、Page Access Token、App Secret 或其他秘密。
|
||||
|
||||
普通聊天:
|
||||
## 兼容说明
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=chat
|
||||
```
|
||||
- 历史参数 `target=private-room` 继续兼容进入 `private-zone`,但不得用于新投放链接。
|
||||
- `private_zone` 等其他拼写仍按无效目标处理并回退聊天页。
|
||||
- `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)
|
||||
- [测试环境和正式环境链接清单](./links.md)
|
||||
- [Facebook 12 条外部入口链接清单](./links.md)
|
||||
|
||||
@@ -1,66 +1,52 @@
|
||||
# 外部入口链接清单
|
||||
# Facebook 12 条外部入口链接清单
|
||||
|
||||
这份清单可以独立发送和直接点击。所有新链接统一使用标准参数 `target=private-zone`;历史参数 `target=private-room` 仅用于兼容已经发出的旧链接。
|
||||
本清单是 Facebook 正式投放的唯一业务链接清单,共 4 类入口乘以 3 个角色,合计 12 条模板。
|
||||
|
||||
## 使用规则
|
||||
## 发送规则
|
||||
|
||||
- `target=chat`:进入聊天页。
|
||||
- `target=tip`:进入对应角色的打赏页。
|
||||
- `target=private-zone`:进入对应角色的私密空间。
|
||||
- `character` 可使用 `elio`、`maya`、`nayeli`;不传或值无效时使用 Elio。
|
||||
- `mode=promotion` 只在 `target=chat` 且 `promotion_type` 为 `voice`、`image` 或 `private` 时生效。
|
||||
- PSID 示例统一使用 `27511427698460020`;不要在入口中放登录 Token、Page Access Token 或 App Secret。
|
||||
- 发送前必须把每条模板中的 `<PSID>` 替换为当前用户经过 URL 编码的 Facebook Page-scoped User ID;不得把占位符原样发送。
|
||||
- 每条链接都显式传入 `character`,不得依赖缺省角色回退。
|
||||
- Facebook 业务名称“私密空间”进入普通聊天页;业务名称“图片包”才进入应用的 `private-zone`。
|
||||
- 不要在入口中传递登录 Token、Page Access Token、App Secret 或其他秘密。
|
||||
- 以下模板包含占位符,替换真实 PSID 后才能直接发送或点击。
|
||||
|
||||
## 测试环境
|
||||
## 正式投放模板
|
||||
|
||||
| 入口 | 可点击链接 | 预期落地页面 |
|
||||
| --- | --- | --- |
|
||||
| 普通聊天(Elio) | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) | `/characters/elio/chat` |
|
||||
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) | `/characters/maya/chat` |
|
||||
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) | `/characters/nayeli/chat` |
|
||||
| 携带 PSID 的 Elio 聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) | `/characters/elio/chat` |
|
||||
| 语音促销 | [打开语音促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=voice) | `/characters/elio/chat`,保存语音促销意图 |
|
||||
| 图片促销 | [打开图片促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=image) | `/characters/elio/chat`,保存图片促销意图 |
|
||||
| 私密文本促销 | [打开私密文本促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=private) | `/characters/elio/chat`,保存私密文本促销意图 |
|
||||
| Elio 咖啡打赏 | [打开 Elio 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=elio) | `/characters/elio/tip` |
|
||||
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) | `/characters/maya/tip` |
|
||||
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) | `/characters/nayeli/tip` |
|
||||
| Elio 私密空间 | [打开 Elio 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=elio) | `/characters/elio/private-zone` |
|
||||
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||
| 编号 | 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` |
|
||||
|
||||
## 旧链接兼容验证
|
||||
## 预发验证
|
||||
|
||||
以下链接仅用于验证已经发出的 `private-room` 旧链接仍能正确进入私密空间。新投放链接不要再使用这些地址。
|
||||
预发验证不维护第二套业务清单。将上述 12 条模板的主机统一替换为 `frontend-test.banlv-ai.com`,其余路径和参数保持不变:
|
||||
|
||||
| 旧入口 | 可点击链接 | 预期落地页面 |
|
||||
| --- | --- | --- |
|
||||
| Elio 旧私密空间 | [验证 Elio 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=elio) | `/characters/elio/private-zone` |
|
||||
| Maya 旧私密空间 | [验证 Maya 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=maya) | `/characters/maya/private-zone` |
|
||||
| Nayeli 旧私密空间 | [验证 Nayeli 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||
```text
|
||||
https://frontend-test.banlv-ai.com/external-entry?<与正式模板相同的查询参数>
|
||||
```
|
||||
|
||||
## 正式环境
|
||||
|
||||
正式环境仅列标准链接,不再传播 `private-room` 旧参数。
|
||||
|
||||
| 入口 | 可点击链接 | 预期落地页面 |
|
||||
| --- | --- | --- |
|
||||
| 普通聊天(Elio) | [打开普通聊天](https://cozsweet.com/external-entry?target=chat) | `/characters/elio/chat` |
|
||||
| Maya 聊天 | [打开 Maya 聊天](https://cozsweet.com/external-entry?target=chat&character=maya) | `/characters/maya/chat` |
|
||||
| Nayeli 聊天 | [打开 Nayeli 聊天](https://cozsweet.com/external-entry?target=chat&character=nayeli) | `/characters/nayeli/chat` |
|
||||
| 携带 PSID 的 Elio 聊天 | [打开 PSID 聊天示例](https://cozsweet.com/external-entry?target=chat&psid=27511427698460020) | `/characters/elio/chat` |
|
||||
| 语音促销 | [打开语音促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=voice) | `/characters/elio/chat`,保存语音促销意图 |
|
||||
| 图片促销 | [打开图片促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=image) | `/characters/elio/chat`,保存图片促销意图 |
|
||||
| 私密文本促销 | [打开私密文本促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=private) | `/characters/elio/chat`,保存私密文本促销意图 |
|
||||
| Elio 咖啡打赏 | [打开 Elio 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=elio) | `/characters/elio/tip` |
|
||||
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=maya) | `/characters/maya/tip` |
|
||||
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=nayeli) | `/characters/nayeli/tip` |
|
||||
| Elio 私密空间 | [打开 Elio 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=elio) | `/characters/elio/private-zone` |
|
||||
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||
预发环境是 `pre`,不是正式环境。预发验证通过不代表已经更新 `cozsweet.com` 或获得生产发布批准。
|
||||
|
||||
## 验收判断
|
||||
|
||||
打开入口后,浏览器地址最终应与“预期落地页面”一致。普通聊天和私密空间必须分别落在 `/chat` 与 `/private-zone`,不能再进入同一个页面。
|
||||
- 最终浏览器地址必须是表格中的预期页面,且不再保留 `/external-entry` 的查询参数。
|
||||
- `<PSID>` 替换后的真实值必须由应用保存。
|
||||
- “私密空间”只进入普通聊天,不得出现锁定语音、锁定图片或私密文本促销卡。
|
||||
- “语音”进入聊天后必须只注入一条锁定语音。
|
||||
- “图片包”和“帮忙买咖啡”必须分别进入对应角色的 `private-zone` 与 `tip`,不能串到其他角色。
|
||||
|
||||
如需在其他入口携带 PSID,在已有查询参数的链接末尾追加 `&psid=27511427698460020`。
|
||||
## 不属于当前 12 条清单的兼容能力
|
||||
|
||||
- 历史 `target=private-room` 继续兼容,但不得创建新的投放链接。
|
||||
- `promotion_type=image`、`promotion_type=private` 和独立 PSID 示例不属于当前正式业务清单。
|
||||
- 兼容能力的保留不改变本页“只有 12 条正式投放模板”的口径。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
当前推荐部署链路:
|
||||
|
||||
```text
|
||||
push pre/main
|
||||
push test/main
|
||||
→ Gitea Actions 构建并推送 Docker 镜像
|
||||
→ Actions 通过 SSH 登录目标服务器
|
||||
→ 目标服务器 docker pull 指定镜像 tag
|
||||
@@ -16,7 +16,7 @@ push pre/main
|
||||
|
||||
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
||||
|
||||
## Gitea Secrets
|
||||
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
|
||||
|
||||
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
||||
|
||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||
|
||||
## 本地发布入口
|
||||
|
||||
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
||||
|
||||
```bash
|
||||
# 预发环境
|
||||
# 测试环境
|
||||
./scripts/release/pre_release_web.sh
|
||||
|
||||
# 生产环境
|
||||
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
|
||||
也可以直接推送:
|
||||
|
||||
```bash
|
||||
git push gitea pre
|
||||
git push gitea test
|
||||
git push --force gitea main
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
预发环境:
|
||||
测试环境:
|
||||
|
||||
```bash
|
||||
ssh <user>@<pre-host>
|
||||
ssh <user>@<test-host>
|
||||
cd /opt/cozsweet-web-test
|
||||
docker compose ps
|
||||
curl -I http://127.0.0.1:9135/
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
|
||||
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
|
||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
|
||||
request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
|
||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier
|
||||
on every pull request and `dev` / `test` / `main` push.
|
||||
|
||||
## Environment overrides
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface MockCoreApisOptions {
|
||||
paidImageInsufficientCreditsFlow?: boolean;
|
||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||
topUpHandoffFlow?: boolean;
|
||||
checkoutHandoffFlow?: boolean;
|
||||
}
|
||||
|
||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||
@@ -24,7 +23,6 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||
};
|
||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||
|
||||
@@ -32,7 +30,6 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||
});
|
||||
await registerCharacterMocks(page);
|
||||
await registerUserMocks(page);
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { apiEnvelope } from "../data/common";
|
||||
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||
|
||||
export interface AuthMockState {
|
||||
chatSendTokenRefreshFlow: boolean;
|
||||
isChatSendTokenExpired: () => boolean;
|
||||
topUpHandoffFlow: boolean;
|
||||
checkoutHandoffFlow: boolean;
|
||||
}
|
||||
|
||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
||||
await page.route("**/api/auth/handoff/checkout/consume", async (route) => {
|
||||
if (!state.checkoutHandoffFlow) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) });
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||
if (!state.topUpHandoffFlow) {
|
||||
await route.continue();
|
||||
|
||||
@@ -63,15 +63,3 @@ export const topUpHandoffResponse = {
|
||||
loginProvider: "facebook_messenger",
|
||||
},
|
||||
};
|
||||
|
||||
export const checkoutHandoffResponse = {
|
||||
token: "e2e-checkout-token",
|
||||
refreshToken: "e2e-checkout-refresh-token",
|
||||
loginStatus: "email",
|
||||
user: e2eEmailUser,
|
||||
checkoutIntent: {
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,17 +2,18 @@ import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function completeVipPayment(page: Page) {
|
||||
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||
await expect(checkoutButton).toBeEnabled();
|
||||
|
||||
await checkoutButton.click();
|
||||
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 orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
||||
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
|
||||
await checkoutButton.click();
|
||||
const createOrderRequest = await createOrderRequestPromise;
|
||||
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
||||
await orderStatusRequestPromise;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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,105 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||
import {
|
||||
clearBrowserState,
|
||||
seedEmailSession,
|
||||
} from "@e2e/fixtures/test-helpers";
|
||||
|
||||
const facebookAndroidUserAgent =
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
|
||||
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
|
||||
|
||||
test.use({
|
||||
userAgent: facebookAndroidUserAgent,
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
await clearBrowserState(context, page, baseURL);
|
||||
});
|
||||
|
||||
test("consumes the handoff, removes its token, and does not create an order", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockCoreApis(page, { checkoutHandoffFlow: true });
|
||||
let createOrderRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/payment/create-order")) {
|
||||
createOrderRequests += 1;
|
||||
}
|
||||
});
|
||||
const consumeRequest = page.waitForRequest(
|
||||
"**/api/auth/handoff/checkout/consume",
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||
);
|
||||
|
||||
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||
await expect(page).toHaveURL(
|
||||
/\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/,
|
||||
);
|
||||
expect(page.url()).not.toContain("handoffToken");
|
||||
await expect.poll(() => createOrderRequests).toBe(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||
})),
|
||||
)
|
||||
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
|
||||
});
|
||||
|
||||
test("offers the Facebook external-browser path before creating a Stripe order", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockCoreApis(page);
|
||||
let createOrderRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/payment/create-order")) {
|
||||
createOrderRequests += 1;
|
||||
}
|
||||
});
|
||||
await page.route("**/api/auth/handoff/checkout", async (route) => {
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
externalUrl:
|
||||
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
|
||||
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await seedEmailSession(page);
|
||||
await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
|
||||
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||
const externalButton = page.getByRole("button", {
|
||||
name: "Open in browser for more payment methods",
|
||||
});
|
||||
const paymentButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||
await expect(externalButton).toBeVisible();
|
||||
await expect(externalButton).toBeEnabled();
|
||||
await expect(paymentButton).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||
).toHaveCount(0);
|
||||
const externalBox = await externalButton.boundingBox();
|
||||
const paymentBox = await paymentButton.boundingBox();
|
||||
expect(externalBox).not.toBeNull();
|
||||
expect(paymentBox).not.toBeNull();
|
||||
expect(externalBox!.y).toBeLessThan(paymentBox!.y);
|
||||
expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844);
|
||||
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
|
||||
await externalButton.click();
|
||||
expect((await handoffRequest).postDataJSON()).toMatchObject({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
});
|
||||
expect(createOrderRequests).toBe(0);
|
||||
});
|
||||
@@ -232,11 +232,19 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
||||
"true",
|
||||
);
|
||||
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||
await expect(checkoutButton).toBeEnabled();
|
||||
await expect(checkoutButton).toBeDisabled();
|
||||
await expectCheckoutButtonLayout(page);
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
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(
|
||||
@@ -275,14 +283,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
|
||||
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");
|
||||
|
||||
const orderId = await expectQrisOrder(
|
||||
await expectQrisOrder(
|
||||
page,
|
||||
/Pay and Top Up/i,
|
||||
"dol_1000",
|
||||
@@ -292,20 +300,14 @@ test("closing QRIS restores a resumable checkout without creating a duplicate or
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
const resumeButton = page.getByRole("button", {
|
||||
name: "Resume QRIS payment",
|
||||
});
|
||||
await expect(resumeButton).toBeEnabled();
|
||||
await 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);
|
||||
|
||||
await resumeButton.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(payment.getCreateOrderCount()).toBe(1);
|
||||
|
||||
payment.markPaid(orderId);
|
||||
await expect(
|
||||
page.getByRole("alertdialog", { name: "Payment successful" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
|
||||
@@ -400,7 +402,7 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
|
||||
await page.goto("/characters/elio/tip");
|
||||
|
||||
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
|
||||
const orderId = await expectQrisOrder(
|
||||
await expectQrisOrder(
|
||||
page,
|
||||
/Order and Buy/i,
|
||||
"tip_coffee_usd_4_99",
|
||||
@@ -410,19 +412,11 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
const resumeButton = page.getByRole("button", {
|
||||
name: "Resume QRIS payment",
|
||||
});
|
||||
await expect(resumeButton).toBeEnabled();
|
||||
await resumeButton.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
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);
|
||||
|
||||
payment.markPaid(orderId);
|
||||
|
||||
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("serves the VIP membership benefits agreement from the same site", async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto("/legal/vip-membership-benefits.html");
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "VIP Membership Benefits Agreement" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("serves the automatic renewal agreement from the same site", async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto("/legal/automatic-renewal.html");
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Automatic Renewal Agreement" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Automatic Renewal Agreement | Cozsweet</title>
|
||||
<style>
|
||||
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Automatic Renewal Agreement</h1>
|
||||
<p class="meta">Effective date: July 29, 2026</p>
|
||||
|
||||
<p class="notice">
|
||||
By selecting <strong>Confirm</strong> before payment, you authorize Cozsweet and its payment provider to renew the selected membership automatically under the terms shown below.
|
||||
</p>
|
||||
|
||||
<h2>1. When renewal applies</h2>
|
||||
<p>
|
||||
Automatic renewal applies only when the selected membership is identified as auto-renewing in the purchase flow. One-time credit purchases, tips, gifts, lifetime plans, and payment methods explicitly shown as one-time purchases do not automatically renew.
|
||||
</p>
|
||||
|
||||
<h2>2. Billing frequency</h2>
|
||||
<p>
|
||||
The plan renews at the end of the billing period shown at checkout, such as monthly, quarterly, or annually. Renewal continues until cancelled. Your payment method may be charged shortly before or on the start of the next period as permitted by the payment provider.
|
||||
</p>
|
||||
|
||||
<h2>3. Renewal amount</h2>
|
||||
<p>
|
||||
The initial charge and renewal amount are displayed before confirmation. A promotion may reduce the first charge without reducing later renewals. Unless another renewal amount is shown, renewal uses the applicable regular price for the selected plan, together with any taxes or fees required by law.
|
||||
</p>
|
||||
|
||||
<h2>4. Confirmation</h2>
|
||||
<p>
|
||||
Cozsweet asks for renewal confirmation when an account first attempts to pay for an automatically renewing membership in the current agreement version. Cancelling the confirmation does not create an order. Confirming records the acknowledgement in the current browser and continues to payment.
|
||||
</p>
|
||||
|
||||
<h2>5. Cancellation</h2>
|
||||
<p>
|
||||
You may cancel future renewal using a cancellation option made available in the product or by using the support/payment-issue path before the next renewal is processed. Cancellation takes effect for future billing; membership already paid for normally remains available until the end of the current term.
|
||||
</p>
|
||||
|
||||
<h2>6. Failed or disputed payments</h2>
|
||||
<p>
|
||||
A failed renewal can interrupt membership benefits. If you see a duplicate charge, a long-pending payment, or missing benefits, do not repeatedly submit payment. Use <strong>Payment issue?</strong> on the purchase page and keep the returned Feedback ID. Never provide passwords, full card numbers, security codes, verification codes, or access tokens.
|
||||
</p>
|
||||
|
||||
<h2>7. Material changes</h2>
|
||||
<p>
|
||||
If the renewal terms materially change, Cozsweet will update this agreement and request a new acknowledgement when required. The version effective for a payment is the version linked from its confirmation dialog.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,66 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>VIP Membership Benefits Agreement | Cozsweet</title>
|
||||
<style>
|
||||
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||
a { color: #d8327c; }
|
||||
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>VIP Membership Benefits Agreement</h1>
|
||||
<p class="meta">Effective date: July 29, 2026</p>
|
||||
|
||||
<p class="notice">
|
||||
This agreement explains the VIP membership displayed on the Cozsweet purchase page. The plan name, price, currency, duration, credits, and benefits shown at checkout are the terms that apply to your purchase.
|
||||
</p>
|
||||
|
||||
<h2>1. Membership benefits</h2>
|
||||
<p>
|
||||
VIP may include the chat access, credits, private content access, or other benefits displayed for the selected plan. Benefits are available only for the membership period and account shown at checkout. A membership does not guarantee that every feature or item is free; some content can still require credits or a separate purchase when clearly shown in the product.
|
||||
</p>
|
||||
|
||||
<h2>2. Plan term and account</h2>
|
||||
<p>
|
||||
Membership is attached to the Cozsweet account used for payment. The selected monthly, quarterly, annual, lifetime, or other plan term is shown before payment. Do not share payment credentials or account access with another person.
|
||||
</p>
|
||||
|
||||
<h2>3. Automatic renewal</h2>
|
||||
<p>
|
||||
A plan marked as automatically renewing continues until cancelled. Before the first automatically renewing purchase, Cozsweet displays a separate confirmation. Please also read the <a href="/legal/automatic-renewal.html">Automatic Renewal Agreement</a>.
|
||||
</p>
|
||||
|
||||
<h2>4. Prices and promotions</h2>
|
||||
<p>
|
||||
The checkout page is the source of truth for the current amount and currency. A first-payment or limited promotion can apply only to the initial charge; the renewal amount may be the regular price displayed in the renewal notice. Cozsweet does not ask you to pay a price that is not shown in the checkout flow.
|
||||
</p>
|
||||
|
||||
<h2>5. Payment and access</h2>
|
||||
<p>
|
||||
Benefits are activated only after payment is confirmed and the order is fulfilled. A pending or failed payment does not activate membership. If payment is duplicated, remains pending, or benefits are missing, do not submit repeated payments; use the <strong>Payment issue?</strong> entry on the purchase page and retain the returned Feedback ID.
|
||||
</p>
|
||||
|
||||
<h2>6. Cancellation, refunds, and applicable rights</h2>
|
||||
<p>
|
||||
Cancellation stops future renewal but does not normally remove benefits already paid for during the current term. Refund rights depend on applicable law, the payment provider, and the circumstances of the order. Use the product support or payment-issue path for review; never send a password, card number, security code, verification code, or access token.
|
||||
</p>
|
||||
|
||||
<h2>7. Changes</h2>
|
||||
<p>
|
||||
If a material membership term changes, the updated version and effective date will be made available before it applies to a new purchase or renewal where required.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
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/);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
# 推送指定分支到指定远端。
|
||||
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
||||
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
||||
# deploy_web_test.sh(pre)→ push_to_remote "gitea" "pre"
|
||||
# deploy_web_test.sh(test)→ push_to_remote "gitea" "test"
|
||||
push_to_remote() {
|
||||
local remote="$1"
|
||||
local branch="$2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ==========================================
|
||||
# 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
||||
# ==========================================
|
||||
|
||||
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
push_to_remote "gitea" "pre"
|
||||
push_to_remote "gitea" "test"
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
@@ -11,9 +11,9 @@ unset_git_hook_env() {
|
||||
|
||||
unset_git_hook_env
|
||||
|
||||
# pre 分支 worktree 路径
|
||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
|
||||
echo "=== 进入 pre 分支 worktree ==="
|
||||
# Test 分支 worktree 路径
|
||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test"
|
||||
echo "=== 进入 test 分支 worktree ==="
|
||||
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
||||
|
||||
echo "=== 检查当前分支 ==="
|
||||
@@ -22,17 +22,17 @@ git branch --show-current
|
||||
echo "=== 变基到 dev 分支 ==="
|
||||
git rebase dev
|
||||
|
||||
# 复制预发环境图标(运行资源目录仍为 icons/test)
|
||||
echo "=== 复制预发环境图标(icons/test → public/) ==="
|
||||
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/)
|
||||
echo "=== 复制测试环境图标(icons/test → public/) ==="
|
||||
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-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
||||
|
||||
# 准备预发环境变量(运行配置仍使用 .env.local)
|
||||
echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local) ==="
|
||||
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local)
|
||||
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local) ==="
|
||||
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
||||
|
||||
echo "=== 执行构建部署脚本(预发环境)==="
|
||||
echo "=== 执行构建部署脚本(测试环境)==="
|
||||
./scripts/deploy/deploy_web_test.sh
|
||||
|
||||
echo "=== 预发布完成 ==="
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/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,77 +0,0 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
|
||||
() => ({
|
||||
createCheckoutHandoff: vi.fn(),
|
||||
openUrlWithExternalBrowser: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
|
||||
vi.mock("@/utils/url-launcher-util", () => ({
|
||||
UrlLauncherUtil: { openUrlWithExternalBrowser },
|
||||
}));
|
||||
vi.mock("@/utils/browser-detect", () => ({
|
||||
BrowserDetector: { isFacebookInAppBrowser: () => true },
|
||||
}));
|
||||
|
||||
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
|
||||
|
||||
describe("ExternalBrowserCheckoutButton", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createCheckoutHandoff.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
externalUrl:
|
||||
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||
},
|
||||
});
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("creates only a handoff before opening the external browser", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ExternalBrowserCheckoutButton
|
||||
characterSlug="maya"
|
||||
checkoutIntent={{
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
commercialOfferId: "offer-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button?.textContent).toContain(
|
||||
"Open in browser for more payment methods",
|
||||
);
|
||||
await act(async () => button?.click());
|
||||
|
||||
expect(createCheckoutHandoff).toHaveBeenCalledWith({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
commercialOfferId: "offer-1",
|
||||
});
|
||||
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
|
||||
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque&character=maya",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,17 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||
const hiddenLaunch = {
|
||||
handleEzpayCancel: vi.fn(),
|
||||
handleEzpayConfirm: vi.fn(),
|
||||
handleQrisClose: vi.fn(),
|
||||
handleRegionalQrClose: vi.fn(),
|
||||
handleStripeClose: vi.fn(),
|
||||
handleStripeConfirmed: vi.fn(),
|
||||
isConfirmingEzpay: false,
|
||||
qrisErrorMessage: null,
|
||||
qrisPayment: null,
|
||||
qrisStatus: null,
|
||||
regionalQrErrorMessage: null,
|
||||
regionalQrPayment: null,
|
||||
regionalQrStatus: null,
|
||||
shouldShowEzpayConfirmDialog: false,
|
||||
shouldShowQrisDialog: false,
|
||||
shouldShowRegionalQrDialog: false,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
stripeCustomerSessionClientSecret: null,
|
||||
savedPaymentMethodsEnabled: false,
|
||||
};
|
||||
|
||||
describe("PaymentLaunchDialogs", () => {
|
||||
@@ -98,14 +96,15 @@ describe("PaymentLaunchDialogs", () => {
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
qrisPayment: {
|
||||
regionalQrPayment: {
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
experience: "qris",
|
||||
},
|
||||
qrisStatus: "pending",
|
||||
shouldShowQrisDialog: true,
|
||||
regionalQrStatus: "pending",
|
||||
shouldShowRegionalQrDialog: true,
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
@@ -124,6 +123,44 @@ describe("PaymentLaunchDialogs", () => {
|
||||
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(
|
||||
@@ -133,15 +170,16 @@ describe("PaymentLaunchDialogs", () => {
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
qrisErrorMessage: "Payment failed or was cancelled.",
|
||||
qrisPayment: {
|
||||
regionalQrErrorMessage: "Payment failed or was cancelled.",
|
||||
regionalQrPayment: {
|
||||
qrData: "",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
experience: "qris",
|
||||
},
|
||||
qrisStatus: "failed",
|
||||
shouldShowQrisDialog: true,
|
||||
regionalQrStatus: "failed",
|
||||
shouldShowRegionalQrDialog: true,
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -2,57 +2,18 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||
const elementsSubmit = vi.fn(async () => ({}));
|
||||
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||
});
|
||||
|
||||
vi.mock("@stripe/stripe-js", () => ({
|
||||
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("@stripe/react-stripe-js", () => ({
|
||||
Elements: ({
|
||||
children,
|
||||
options,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
options: Record<string, unknown>;
|
||||
}) => {
|
||||
capturedElementsOptions = options;
|
||||
return <>{children}</>;
|
||||
},
|
||||
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||
capturedExpressProps = props;
|
||||
return <div data-testid="express-checkout" />;
|
||||
},
|
||||
PaymentElement: (props: Record<string, unknown>) => {
|
||||
capturedPaymentProps = props;
|
||||
return <div data-testid="payment-element" />;
|
||||
},
|
||||
useElements: () => ({ submit: elementsSubmit }),
|
||||
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||
}));
|
||||
|
||||
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
|
||||
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||
|
||||
describe("StripePaymentDialog", () => {
|
||||
describe("Stripe payment portal states", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedElementsOptions = null;
|
||||
capturedExpressProps = null;
|
||||
capturedPaymentProps = null;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
container.style.transform = "translateX(50%)";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
@@ -63,129 +24,40 @@ describe("StripePaymentDialog", () => {
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
it("renders express methods and wallets before card with English Stripe copy", () => {
|
||||
it("portals the unavailable state and keeps its explicit close action", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="cuss_123_secret_saved"
|
||||
savedPaymentMethodsEnabled
|
||||
onClose={vi.fn()}
|
||||
clientSecret="client-secret"
|
||||
onClose={onClose}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(capturedElementsOptions).toMatchObject({
|
||||
clientSecret: "pi_123_secret_payment",
|
||||
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||
locale: "en",
|
||||
});
|
||||
expect(capturedExpressProps?.options).toMatchObject({
|
||||
paymentMethodOrder: [
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "never",
|
||||
googlePay: "never",
|
||||
link: "auto",
|
||||
paypal: "auto",
|
||||
amazonPay: "auto",
|
||||
klarna: "auto",
|
||||
},
|
||||
});
|
||||
expect(capturedPaymentProps?.options).toMatchObject({
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
|
||||
});
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("Choose a payment method");
|
||||
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||
expect(express).not.toBeNull();
|
||||
expect(card).not.toBeNull();
|
||||
expect(
|
||||
express!.compareDocumentPosition(card!) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
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("keeps Payment Element wallet fallbacks when Express Checkout is unavailable", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
it("portals the lazy loading state", () => {
|
||||
act(() => root.render(<StripePaymentDialogLoading />));
|
||||
|
||||
const onReady = capturedExpressProps?.onReady as
|
||||
| ((event: { availablePaymentMethods: undefined }) => void)
|
||||
| undefined;
|
||||
expect(onReady).toBeTypeOf("function");
|
||||
act(() => onReady?.({ availablePaymentMethods: undefined }));
|
||||
|
||||
const expressSection = document.body.querySelector(
|
||||
'[aria-label="Express payment methods"]',
|
||||
);
|
||||
expect(expressSection).toHaveProperty("hidden", true);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="payment-element"]'),
|
||||
).not.toBeNull();
|
||||
expect(capturedPaymentProps?.options).toMatchObject({
|
||||
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
|
||||
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="invalid"
|
||||
savedPaymentMethodsEnabled={false}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(capturedElementsOptions).not.toHaveProperty(
|
||||
"customerSessionClientSecret",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||
const onConfirmed = vi.fn();
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
onClose={vi.fn()}
|
||||
onConfirmed={onConfirmed}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const onConfirm = capturedExpressProps?.onConfirm as
|
||||
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||
| undefined;
|
||||
expect(onConfirm).toBeTypeOf("function");
|
||||
await act(async () => {
|
||||
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||
});
|
||||
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
elements: expect.any(Object),
|
||||
redirect: "if_required",
|
||||
}),
|
||||
);
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
|
||||
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||
import {
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||
import { BrowserDetector } from "@/utils/browser-detect";
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import { Result } from "@/utils/result";
|
||||
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
|
||||
|
||||
export interface ExternalBrowserCheckoutButtonProps {
|
||||
checkoutIntent: CheckoutIntent;
|
||||
disabled?: boolean;
|
||||
characterSlug?: string;
|
||||
}
|
||||
|
||||
export function ExternalBrowserCheckoutButton({
|
||||
checkoutIntent,
|
||||
disabled = false,
|
||||
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
}: ExternalBrowserCheckoutButtonProps) {
|
||||
const isFacebookBrowser = useSyncExternalStore(
|
||||
() => () => undefined,
|
||||
() => BrowserDetector.isFacebookInAppBrowser(),
|
||||
() => false,
|
||||
);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
if (!isFacebookBrowser) return null;
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (disabled || isOpening) return;
|
||||
setIsOpening(true);
|
||||
setErrorMessage(null);
|
||||
const result = await createCheckoutHandoff(checkoutIntent);
|
||||
if (Result.isErr(result)) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(
|
||||
result.error,
|
||||
"Could not open this checkout in your browser. Please sign in and try again.",
|
||||
),
|
||||
);
|
||||
setIsOpening(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const verifiedCharacter =
|
||||
getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||
const externalUrl = new URL(
|
||||
result.data.externalUrl,
|
||||
window.location.origin,
|
||||
);
|
||||
externalUrl.searchParams.set("character", verifiedCharacter);
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString());
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(
|
||||
error,
|
||||
"Could not open this checkout in your browser. Please try again.",
|
||||
),
|
||||
);
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-2 w-full text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
|
||||
disabled={disabled || isOpening}
|
||||
onClick={() => void handleOpen()}
|
||||
>
|
||||
{isOpening
|
||||
? "Opening browser..."
|
||||
: "Open in browser for more payment methods"}
|
||||
</button>
|
||||
{errorMessage ? (
|
||||
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,19 +13,17 @@ type PaymentLaunchDialogFlow = Pick<
|
||||
PaymentLaunchFlow,
|
||||
| "handleEzpayCancel"
|
||||
| "handleEzpayConfirm"
|
||||
| "handleQrisClose"
|
||||
| "handleRegionalQrClose"
|
||||
| "handleStripeClose"
|
||||
| "handleStripeConfirmed"
|
||||
| "isConfirmingEzpay"
|
||||
| "qrisErrorMessage"
|
||||
| "qrisPayment"
|
||||
| "qrisStatus"
|
||||
| "regionalQrErrorMessage"
|
||||
| "regionalQrPayment"
|
||||
| "regionalQrStatus"
|
||||
| "shouldShowEzpayConfirmDialog"
|
||||
| "shouldShowQrisDialog"
|
||||
| "shouldShowRegionalQrDialog"
|
||||
| "shouldShowStripeDialog"
|
||||
| "stripeClientSecret"
|
||||
| "stripeCustomerSessionClientSecret"
|
||||
| "savedPaymentMethodsEnabled"
|
||||
>;
|
||||
|
||||
export interface PaymentLaunchDialogsProps {
|
||||
@@ -55,21 +53,17 @@ export function PaymentLaunchDialogs({
|
||||
onConfirm={launch.handleEzpayConfirm}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
|
||||
<QrisPaymentDialog
|
||||
payment={launch.qrisPayment}
|
||||
status={launch.qrisStatus}
|
||||
errorMessage={launch.qrisErrorMessage}
|
||||
onClose={launch.handleQrisClose}
|
||||
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
|
||||
<RegionalQrPaymentDialog
|
||||
payment={launch.regionalQrPayment}
|
||||
status={launch.regionalQrStatus}
|
||||
errorMessage={launch.regionalQrErrorMessage}
|
||||
onClose={launch.handleRegionalQrClose}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||
<LazyStripePaymentDialog
|
||||
clientSecret={launch.stripeClientSecret}
|
||||
customerSessionClientSecret={
|
||||
launch.stripeCustomerSessionClientSecret
|
||||
}
|
||||
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||
returnPath={stripeReturnPath}
|
||||
onClose={launch.handleStripeClose}
|
||||
onConfirmed={launch.handleStripeConfirmed}
|
||||
@@ -79,43 +73,56 @@ export function PaymentLaunchDialogs({
|
||||
);
|
||||
}
|
||||
|
||||
interface QrisPaymentDialogProps {
|
||||
interface RegionalQrPaymentDialogProps {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
|
||||
status: PaymentLaunchFlow["qrisStatus"];
|
||||
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
|
||||
status: PaymentLaunchFlow["regionalQrStatus"];
|
||||
}
|
||||
|
||||
function formatQrisAmount(amountCents: number, currency: string): string {
|
||||
function formatRegionalQrAmount(amountCents: number, currency: string): string {
|
||||
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
||||
try {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||
}).format(amountCents / 100);
|
||||
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 qrisStatusMessage(
|
||||
status: PaymentLaunchFlow["qrisStatus"],
|
||||
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 QRIS order has expired.";
|
||||
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 QrisPaymentDialog({
|
||||
function RegionalQrPaymentDialog({
|
||||
errorMessage,
|
||||
onClose,
|
||||
payment,
|
||||
status,
|
||||
}: QrisPaymentDialogProps) {
|
||||
}: RegionalQrPaymentDialogProps) {
|
||||
const titleId = useId();
|
||||
const statusMessage = qrisStatusMessage(status, errorMessage);
|
||||
const copy = regionalQrCopy(payment.experience);
|
||||
const statusMessage = regionalQrStatusMessage(
|
||||
status,
|
||||
errorMessage,
|
||||
copy.paymentName,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalPortal
|
||||
@@ -129,17 +136,17 @@ function QrisPaymentDialog({
|
||||
>
|
||||
<div className={`${styles.header} text-center`}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Scan to pay with QRIS
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className={styles.content}>
|
||||
Open a QRIS-compatible banking or wallet app and scan this code.
|
||||
{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="QRIS payment QR code"
|
||||
title={copy.qrTitle}
|
||||
size={256}
|
||||
level="M"
|
||||
marginSize={2}
|
||||
@@ -147,14 +154,14 @@ function QrisPaymentDialog({
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.error} role="alert">
|
||||
QRIS data is unavailable. Please close this dialog and try again.
|
||||
{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">
|
||||
{formatQrisAmount(payment.amountCents, payment.currency)}
|
||||
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
|
||||
</p>
|
||||
<p
|
||||
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
||||
@@ -176,6 +183,41 @@ function QrisPaymentDialog({
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
description: ReactNode;
|
||||
externalCheckoutAnalyticsKey: string;
|
||||
|
||||
@@ -9,11 +9,6 @@ export const stripePaymentDialogStyles = {
|
||||
content:
|
||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||
expressSection: "w-full empty:hidden",
|
||||
cardSection:
|
||||
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
|
||||
cardTitle:
|
||||
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
|
||||
error:
|
||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||
|
||||
@@ -4,28 +4,19 @@
|
||||
*
|
||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Elements,
|
||||
ExpressCheckoutElement,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import {
|
||||
loadStripe,
|
||||
type AvailablePaymentMethods,
|
||||
type StripeExpressCheckoutElementConfirmEvent,
|
||||
} from "@stripe/stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
|
||||
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { BrowserDetector } from "@/utils/browser-detect";
|
||||
import { PlatformDetector } from "@/utils/platform-detect";
|
||||
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||
|
||||
@@ -35,16 +26,8 @@ const stripePromise = stripePublishableKey
|
||||
? loadStripe(stripePublishableKey)
|
||||
: null;
|
||||
|
||||
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||
|
||||
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||
return value === true || (typeof value === "object" && value.available);
|
||||
}
|
||||
|
||||
export interface StripePaymentDialogProps {
|
||||
clientSecret: string;
|
||||
customerSessionClientSecret?: string | null;
|
||||
savedPaymentMethodsEnabled?: boolean;
|
||||
returnPath?: string;
|
||||
onClose: () => void;
|
||||
onConfirmed?: () => void;
|
||||
@@ -52,19 +35,12 @@ export interface StripePaymentDialogProps {
|
||||
|
||||
export function StripePaymentDialog({
|
||||
clientSecret,
|
||||
customerSessionClientSecret = null,
|
||||
savedPaymentMethodsEnabled = false,
|
||||
returnPath = ROUTES.subscription + "/success",
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: StripePaymentDialogProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
||||
provider: "stripe",
|
||||
customerSessionClientSecret,
|
||||
savedPaymentMethodsEnabled,
|
||||
});
|
||||
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
@@ -124,10 +100,6 @@ export function StripePaymentDialog({
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
locale: "en",
|
||||
...(savedCardClientSecret
|
||||
? { customerSessionClientSecret: savedCardClientSecret }
|
||||
: {}),
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
@@ -160,20 +132,6 @@ function StripePaymentForm({
|
||||
const elements = useElements();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const submittingRef = useRef(false);
|
||||
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia("(min-width: 640px)");
|
||||
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
|
||||
updateColumns();
|
||||
query.addEventListener("change", updateColumns);
|
||||
return () => query.removeEventListener("change", updateColumns);
|
||||
}, []);
|
||||
|
||||
const handleLoadError = (event: Parameters<
|
||||
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||
@@ -192,118 +150,38 @@ function StripePaymentForm({
|
||||
);
|
||||
};
|
||||
|
||||
const updateExpressAvailability = (
|
||||
availablePaymentMethods:
|
||||
| AvailablePaymentMethods
|
||||
| {
|
||||
applePay?: { available: boolean };
|
||||
googlePay?: { available: boolean };
|
||||
link?: { available: boolean };
|
||||
paypal?: { available: boolean };
|
||||
amazonPay?: { available: boolean };
|
||||
klarna?: { available: boolean };
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
const availability = {
|
||||
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
||||
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
||||
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
||||
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
||||
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
||||
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
||||
};
|
||||
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
||||
const browser = BrowserDetector.isFacebookInAppBrowser()
|
||||
? "facebook"
|
||||
: BrowserDetector.getBrowserName() || "unknown";
|
||||
const platform = PlatformDetector.getPlatform();
|
||||
const metadata = {
|
||||
browser,
|
||||
platform,
|
||||
availablePaymentMethods: availability,
|
||||
};
|
||||
log.info(
|
||||
"[stripe-payment-dialog] Express Checkout availability",
|
||||
metadata,
|
||||
);
|
||||
behaviorAnalytics.elementClick(
|
||||
"stripe.express_checkout_availability",
|
||||
"Stripe Express Checkout availability",
|
||||
metadata,
|
||||
);
|
||||
};
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements || isSubmitting) return;
|
||||
|
||||
const handleExpressLoadError = (event: Parameters<
|
||||
NonNullable<
|
||||
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
||||
>
|
||||
>[0]) => {
|
||||
setHasExpressPaymentMethods(false);
|
||||
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
||||
type: event.error.type,
|
||||
code: event.error.code,
|
||||
message: event.error.message,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmPayment = async (
|
||||
options: {
|
||||
submitPaymentElement: boolean;
|
||||
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
||||
},
|
||||
) => {
|
||||
if (!stripe || !elements || submittingRef.current) return;
|
||||
|
||||
submittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
if (options.submitPaymentElement) {
|
||||
let submitError: unknown;
|
||||
try {
|
||||
({ error: submitError } = await elements.submit());
|
||||
} catch (error) {
|
||||
submitError = error;
|
||||
}
|
||||
if (submitError) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(
|
||||
submitError,
|
||||
"Please check your payment info.",
|
||||
),
|
||||
);
|
||||
submittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = new URL(
|
||||
returnPath ?? ROUTES.subscription + "/success",
|
||||
window.location.origin,
|
||||
);
|
||||
let confirmationError: unknown;
|
||||
try {
|
||||
({ error: confirmationError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
}));
|
||||
} catch (error) {
|
||||
confirmationError = error;
|
||||
}
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (confirmationError) {
|
||||
const message = ExceptionHandler.message(
|
||||
confirmationError,
|
||||
"Payment confirmation failed.",
|
||||
if (error) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
||||
);
|
||||
setErrorMessage(message);
|
||||
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||
submittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -311,75 +189,9 @@ function StripePaymentForm({
|
||||
onConfirmed?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await confirmPayment({ submitPaymentElement: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<section
|
||||
className={styles.expressSection}
|
||||
hidden={hasExpressPaymentMethods === false}
|
||||
aria-label="Express payment methods"
|
||||
>
|
||||
<ExpressCheckoutElement
|
||||
options={{
|
||||
paymentMethodOrder: [
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "never",
|
||||
googlePay: "never",
|
||||
link: "auto",
|
||||
paypal: "auto",
|
||||
amazonPay: "auto",
|
||||
klarna: "auto",
|
||||
},
|
||||
layout: {
|
||||
maxColumns: expressMaxColumns,
|
||||
maxRows: 6,
|
||||
overflow: "never",
|
||||
},
|
||||
}}
|
||||
onReady={(event) =>
|
||||
updateExpressAvailability(event.availablePaymentMethods)
|
||||
}
|
||||
onAvailablePaymentMethodsChange={(event) =>
|
||||
updateExpressAvailability(event.paymentMethods)
|
||||
}
|
||||
onLoadError={handleExpressLoadError}
|
||||
onConfirm={(event) =>
|
||||
void confirmPayment({
|
||||
submitPaymentElement: false,
|
||||
expressEvent: event,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
className={styles.cardSection}
|
||||
aria-labelledby="payment-method-title"
|
||||
>
|
||||
<h3 id="payment-method-title" className={styles.cardTitle}>
|
||||
Choose a payment method
|
||||
</h3>
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||
wallets: {
|
||||
applePay: "auto",
|
||||
googlePay: "auto",
|
||||
link: "never",
|
||||
},
|
||||
}}
|
||||
onLoadError={handleLoadError}
|
||||
/>
|
||||
</section>
|
||||
<PaymentElement onLoadError={handleLoadError} />
|
||||
{errorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getPaymentUrl,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getPaymentUrlHostname,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -42,33 +42,31 @@ export interface UsePaymentLaunchFlowInput {
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
countryCode?: string | null;
|
||||
}
|
||||
|
||||
export interface PaymentLaunchFlow {
|
||||
ezpayPaymentUrl: string | null;
|
||||
handleEzpayCancel: () => void;
|
||||
handleEzpayConfirm: () => void;
|
||||
handleQrisClose: () => void;
|
||||
handleQrisResume: () => void;
|
||||
handleRegionalQrClose: () => void;
|
||||
handleStripeClose: () => void;
|
||||
handleStripeConfirmed: () => void;
|
||||
hasHiddenQrisPayment: boolean;
|
||||
isConfirmingEzpay: boolean;
|
||||
qrisErrorMessage: string | null;
|
||||
qrisPayment: QrisPaymentDetails | null;
|
||||
qrisStatus: PaymentContextState["orderStatus"];
|
||||
regionalQrErrorMessage: string | null;
|
||||
regionalQrPayment: RegionalQrPaymentDetails | null;
|
||||
regionalQrStatus: PaymentContextState["orderStatus"];
|
||||
resetLaunchState: () => void;
|
||||
shouldShowEzpayConfirmDialog: boolean;
|
||||
shouldShowQrisDialog: boolean;
|
||||
shouldShowRegionalQrDialog: boolean;
|
||||
shouldShowStripeDialog: boolean;
|
||||
stripeClientSecret: string | null;
|
||||
stripeCustomerSessionClientSecret: string | null;
|
||||
savedPaymentMethodsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface QrisPaymentDetails {
|
||||
export interface RegionalQrPaymentDetails {
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
experience: "gcashQrPh" | "qris" | "paymentQr";
|
||||
orderId: string;
|
||||
qrData: string;
|
||||
}
|
||||
@@ -158,33 +156,36 @@ export function usePaymentLaunchFlow({
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
countryCode,
|
||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||
const launchedNonceRef = useRef(0);
|
||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
||||
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const stripeClientSecret = payment.payParams
|
||||
? getStripeClientSecret(payment.payParams)
|
||||
: null;
|
||||
const stripeCustomerSessionClientSecret = payment.payParams
|
||||
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||
: null;
|
||||
const selectedPlan = payment.plans.find(
|
||||
(item) => item.planId === payment.selectedPlanId,
|
||||
);
|
||||
const paymentCurrency = payment.payParams
|
||||
? paymentParamString(payment.payParams, "currency") ??
|
||||
selectedPlan?.currency ??
|
||||
null
|
||||
: selectedPlan?.currency ?? null;
|
||||
const ezpayLaunchTarget =
|
||||
payment.payParams && isEzpayPayment(payment.payParams)
|
||||
? resolveEzpayLaunchTarget(payment.payParams)
|
||||
? resolveEzpayLaunchTarget(payment.payParams, {
|
||||
countryCode,
|
||||
currency: paymentCurrency,
|
||||
})
|
||||
: null;
|
||||
const ezpayPaymentUrl =
|
||||
ezpayLaunchTarget?.kind === "url"
|
||||
? ezpayLaunchTarget.paymentUrl
|
||||
: null;
|
||||
const selectedPlan = payment.plans.find(
|
||||
(item) => item.planId === payment.selectedPlanId,
|
||||
);
|
||||
const qrisPayment: QrisPaymentDetails | null =
|
||||
const regionalQrPayment: RegionalQrPaymentDetails | null =
|
||||
payment.payParams &&
|
||||
payment.currentOrderId &&
|
||||
ezpayLaunchTarget?.kind === "qr"
|
||||
@@ -194,9 +195,9 @@ export function usePaymentLaunchFlow({
|
||||
selectedPlan?.amountCents ??
|
||||
0,
|
||||
currency:
|
||||
paymentParamString(payment.payParams, "currency") ??
|
||||
selectedPlan?.currency ??
|
||||
"IDR",
|
||||
paymentCurrency ??
|
||||
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
|
||||
experience: ezpayLaunchTarget.experience,
|
||||
orderId: payment.currentOrderId,
|
||||
qrData: ezpayLaunchTarget.qrData,
|
||||
}
|
||||
@@ -215,7 +216,33 @@ export function usePaymentLaunchFlow({
|
||||
|
||||
const isEzpay = isEzpayPayment(payment.payParams);
|
||||
if (isEzpay) {
|
||||
const target = resolveEzpayLaunchTarget(payment.payParams);
|
||||
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({
|
||||
@@ -230,11 +257,18 @@ export function usePaymentLaunchFlow({
|
||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage: "Missing order id before showing QRIS.",
|
||||
errorMessage: "Missing order id before showing payment QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackPaymentCheckoutOpened(payment, "qris_embedded");
|
||||
trackPaymentCheckoutOpened(
|
||||
payment,
|
||||
target.experience === "qris"
|
||||
? "qris_embedded"
|
||||
: target.experience === "gcashQrPh"
|
||||
? "gcash_qrph_embedded"
|
||||
: "payment_qr_embedded",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,7 +276,7 @@ export function usePaymentLaunchFlow({
|
||||
if (!AppEnvUtil.isProduction()) {
|
||||
log.debug(`[${logScope}] ezpay confirmation required`, {
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl,
|
||||
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
|
||||
subscriptionType,
|
||||
});
|
||||
return;
|
||||
@@ -268,7 +302,7 @@ export function usePaymentLaunchFlow({
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (paymentUrl) {
|
||||
try {
|
||||
window.location.href = paymentUrl;
|
||||
window.location.assign(paymentUrl);
|
||||
trackPaymentCheckoutOpened(payment, paymentUrl);
|
||||
} catch {
|
||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||
@@ -289,6 +323,7 @@ export function usePaymentLaunchFlow({
|
||||
log,
|
||||
logScope,
|
||||
characterSlug,
|
||||
countryCode,
|
||||
payment.currentOrderId,
|
||||
payment,
|
||||
payment.launchNonce,
|
||||
@@ -297,6 +332,7 @@ export function usePaymentLaunchFlow({
|
||||
payment.plans,
|
||||
payment.selectedPlanId,
|
||||
paymentDispatch,
|
||||
paymentCurrency,
|
||||
returnTo,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
@@ -313,22 +349,13 @@ export function usePaymentLaunchFlow({
|
||||
payParams: payment.payParams,
|
||||
paymentUrl: ezpayPaymentUrl,
|
||||
});
|
||||
const shouldShowQrisDialog = Boolean(
|
||||
qrisPayment &&
|
||||
qrisPayment.orderId !== hiddenQrisOrderId &&
|
||||
!payment.isPaid,
|
||||
);
|
||||
const hasHiddenQrisPayment = Boolean(
|
||||
qrisPayment &&
|
||||
qrisPayment.orderId === hiddenQrisOrderId &&
|
||||
payment.isPollingOrder &&
|
||||
!payment.isPaid,
|
||||
const shouldShowRegionalQrDialog = Boolean(
|
||||
regionalQrPayment && !payment.isPaid,
|
||||
);
|
||||
|
||||
function resetLaunchState(): void {
|
||||
setIsConfirmingEzpay(false);
|
||||
setHiddenStripeClientSecret(null);
|
||||
setHiddenQrisOrderId(null);
|
||||
}
|
||||
|
||||
function handleStripeClose(): void {
|
||||
@@ -375,43 +402,30 @@ export function usePaymentLaunchFlow({
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
|
||||
function handleQrisClose(): void {
|
||||
log.debug(`[${logScope}] qris dialog closed`, {
|
||||
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||
function handleRegionalQrClose(): void {
|
||||
log.debug(`[${logScope}] regional payment QR dialog closed`, {
|
||||
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
|
||||
experience: regionalQrPayment?.experience ?? null,
|
||||
subscriptionType,
|
||||
});
|
||||
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
||||
}
|
||||
|
||||
function handleQrisResume(): void {
|
||||
if (!hasHiddenQrisPayment) return;
|
||||
log.debug(`[${logScope}] qris dialog resumed`, {
|
||||
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||
subscriptionType,
|
||||
});
|
||||
setHiddenQrisOrderId(null);
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
|
||||
return {
|
||||
ezpayPaymentUrl,
|
||||
handleEzpayCancel,
|
||||
handleEzpayConfirm,
|
||||
handleQrisClose,
|
||||
handleQrisResume,
|
||||
handleRegionalQrClose,
|
||||
handleStripeClose,
|
||||
handleStripeConfirmed,
|
||||
hasHiddenQrisPayment,
|
||||
isConfirmingEzpay,
|
||||
qrisErrorMessage: payment.errorMessage,
|
||||
qrisPayment,
|
||||
qrisStatus: payment.orderStatus,
|
||||
regionalQrErrorMessage: payment.errorMessage,
|
||||
regionalQrPayment,
|
||||
regionalQrStatus: payment.orderStatus,
|
||||
resetLaunchState,
|
||||
shouldShowEzpayConfirmDialog,
|
||||
shouldShowQrisDialog,
|
||||
shouldShowRegionalQrDialog,
|
||||
shouldShowStripeDialog,
|
||||
stripeClientSecret,
|
||||
stripeCustomerSessionClientSecret,
|
||||
savedPaymentMethodsEnabled:
|
||||
stripeCustomerSessionClientSecret !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface UsePaymentRouteFlowInput {
|
||||
characterId?: string;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -45,7 +44,6 @@ export function usePaymentRouteFlow({
|
||||
characterId,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -59,7 +57,6 @@ export function usePaymentRouteFlow({
|
||||
characterId ?? "",
|
||||
initialCategory ?? "",
|
||||
initialPlanId ?? "",
|
||||
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||
commercialOfferId ?? "",
|
||||
chatActionId ?? "",
|
||||
].join(":");
|
||||
@@ -81,9 +78,6 @@ export function usePaymentRouteFlow({
|
||||
...(characterId ? { characterId } : {}),
|
||||
...(initialCategory ? { category: initialCategory } : {}),
|
||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||
...(initialAutoRenew === null
|
||||
? {}
|
||||
: { autoRenew: initialAutoRenew }),
|
||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||
...(chatActionId ? { chatActionId } : {}),
|
||||
});
|
||||
@@ -95,7 +89,6 @@ export function usePaymentRouteFlow({
|
||||
chatActionId,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
initialPayChannel,
|
||||
paymentDispatch,
|
||||
]);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
persistExternalEntryPayload,
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
||||
import { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||
import { Result } from "@/utils/result";
|
||||
import {
|
||||
isFavoriteEntryRequest,
|
||||
@@ -63,9 +61,6 @@ export default function ExternalEntryPersist({
|
||||
const [hasPersisted, setHasPersisted] = useState(false);
|
||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
||||
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const hasNavigatedRef = useRef(false);
|
||||
const handoffStartedRef = useRef(false);
|
||||
const targetRoute = resolveExternalEntryTarget({ target });
|
||||
@@ -76,14 +71,11 @@ export default function ExternalEntryPersist({
|
||||
mode,
|
||||
promotionType,
|
||||
});
|
||||
const normalizedTarget = target?.trim().toLowerCase() ?? "";
|
||||
const isTopUpHandoff = normalizedTarget === "topup";
|
||||
const isCheckoutHandoff = normalizedTarget === "checkout";
|
||||
const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff;
|
||||
const isTopUpHandoff = targetRoute === ROUTES.subscription;
|
||||
const displayedHandoffError =
|
||||
handoffError ??
|
||||
(isLoginHandoff && !hasValue(handoffToken)
|
||||
? "This checkout link is invalid. Please return and request a new link."
|
||||
(isTopUpHandoff && !hasValue(handoffToken)
|
||||
? "This top-up link is invalid. Please request a new link in Messenger."
|
||||
: null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,7 +128,7 @@ export default function ExternalEntryPersist({
|
||||
useEffect(() => {
|
||||
if (hasNavigatedRef.current || !hasPersisted) return;
|
||||
|
||||
if (isLoginHandoff) {
|
||||
if (isTopUpHandoff) {
|
||||
if (!authState.hasInitialized || authState.isLoading) return;
|
||||
if (handoffCompleted) {
|
||||
if (
|
||||
@@ -146,60 +138,25 @@ export default function ExternalEntryPersist({
|
||||
return;
|
||||
}
|
||||
hasNavigatedRef.current = true;
|
||||
router.replace(checkoutDestination ?? destination);
|
||||
router.replace(destination);
|
||||
return;
|
||||
}
|
||||
if (handoffStartedRef.current) return;
|
||||
if (!hasValue(handoffToken)) return;
|
||||
handoffStartedRef.current = true;
|
||||
void (async () => {
|
||||
if (isCheckoutHandoff) {
|
||||
const result = await consumeCheckoutHandoff(handoffToken);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn(
|
||||
"[ExternalEntryPersist] checkout handoff failed",
|
||||
result.error,
|
||||
);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
"/external-entry?target=checkout",
|
||||
);
|
||||
setHandoffError(
|
||||
"This checkout link is invalid, expired, or already used. Please return and request a new link.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
"/external-entry?target=checkout",
|
||||
);
|
||||
setCheckoutDestination(
|
||||
resolveCheckoutIntentDestination(result.data, character),
|
||||
);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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" });
|
||||
@@ -234,12 +191,9 @@ export default function ExternalEntryPersist({
|
||||
authState.loginStatus,
|
||||
character,
|
||||
destination,
|
||||
checkoutDestination,
|
||||
hasPersisted,
|
||||
handoffCompleted,
|
||||
handoffToken,
|
||||
isCheckoutHandoff,
|
||||
isLoginHandoff,
|
||||
isTopUpHandoff,
|
||||
psid,
|
||||
router,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* `/external-entry?target=tip`
|
||||
* `/external-entry?target=private-zone&character=nayeli`
|
||||
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
||||
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
|
||||
*
|
||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||
|
||||
@@ -51,7 +51,6 @@ const mocks = vi.hoisted(() => {
|
||||
commercialOffer: null,
|
||||
selectedPlanId: "vip_monthly",
|
||||
payChannel: "stripe" as const,
|
||||
autoRenew: true,
|
||||
agreed: true,
|
||||
currentOrderId: null,
|
||||
isCreatingOrder: false,
|
||||
@@ -101,9 +100,7 @@ vi.mock("@/hooks/use-has-hydrated", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/user/user-context", () => ({
|
||||
useUserState: () => ({
|
||||
currentUser: { id: "user-renewal-1", countryCode: "ID" },
|
||||
}),
|
||||
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/character-catalog-provider", () => ({
|
||||
@@ -167,25 +164,26 @@ vi.mock("../components", () => ({
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
SubscriptionCheckoutButton: ({
|
||||
disabled,
|
||||
renewalPlan,
|
||||
renewalConsentSubjectId,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
renewalPlan: { id: string } | null;
|
||||
renewalConsentSubjectId: string | null;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="checkout"
|
||||
data-renewal-plan={renewalPlan?.id ?? ""}
|
||||
data-renewal-subject={renewalConsentSubjectId ?? ""}
|
||||
disabled={disabled}
|
||||
>
|
||||
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,
|
||||
}));
|
||||
@@ -220,30 +218,66 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("allows immediate checkout and changes VIP plans without opening renewal confirmation", () => {
|
||||
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(false);
|
||||
expect(checkout?.dataset.renewalPlan).toBe("vip_monthly");
|
||||
expect(checkout?.dataset.renewalSubject).toBe("user-renewal-1");
|
||||
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.paymentDispatch).toHaveBeenCalledWith({
|
||||
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"));
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
canCheckoutSubscriptionPlan,
|
||||
findSelectedSubscriptionPlan,
|
||||
getDefaultSubscriptionPlanId,
|
||||
getFirstRechargeOfferView,
|
||||
requiresVipPlanConfirmation,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
} from "../subscription-screen.helpers";
|
||||
@@ -278,4 +280,85 @@ describe("subscription screen helpers", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("requires confirmation for each unconfirmed VIP plan", () => {
|
||||
const vipPlans = [
|
||||
{
|
||||
id: "vip_monthly",
|
||||
title: "Monthly",
|
||||
price: "19.90",
|
||||
currency: "usd",
|
||||
originalPrice: "",
|
||||
},
|
||||
{
|
||||
id: "vip_quarterly",
|
||||
title: "Quarterly",
|
||||
price: "49.90",
|
||||
currency: "usd",
|
||||
originalPrice: "",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
requiresVipPlanConfirmation({
|
||||
planId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresVipPlanConfirmation({
|
||||
planId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(["vip_monthly"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresVipPlanConfirmation({
|
||||
planId: "coin_1000",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows checkout only after the selected VIP plan is confirmed", () => {
|
||||
const vipPlans = [
|
||||
{
|
||||
id: "vip_monthly",
|
||||
title: "Monthly",
|
||||
price: "19.90",
|
||||
currency: "usd",
|
||||
originalPrice: "",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(["vip_monthly"]),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "coin_1000",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
handleQrisResume: vi.fn(),
|
||||
hasHiddenQrisPayment: false,
|
||||
resetLaunchState: vi.fn(),
|
||||
payment: {
|
||||
selectedPlanId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
payChannel: "stripe" as "stripe" | "ezpay",
|
||||
commercialOfferId: null,
|
||||
chatActionId: null,
|
||||
currentOrderId: null,
|
||||
errorMessage: null,
|
||||
isCreatingOrder: false,
|
||||
isPollingOrder: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/payment/payment-context", () => ({
|
||||
usePaymentState: () => mocks.payment,
|
||||
usePaymentDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
|
||||
usePaymentLaunchFlow: () => ({
|
||||
handleQrisResume: mocks.handleQrisResume,
|
||||
hasHiddenQrisPayment: mocks.hasHiddenQrisPayment,
|
||||
resetLaunchState: mocks.resetLaunchState,
|
||||
launch: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_components/payment/payment-launch-dialogs", () => ({
|
||||
PaymentLaunchDialogs: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_components/payment/external-browser-checkout-button", () => ({
|
||||
ExternalBrowserCheckoutButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../subscription-cta-button", () => ({
|
||||
SubscriptionCtaButton: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../subscription-renewal-confirmation-dialog", () => ({
|
||||
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,
|
||||
}));
|
||||
|
||||
import { SubscriptionCheckoutButton } from "../subscription-checkout-button";
|
||||
|
||||
const renewalPlan = {
|
||||
id: "vip_monthly",
|
||||
title: "Monthly",
|
||||
price: "19.90",
|
||||
currency: "usd",
|
||||
originalPrice: "39.90",
|
||||
};
|
||||
|
||||
describe("SubscriptionCheckoutButton renewal confirmation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.handleQrisResume.mockClear();
|
||||
mocks.hasHiddenQrisPayment = false;
|
||||
mocks.resetLaunchState.mockClear();
|
||||
mocks.payment.selectedPlanId = "vip_monthly";
|
||||
mocks.payment.autoRenew = true;
|
||||
mocks.payment.payChannel = "stripe";
|
||||
mocks.payment.isCreatingOrder = false;
|
||||
mocks.payment.isPollingOrder = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("shows confirmation on the first auto-renewing VIP checkout and resumes after confirm", () => {
|
||||
renderButton(root, "user-1");
|
||||
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not ask the same user again after confirmation", () => {
|
||||
renderButton(root, "user-1");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.resetLaunchState.mockClear();
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
renderButton(root, "user-1");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps asking after cancel and isolates acknowledgement by user", () => {
|
||||
renderButton(root, "user-1");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
act(() => clickButton(container, "Cancel"));
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
mocks.dispatch.mockClear();
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
renderButton(root, "user-2");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips confirmation for a non-renewing purchase", () => {
|
||||
mocks.payment.autoRenew = false;
|
||||
renderButton(root, "user-1", null);
|
||||
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips confirmation for one-time EzPay membership checkout", () => {
|
||||
mocks.payment.payChannel = "ezpay";
|
||||
renderButton(root, "user-1");
|
||||
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("reopens a hidden QRIS order without creating another order", () => {
|
||||
mocks.payment.payChannel = "ezpay";
|
||||
mocks.payment.isPollingOrder = true;
|
||||
mocks.hasHiddenQrisPayment = true;
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
<SubscriptionCheckoutButton
|
||||
disabled
|
||||
subscriptionType="topup"
|
||||
sourceCharacterSlug="elio"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const resumeButton = getButton(container, "Resume QRIS payment");
|
||||
expect(resumeButton.disabled).toBe(false);
|
||||
act(() => resumeButton.click());
|
||||
|
||||
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
|
||||
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
|
||||
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function renderButton(
|
||||
root: Root,
|
||||
renewalConsentSubjectId: string,
|
||||
plan: typeof renewalPlan | null = renewalPlan,
|
||||
): void {
|
||||
act(() =>
|
||||
root.render(
|
||||
<SubscriptionCheckoutButton
|
||||
subscriptionType="vip"
|
||||
sourceCharacterSlug="elio"
|
||||
renewalPlan={plan}
|
||||
renewalConsentSubjectId={renewalConsentSubjectId}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function clickButton(root: ParentNode, label: string): void {
|
||||
getButton(root, label).click();
|
||||
}
|
||||
|
||||
function getButton(root: ParentNode, label: string): HTMLButtonElement {
|
||||
const button = Array.from(root.querySelectorAll("button")).find(
|
||||
(item) => item.textContent?.trim() === label,
|
||||
);
|
||||
if (!button) throw new Error(`Expected ${label} button`);
|
||||
return button;
|
||||
}
|
||||
@@ -71,14 +71,7 @@ describe("subscription payment dialogs", () => {
|
||||
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
|
||||
expect(dialog?.textContent).toContain("Monthly");
|
||||
expect(dialog?.textContent).toContain("19.90 usd");
|
||||
expect(
|
||||
Array.from(dialog?.querySelectorAll("a") ?? []).map((link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual([
|
||||
"/legal/vip-membership-benefits.html",
|
||||
"/legal/automatic-renewal.html",
|
||||
]);
|
||||
expect(dialog?.querySelectorAll("a")).toHaveLength(2);
|
||||
|
||||
act(() => clickButton(dialog, "Confirm"));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -4,17 +4,10 @@
|
||||
*
|
||||
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import {
|
||||
hasAutomaticRenewalAcknowledgement,
|
||||
rememberAutomaticRenewalAcknowledgement,
|
||||
} from "@/lib/payment/automatic_renewal_acknowledgement";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
@@ -22,19 +15,16 @@ import {
|
||||
import { Logger } from "@/utils/logger";
|
||||
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import { SubscriptionRenewalConfirmationDialog } from "./subscription-renewal-confirmation-dialog";
|
||||
import type { VipOfferPlanView } from "./subscription-vip-offer-section";
|
||||
|
||||
const log = new Logger("SubscriptionCheckoutButton");
|
||||
|
||||
export interface SubscriptionCheckoutButtonProps {
|
||||
/** 是否可用(未选套餐、缺少角色来源或支付处理中为 false) */
|
||||
/** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false) */
|
||||
disabled?: boolean;
|
||||
subscriptionType: "vip" | "topup";
|
||||
returnTo?: SubscriptionReturnTo;
|
||||
sourceCharacterSlug?: string;
|
||||
renewalPlan?: VipOfferPlanView | null;
|
||||
renewalConsentSubjectId?: string | null;
|
||||
countryCode?: string | null;
|
||||
}
|
||||
|
||||
export function SubscriptionCheckoutButton({
|
||||
@@ -42,11 +32,8 @@ export function SubscriptionCheckoutButton({
|
||||
subscriptionType,
|
||||
returnTo = null,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
renewalPlan = null,
|
||||
renewalConsentSubjectId = null,
|
||||
countryCode = null,
|
||||
}: SubscriptionCheckoutButtonProps) {
|
||||
const [showRenewalConfirmation, setShowRenewalConfirmation] =
|
||||
useState(false);
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const paymentLaunch = usePaymentLaunchFlow({
|
||||
@@ -57,73 +44,29 @@ export function SubscriptionCheckoutButton({
|
||||
returnTo: returnTo ?? undefined,
|
||||
characterSlug: sourceCharacterSlug,
|
||||
subscriptionType,
|
||||
countryCode,
|
||||
});
|
||||
|
||||
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
|
||||
const isLoading =
|
||||
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
|
||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||
const readyLabel = "Pay and Top Up";
|
||||
const label = isResumingQris
|
||||
? "Resume QRIS payment"
|
||||
: payment.isPollingOrder
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
: readyLabel;
|
||||
const label = payment.isPollingOrder
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
: readyLabel;
|
||||
|
||||
const startCheckout = () => {
|
||||
const handleClick = () => {
|
||||
if (disabled || isLoading) return;
|
||||
paymentLaunch.resetLaunchState();
|
||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isResumingQris) {
|
||||
paymentLaunch.handleQrisResume();
|
||||
return;
|
||||
}
|
||||
if (disabled || isLoading) return;
|
||||
if (
|
||||
payment.payChannel === "stripe" &&
|
||||
payment.autoRenew &&
|
||||
renewalPlan !== null &&
|
||||
!hasAutomaticRenewalAcknowledgement(renewalConsentSubjectId)
|
||||
) {
|
||||
setShowRenewalConfirmation(true);
|
||||
return;
|
||||
}
|
||||
startCheckout();
|
||||
};
|
||||
|
||||
const handleRenewalConfirm = () => {
|
||||
rememberAutomaticRenewalAcknowledgement(renewalConsentSubjectId);
|
||||
setShowRenewalConfirmation(false);
|
||||
startCheckout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||
<ExternalBrowserCheckoutButton
|
||||
disabled={disabled || isLoading}
|
||||
characterSlug={sourceCharacterSlug}
|
||||
checkoutIntent={{
|
||||
planId: payment.selectedPlanId,
|
||||
autoRenew: payment.autoRenew,
|
||||
...(payment.commercialOfferId
|
||||
? { commercialOfferId: payment.commercialOfferId }
|
||||
: {}),
|
||||
...(payment.chatActionId
|
||||
? { chatActionId: payment.chatActionId }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SubscriptionCtaButton
|
||||
type="button"
|
||||
data-analytics-key="subscription.checkout"
|
||||
data-analytics-label="Start subscription checkout"
|
||||
disabled={disabled && !isResumingQris}
|
||||
disabled={disabled}
|
||||
isLoading={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
@@ -148,12 +91,6 @@ export function SubscriptionCheckoutButton({
|
||||
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
|
||||
launch={paymentLaunch}
|
||||
/>
|
||||
<SubscriptionRenewalConfirmationDialog
|
||||
open={showRenewalConfirmation}
|
||||
plan={renewalPlan}
|
||||
onCancel={() => setShowRenewalConfirmation(false)}
|
||||
onConfirm={handleRenewalConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/lib/analytics/payment_analytics_context";
|
||||
import {
|
||||
getFirstPaymentSearchParam,
|
||||
parsePaymentAutoRenew,
|
||||
parsePaymentReturnSearchParams,
|
||||
parseSubscriptionReturnTo,
|
||||
type PaymentSearchParams,
|
||||
@@ -56,7 +55,6 @@ export default async function SubscriptionPage({
|
||||
analyticsContext={analyticsContext}
|
||||
sourceCharacterSlug={sourceCharacterSlug}
|
||||
initialPlanId={initialPlanId}
|
||||
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
|
||||
commercialOfferId={commercialOfferId}
|
||||
resumeOrderId={resumeOrderId}
|
||||
chatActionId={chatActionId}
|
||||
|
||||
@@ -105,6 +105,30 @@ export function findSelectedSubscriptionPlan(input: {
|
||||
return plans.find((plan) => plan.id === input.selectedPlanId) ?? null;
|
||||
}
|
||||
|
||||
export function requiresVipPlanConfirmation(input: {
|
||||
planId: string;
|
||||
vipPlans: readonly VipOfferPlanView[];
|
||||
confirmedVipPlanIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return (
|
||||
input.vipPlans.some((plan) => plan.id === input.planId) &&
|
||||
!input.confirmedVipPlanIds.has(input.planId)
|
||||
);
|
||||
}
|
||||
|
||||
export function canCheckoutSubscriptionPlan(input: {
|
||||
selectedPlanId: string;
|
||||
vipPlans: readonly VipOfferPlanView[];
|
||||
confirmedVipPlanIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!input.selectedPlanId) return false;
|
||||
return !requiresVipPlanConfirmation({
|
||||
planId: input.selectedPlanId,
|
||||
vipPlans: input.vipPlans,
|
||||
confirmedVipPlanIds: input.confirmedVipPlanIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDefaultSubscriptionPlanId(input: {
|
||||
canSubscribeVip: boolean;
|
||||
selectedPlanId: string;
|
||||
|
||||
@@ -24,13 +24,16 @@ import {
|
||||
SubscriptionCoinsOfferSection,
|
||||
SubscriptionPaymentIssueDialog,
|
||||
SubscriptionPaymentSuccessDialog,
|
||||
SubscriptionRenewalConfirmationDialog,
|
||||
SubscriptionVipOfferSection,
|
||||
} from "./components";
|
||||
import styles from "./components/subscription-screen.module.css";
|
||||
import {
|
||||
canCheckoutSubscriptionPlan,
|
||||
findSelectedSubscriptionPlan,
|
||||
getFirstRechargeOfferView,
|
||||
getDefaultSubscriptionPlanId,
|
||||
requiresVipPlanConfirmation,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
type SubscriptionType,
|
||||
@@ -47,7 +50,6 @@ export interface SubscriptionScreenProps {
|
||||
analyticsContext?: PaymentAnalyticsContext;
|
||||
sourceCharacterSlug?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -61,11 +63,14 @@ export function SubscriptionScreen({
|
||||
analyticsContext: providedAnalyticsContext,
|
||||
sourceCharacterSlug = null,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
}: SubscriptionScreenProps) {
|
||||
const [confirmedVipPlanIds, setConfirmedVipPlanIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
const [pendingVipPlanId, setPendingVipPlanId] = useState<string | null>(null);
|
||||
const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false);
|
||||
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
|
||||
null,
|
||||
@@ -95,7 +100,6 @@ export function SubscriptionScreen({
|
||||
sourceCharacterSlug: sourceCharacter?.slug ?? null,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
@@ -149,20 +153,49 @@ export function SubscriptionScreen({
|
||||
const isPaymentBusy =
|
||||
payment.isCreatingOrder ||
|
||||
payment.isPollingOrder;
|
||||
const selectedVipPlan =
|
||||
vipOfferPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
|
||||
const selectedPlanIsVip = selectedVipPlan !== null;
|
||||
const selectedPlanIsVip = vipOfferPlans.some(
|
||||
(plan) => plan.id === payment.selectedPlanId,
|
||||
);
|
||||
const canActivate =
|
||||
sourceCharacter !== null &&
|
||||
selectedPlan !== null &&
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: payment.selectedPlanId,
|
||||
vipPlans: vipOfferPlans,
|
||||
confirmedVipPlanIds,
|
||||
}) &&
|
||||
!isPaymentBusy;
|
||||
const pendingVipPlan =
|
||||
vipOfferPlans.find((plan) => plan.id === pendingVipPlanId) ?? null;
|
||||
|
||||
const handleSelectPlan = (planId: string) => {
|
||||
const plan = payment.plans.find((item) => item.planId === planId);
|
||||
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
|
||||
if (
|
||||
requiresVipPlanConfirmation({
|
||||
planId,
|
||||
vipPlans: vipOfferPlans,
|
||||
confirmedVipPlanIds,
|
||||
})
|
||||
) {
|
||||
setPendingVipPlanId(planId);
|
||||
return;
|
||||
}
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
};
|
||||
|
||||
const handleConfirmVipPlan = () => {
|
||||
if (!pendingVipPlanId) return;
|
||||
const planId = pendingVipPlanId;
|
||||
setConfirmedVipPlanIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(planId);
|
||||
return next;
|
||||
});
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
setPendingVipPlanId(null);
|
||||
};
|
||||
|
||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
@@ -304,11 +337,17 @@ export function SubscriptionScreen({
|
||||
subscriptionType={subscriptionType}
|
||||
returnTo={returnTo}
|
||||
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||
renewalPlan={selectedVipPlan}
|
||||
renewalConsentSubjectId={userState.currentUser?.id || null}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscriptionRenewalConfirmationDialog
|
||||
open={pendingVipPlan !== null}
|
||||
plan={pendingVipPlan}
|
||||
onCancel={() => setPendingVipPlanId(null)}
|
||||
onConfirm={handleConfirmVipPlan}
|
||||
/>
|
||||
|
||||
{showPaymentIssueDialog ? (
|
||||
<SubscriptionPaymentIssueDialog
|
||||
open
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface UseSubscriptionPaymentFlowInput {
|
||||
initialPayChannel: PayChannel;
|
||||
sourceCharacterSlug?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -37,7 +36,6 @@ export function useSubscriptionPaymentFlow({
|
||||
initialPayChannel,
|
||||
sourceCharacterSlug = null,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -51,7 +49,6 @@ export function useSubscriptionPaymentFlow({
|
||||
shouldResumePendingOrder,
|
||||
characterId: supportCharacter?.id,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
|
||||
@@ -359,8 +359,6 @@ function makePaymentState(
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
commercialOfferId: null,
|
||||
chatActionId: null,
|
||||
selectedPlanId: giftPlan.planId,
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
@@ -20,6 +19,7 @@ export interface TipCheckoutButtonProps {
|
||||
disabled?: boolean;
|
||||
onOrder: () => void;
|
||||
returnPath: string;
|
||||
countryCode?: string | null;
|
||||
}
|
||||
|
||||
export function TipCheckoutButton({
|
||||
@@ -28,6 +28,7 @@ export function TipCheckoutButton({
|
||||
disabled = false,
|
||||
onOrder,
|
||||
returnPath,
|
||||
countryCode = null,
|
||||
}: TipCheckoutButtonProps) {
|
||||
const character = useActiveCharacter();
|
||||
const payment = usePaymentState();
|
||||
@@ -41,14 +42,11 @@ export function TipCheckoutButton({
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
characterSlug: character.slug,
|
||||
countryCode,
|
||||
});
|
||||
|
||||
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
|
||||
const isLoading =
|
||||
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
|
||||
const label = isResumingQris
|
||||
? "Resume QRIS payment"
|
||||
: payment.isPollingOrder
|
||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||
const label = payment.isPollingOrder
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
@@ -63,24 +61,11 @@ export function TipCheckoutButton({
|
||||
data-analytics-key="tip.checkout"
|
||||
data-analytics-label="Buy coffee tip"
|
||||
className={styles.checkoutButton}
|
||||
disabled={(disabled && !isResumingQris) || isLoading}
|
||||
onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
|
||||
disabled={disabled || isLoading}
|
||||
onClick={onOrder}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||
<ExternalBrowserCheckoutButton
|
||||
disabled={disabled || isLoading}
|
||||
checkoutIntent={{
|
||||
planId: payment.selectedPlanId,
|
||||
autoRenew: false,
|
||||
recipientCharacterId: character.id,
|
||||
...(payment.chatActionId
|
||||
? { chatActionId: payment.chatActionId }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{payment.errorMessage ? (
|
||||
<p className={styles.checkoutError} role="alert">
|
||||
{payment.errorMessage}
|
||||
|
||||
@@ -305,6 +305,7 @@ export function TipScreen({
|
||||
disabled={!canCreateOrder}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
countryCode={userState.currentUser?.countryCode}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,11 +13,11 @@ export class AppConstants {
|
||||
|
||||
/** 会员服务协议文档链接 */
|
||||
static readonly membershipAgreementUrl =
|
||||
"/legal/vip-membership-benefits.html";
|
||||
"https://www.banlv-ai.com/cozsweet/membership-agreement.html";
|
||||
|
||||
/** 自动续费服务协议文档链接 */
|
||||
static readonly autoRenewalAgreementUrl =
|
||||
"/legal/automatic-renewal.html";
|
||||
"https://www.banlv-ai.com/cozsweet/auto-renewal-agreement.html";
|
||||
|
||||
/** 开发环境 APP 标题 */
|
||||
static readonly appTitleDevelopment = "Develop";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
CheckoutHandoffConsumeRequestSchema,
|
||||
CheckoutHandoffCreateRequestSchema,
|
||||
type CheckoutHandoffCreateResponse,
|
||||
type CheckoutIntent,
|
||||
FacebookIdentityRequestSchema,
|
||||
FacebookLoginRequestSchema,
|
||||
FbIdLoginRequestSchema,
|
||||
@@ -280,30 +276,6 @@ export class AuthRepository implements IAuthRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
|
||||
async createCheckoutHandoff(
|
||||
checkoutIntent: CheckoutIntent,
|
||||
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.createCheckoutHandoff(
|
||||
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
|
||||
async consumeCheckoutHandoff(
|
||||
handoffToken: string,
|
||||
): Promise<Result<CheckoutIntent>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.consumeCheckoutHandoff(
|
||||
CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }),
|
||||
);
|
||||
await this._saveLoginData(response, response.loginStatus);
|
||||
return response.checkoutIntent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
|
||||
* 调用 API 成功后写回新的 login token + refresh token。
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
|
||||
import type { Result } from "@/utils/result";
|
||||
import type {
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutIntent,
|
||||
GuestLoginResponse,
|
||||
LoginStatus,
|
||||
LoginResponse,
|
||||
@@ -76,16 +74,6 @@ export interface IAuthRepository {
|
||||
/** 消费一次性充值登录凭证并建立正式会话。 */
|
||||
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
|
||||
|
||||
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
|
||||
createCheckoutHandoff(
|
||||
checkoutIntent: CheckoutIntent,
|
||||
): Promise<Result<CheckoutHandoffCreateResponse>>;
|
||||
|
||||
/** 消费单次支付交接凭证并保存正式登录态。 */
|
||||
consumeCheckoutHandoff(
|
||||
handoffToken: string,
|
||||
): Promise<Result<CheckoutIntent>>;
|
||||
|
||||
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
||||
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CheckoutHandoffConsumeResponseSchema,
|
||||
CheckoutHandoffCreateRequestSchema,
|
||||
} from "../checkout_handoff";
|
||||
|
||||
describe("checkout handoff schemas", () => {
|
||||
it("keeps only the minimal order-free purchase intent", () => {
|
||||
expect(
|
||||
CheckoutHandoffCreateRequestSchema.parse({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "elio",
|
||||
price: 9.99,
|
||||
}),
|
||||
).toEqual({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "elio",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the formal session and checkout intent returned after consumption", () => {
|
||||
const parsed = CheckoutHandoffConsumeResponseSchema.parse({
|
||||
user: {
|
||||
id: "00000000-0000-0000-0000-000000000123",
|
||||
username: "checkout-user",
|
||||
email: "checkout@example.com",
|
||||
platform: "web",
|
||||
},
|
||||
token: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
loginStatus: "email",
|
||||
checkoutIntent: {
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.checkoutIntent.planId).toBe("vip_monthly");
|
||||
expect(parsed.loginStatus).toBe("email");
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrEmpty } from "../nullable-defaults";
|
||||
import { UserSchema } from "../user/user";
|
||||
|
||||
export const CheckoutIntentSchema = z
|
||||
.object({
|
||||
planId: z.string().min(1).max(120),
|
||||
autoRenew: z.boolean(),
|
||||
recipientCharacterId: z.string().min(1).max(80).optional(),
|
||||
commercialOfferId: z.string().min(1).max(80).optional(),
|
||||
chatActionId: z.uuid().optional(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema;
|
||||
|
||||
export const CheckoutHandoffCreateResponseSchema = z
|
||||
.object({
|
||||
externalUrl: z.url(),
|
||||
expiresAt: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const CheckoutHandoffConsumeRequestSchema = z
|
||||
.object({
|
||||
handoffToken: z.string().min(32).max(512),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const CheckoutHandoffConsumeResponseSchema = z
|
||||
.object({
|
||||
user: UserSchema,
|
||||
token: z.string().min(1),
|
||||
refreshToken: stringOrEmpty,
|
||||
loginStatus: z.enum([
|
||||
"email",
|
||||
"google",
|
||||
"facebook",
|
||||
"facebookMessenger",
|
||||
]),
|
||||
checkoutIntent: CheckoutIntentSchema,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type CheckoutIntent = z.output<typeof CheckoutIntentSchema>;
|
||||
export type CheckoutHandoffCreateRequest = z.output<
|
||||
typeof CheckoutHandoffCreateRequestSchema
|
||||
>;
|
||||
export type CheckoutHandoffCreateResponse = z.output<
|
||||
typeof CheckoutHandoffCreateResponseSchema
|
||||
>;
|
||||
export type CheckoutHandoffConsumeRequest = z.output<
|
||||
typeof CheckoutHandoffConsumeRequestSchema
|
||||
>;
|
||||
export type CheckoutHandoffConsumeResponse = z.output<
|
||||
typeof CheckoutHandoffConsumeResponseSchema
|
||||
>;
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./facebook_user_data";
|
||||
export * from "./checkout_handoff";
|
||||
export * from "./login_status";
|
||||
export * from "./request/facebook_identity_request";
|
||||
export * from "./request/facebook_login_request";
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
|
||||
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
|
||||
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
|
||||
"checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" },
|
||||
"checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" },
|
||||
"refresh": { "method": "post", "path": "/api/auth/refresh" },
|
||||
"logout": { "method": "post", "path": "/api/auth/logout" },
|
||||
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
|
||||
|
||||
@@ -30,12 +30,6 @@ export class ApiPath {
|
||||
/** 消费一次性充值登录凭证 */
|
||||
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
|
||||
|
||||
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
|
||||
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
|
||||
|
||||
/** 消费一次性外部浏览器支付交接凭证。 */
|
||||
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
|
||||
|
||||
/** 刷新 Token */
|
||||
static readonly refresh = apiContract.refresh.path;
|
||||
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CheckoutHandoffConsumeRequest,
|
||||
CheckoutHandoffConsumeResponse,
|
||||
CheckoutHandoffConsumeResponseSchema,
|
||||
CheckoutHandoffCreateRequest,
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutHandoffCreateResponseSchema,
|
||||
FacebookIdentityRequest,
|
||||
FacebookIdentityResponse,
|
||||
FacebookIdentityResponseSchema,
|
||||
@@ -110,32 +104,6 @@ export class AuthApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** 在创建支付订单前生成一次性外部浏览器交接链接。 */
|
||||
async createCheckoutHandoff(
|
||||
body: CheckoutHandoffCreateRequest,
|
||||
): Promise<CheckoutHandoffCreateResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.checkoutHandoffCreate,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
return CheckoutHandoffCreateResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */
|
||||
async consumeCheckoutHandoff(
|
||||
body: CheckoutHandoffConsumeRequest,
|
||||
): Promise<CheckoutHandoffConsumeResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.checkoutHandoffConsume,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
return CheckoutHandoffConsumeResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定 Facebook ASID / PSID 到当前用户
|
||||
*/
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type {
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutIntent,
|
||||
} from "@/data/schemas/auth";
|
||||
import { getAuthRepository } from "@/data/repositories/auth_repository";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export function createCheckoutHandoff(
|
||||
checkoutIntent: CheckoutIntent,
|
||||
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
||||
return getAuthRepository().createCheckoutHandoff(checkoutIntent);
|
||||
}
|
||||
|
||||
export function consumeCheckoutHandoff(
|
||||
handoffToken: string,
|
||||
): Promise<Result<CheckoutIntent>> {
|
||||
return getAuthRepository().consumeCheckoutHandoff(handoffToken);
|
||||
}
|
||||
@@ -3,40 +3,26 @@ import { describe, expect, it } from "vitest";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
|
||||
import {
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
shouldBindExternalFacebookIdentity,
|
||||
} from "../external_entry";
|
||||
|
||||
describe("checkout external browser destination", () => {
|
||||
it("restores subscription intent without carrying the handoff token", () => {
|
||||
expect(
|
||||
resolveCheckoutIntentDestination(
|
||||
{
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
commercialOfferId: "offer-1",
|
||||
chatActionId: "00000000-0000-0000-0000-000000000123",
|
||||
},
|
||||
"nayeli",
|
||||
),
|
||||
).toBe(
|
||||
"/subscription?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores a character tip intent by canonical character id", () => {
|
||||
expect(
|
||||
resolveCheckoutIntentDestination({
|
||||
planId: "maya_coffee",
|
||||
autoRenew: false,
|
||||
recipientCharacterId: "maya-tan",
|
||||
}),
|
||||
).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe");
|
||||
});
|
||||
});
|
||||
const facebookBusinessEntryCases = [
|
||||
["private-space", "elio", "chat", null, null, getCharacterRoutes("elio").chat, null],
|
||||
["private-space", "maya", "chat", null, null, getCharacterRoutes("maya").chat, null],
|
||||
["private-space", "nayeli", "chat", null, null, getCharacterRoutes("nayeli").chat, null],
|
||||
["voice", "elio", "chat", "promotion", "voice", getCharacterRoutes("elio").chat, "voice"],
|
||||
["voice", "maya", "chat", "promotion", "voice", getCharacterRoutes("maya").chat, "voice"],
|
||||
["voice", "nayeli", "chat", "promotion", "voice", getCharacterRoutes("nayeli").chat, "voice"],
|
||||
["image-pack", "elio", "private-zone", null, null, getCharacterRoutes("elio").privateZone, null],
|
||||
["image-pack", "maya", "private-zone", null, null, getCharacterRoutes("maya").privateZone, null],
|
||||
["image-pack", "nayeli", "private-zone", null, null, getCharacterRoutes("nayeli").privateZone, null],
|
||||
["coffee", "elio", "tip", null, null, getCharacterRoutes("elio").tip, null],
|
||||
["coffee", "maya", "tip", null, null, getCharacterRoutes("maya").tip, null],
|
||||
["coffee", "nayeli", "tip", null, null, getCharacterRoutes("nayeli").tip, null],
|
||||
] as const;
|
||||
|
||||
describe("external entry navigation", () => {
|
||||
it("defaults to chat", () => {
|
||||
@@ -91,6 +77,26 @@ describe("external entry navigation", () => {
|
||||
).toBe(getCharacterRoutes("elio").privateZone);
|
||||
});
|
||||
|
||||
it.each(facebookBusinessEntryCases)(
|
||||
"routes Facebook business entry %s for %s",
|
||||
(
|
||||
_entry,
|
||||
character,
|
||||
target,
|
||||
mode,
|
||||
promotionType,
|
||||
expectedDestination,
|
||||
expectedPromotionType,
|
||||
) => {
|
||||
expect(resolveExternalEntryDestination({ target, character })).toBe(
|
||||
expectedDestination,
|
||||
);
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({ mode, promotionType }),
|
||||
).toBe(expectedPromotionType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["chat", "elio", getCharacterRoutes("elio").chat],
|
||||
["chat", "maya", getCharacterRoutes("maya").chat],
|
||||
|
||||
@@ -4,10 +4,8 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||
import {
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterById,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
@@ -102,34 +100,6 @@ export function resolveExternalEntryDestination({
|
||||
return routes.chat;
|
||||
}
|
||||
|
||||
export function resolveCheckoutIntentDestination(
|
||||
intent: CheckoutIntent,
|
||||
sourceCharacterSlug?: string | null,
|
||||
): string {
|
||||
const params = new URLSearchParams({ planId: intent.planId });
|
||||
|
||||
if (intent.recipientCharacterId) {
|
||||
const character = getCharacterById(intent.recipientCharacterId);
|
||||
const routes = getCharacterRoutes(
|
||||
character?.slug ?? DEFAULT_CHARACTER_SLUG,
|
||||
);
|
||||
if (intent.chatActionId) params.set("chatActionId", intent.chatActionId);
|
||||
params.set("payChannel", "stripe");
|
||||
return `${routes.tip}?${params.toString()}`;
|
||||
}
|
||||
|
||||
params.set("autoRenew", intent.autoRenew ? "1" : "0");
|
||||
const characterSlug =
|
||||
getCharacterBySlug(sourceCharacterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||
params.set("character", characterSlug);
|
||||
if (intent.commercialOfferId) {
|
||||
params.set("commercialOfferId", intent.commercialOfferId);
|
||||
}
|
||||
if (intent.chatActionId) params.set("chatActionId", intent.chatActionId);
|
||||
params.set("payChannel", "stripe");
|
||||
return `${ROUTES.subscription}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function persistExternalEntryPayload({
|
||||
deviceId,
|
||||
asid,
|
||||
@@ -168,7 +138,6 @@ function resolveTarget(
|
||||
return ROUTES.privateZone;
|
||||
}
|
||||
if (target === "topup") return ROUTES.subscription;
|
||||
if (target === "checkout") return ROUTES.subscription;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getPaymentUrl,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getPaymentUrlHostname,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -44,58 +44,103 @@ describe("payment launch helpers", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("enables saved cards only with an explicit flag and a Customer Session secret", () => {
|
||||
expect(
|
||||
getStripeCustomerSessionClientSecret({
|
||||
provider: "stripe",
|
||||
customerSessionClientSecret: "cuss_123_secret_456",
|
||||
savedPaymentMethodsEnabled: true,
|
||||
}),
|
||||
).toBe("cuss_123_secret_456");
|
||||
expect(
|
||||
getStripeCustomerSessionClientSecret({
|
||||
customerSessionClientSecret: "cuss_123_secret_456",
|
||||
savedPaymentMethodsEnabled: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStripeCustomerSessionClientSecret({
|
||||
customerSessionClientSecret: "invalid-customer-session",
|
||||
savedPaymentMethodsEnabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
||||
it("prioritizes a hosted GCash URL for Philippine checkout", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "URL",
|
||||
payData: "https://pay.example/qris",
|
||||
}),
|
||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
|
||||
payData: "https://pay.example/gcash",
|
||||
}, { countryCode: "PH", currency: "PHP" }),
|
||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
|
||||
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
}),
|
||||
payData: "000201010212ph-qr-payload",
|
||||
cashierUrl: "https://pay.example/gcash-hosted",
|
||||
}, { countryCode: "PH" }),
|
||||
).toEqual({
|
||||
kind: "url",
|
||||
paymentUrl: "https://pay.example/gcash-hosted",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a usable QR Ph fallback without calling it QRIS", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget(
|
||||
{
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "000201010212ph-qr-payload",
|
||||
},
|
||||
{ currency: "PHP" },
|
||||
),
|
||||
).toEqual({
|
||||
kind: "qr",
|
||||
experience: "gcashQrPh",
|
||||
qrData: "000201010212ph-qr-payload",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveEzpayLaunchTarget(
|
||||
{
|
||||
provider: "ezpay",
|
||||
payData: "000201010212ph-qr-payload",
|
||||
},
|
||||
{ countryCode: "PH" },
|
||||
),
|
||||
).toEqual({
|
||||
kind: "qr",
|
||||
experience: "gcashQrPh",
|
||||
qrData: "000201010212ph-qr-payload",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Indonesian QRIS ahead of an optional hosted URL", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget(
|
||||
{
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
cashierUrl: "https://pay.example/indonesia",
|
||||
},
|
||||
{ countryCode: "ID", currency: "IDR" },
|
||||
),
|
||||
).toEqual({
|
||||
kind: "qr",
|
||||
experience: "qris",
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a regional error only when both URL and QR data are absent", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "",
|
||||
}),
|
||||
resolveEzpayLaunchTarget(
|
||||
{
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "",
|
||||
},
|
||||
{ countryCode: "PH" },
|
||||
),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
errorMessage:
|
||||
"GCash / QR Ph payment data is missing. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps diagnostics to the payment URL hostname", () => {
|
||||
expect(
|
||||
getPaymentUrlHostname(
|
||||
"https://pay.example/gcash?token=must-not-appear-in-logs",
|
||||
),
|
||||
).toBe("pay.example");
|
||||
expect(getPaymentUrlHostname("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export const AUTOMATIC_RENEWAL_AGREEMENT_VERSION = "2026-07-29";
|
||||
|
||||
const STORAGE_PREFIX = "cozsweet.automatic-renewal-acknowledged";
|
||||
|
||||
function storageKey(subjectId: string | null | undefined): string | null {
|
||||
const normalized = subjectId?.trim();
|
||||
if (!normalized) return null;
|
||||
return `${STORAGE_PREFIX}:${AUTOMATIC_RENEWAL_AGREEMENT_VERSION}:${encodeURIComponent(normalized)}`;
|
||||
}
|
||||
|
||||
export function hasAutomaticRenewalAcknowledgement(
|
||||
subjectId: string | null | undefined,
|
||||
): boolean {
|
||||
const key = storageKey(subjectId);
|
||||
if (!key || typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(key) === "confirmed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberAutomaticRenewalAcknowledgement(
|
||||
subjectId: string | null | undefined,
|
||||
): void {
|
||||
const key = storageKey(subjectId);
|
||||
if (!key || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, "confirmed");
|
||||
} catch {
|
||||
// Storage can be unavailable in privacy-restricted in-app browsers.
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,20 @@ const log = new Logger("LibPaymentPaymentLaunch");
|
||||
|
||||
export type EzpayLaunchTarget =
|
||||
| { kind: "url"; paymentUrl: string }
|
||||
| { kind: "qr"; qrData: string }
|
||||
| {
|
||||
kind: "qr";
|
||||
experience: EzpayQrExperience;
|
||||
qrData: string;
|
||||
}
|
||||
| { kind: "error"; errorMessage: string };
|
||||
|
||||
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
|
||||
|
||||
export interface ResolveEzpayLaunchContext {
|
||||
countryCode?: string | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
function getNonEmptyString(
|
||||
payParams: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
@@ -41,6 +52,12 @@ function getHttpPaymentUrl(value: string | null): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPaymentUrlHostname(value: string | null): string | null {
|
||||
const paymentUrl = getHttpPaymentUrl(value);
|
||||
if (!paymentUrl) return null;
|
||||
return new URL(paymentUrl).hostname;
|
||||
}
|
||||
|
||||
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||
const keys = [
|
||||
"cashierUrl",
|
||||
@@ -71,6 +88,7 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
||||
|
||||
export function resolveEzpayLaunchTarget(
|
||||
payParams: Record<string, unknown>,
|
||||
context: ResolveEzpayLaunchContext = {},
|
||||
): EzpayLaunchTarget {
|
||||
const rawChannelType = getNonEmptyString(
|
||||
payParams,
|
||||
@@ -79,13 +97,33 @@ export function resolveEzpayLaunchTarget(
|
||||
);
|
||||
const channelType = rawChannelType?.toUpperCase() ?? null;
|
||||
const payData = getNonEmptyString(payParams, "payData", "pay_data");
|
||||
const payDataUrl = getHttpPaymentUrl(payData);
|
||||
const namedPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
const paymentUrl = payDataUrl ?? namedPaymentUrl;
|
||||
const qrData = payData && !payDataUrl ? payData : null;
|
||||
const region = resolveEzpayRegion(payParams, context);
|
||||
|
||||
if (
|
||||
region === "ph" &&
|
||||
paymentUrl &&
|
||||
(channelType === "QR" || channelType === "URL" || channelType === null)
|
||||
) {
|
||||
return { kind: "url", paymentUrl };
|
||||
}
|
||||
|
||||
if (channelType === "QR") {
|
||||
return payData
|
||||
? { kind: "qr", qrData: payData }
|
||||
if (qrData) {
|
||||
return {
|
||||
kind: "qr",
|
||||
experience: resolveEzpayQrExperience(region),
|
||||
qrData,
|
||||
};
|
||||
}
|
||||
return paymentUrl
|
||||
? { kind: "url", paymentUrl }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
errorMessage: regionalQrMissingMessage(region),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,8 +135,6 @@ export function resolveEzpayLaunchTarget(
|
||||
}
|
||||
|
||||
if (channelType === "URL") {
|
||||
const paymentUrl =
|
||||
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return paymentUrl
|
||||
? { kind: "url", paymentUrl }
|
||||
: {
|
||||
@@ -114,14 +150,66 @@ export function resolveEzpayLaunchTarget(
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return legacyPaymentUrl
|
||||
? { kind: "url", paymentUrl: legacyPaymentUrl }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage:
|
||||
"Ezpay payment parameters did not include a supported URL or QRIS code.",
|
||||
};
|
||||
if (paymentUrl) return { kind: "url", paymentUrl };
|
||||
if (qrData && region !== null) {
|
||||
return {
|
||||
kind: "qr",
|
||||
experience: resolveEzpayQrExperience(region),
|
||||
qrData,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "error",
|
||||
errorMessage:
|
||||
"Ezpay payment parameters did not include a supported URL or payment QR code.",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEzpayRegion(
|
||||
payParams: Record<string, unknown>,
|
||||
context: ResolveEzpayLaunchContext,
|
||||
): "ph" | "id" | null {
|
||||
const currency = (
|
||||
context.currency ?? getNonEmptyString(payParams, "currency")
|
||||
)
|
||||
?.trim()
|
||||
.toUpperCase();
|
||||
if (currency === "PHP") return "ph";
|
||||
if (currency === "IDR") return "id";
|
||||
|
||||
const countryCode = (
|
||||
context.countryCode ??
|
||||
getNonEmptyString(
|
||||
payParams,
|
||||
"purchaseCountryCode",
|
||||
"purchase_country_code",
|
||||
"countryCode",
|
||||
"country_code",
|
||||
)
|
||||
)
|
||||
?.trim()
|
||||
.toUpperCase();
|
||||
if (countryCode === "PH") return "ph";
|
||||
if (countryCode === "ID") return "id";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEzpayQrExperience(
|
||||
region: "ph" | "id" | null,
|
||||
): EzpayQrExperience {
|
||||
if (region === "ph") return "gcashQrPh";
|
||||
if (region === "id") return "qris";
|
||||
return "paymentQr";
|
||||
}
|
||||
|
||||
function regionalQrMissingMessage(region: "ph" | "id" | null): string {
|
||||
if (region === "ph") {
|
||||
return "GCash / QR Ph payment data is missing. Please try again.";
|
||||
}
|
||||
if (region === "id") {
|
||||
return "QRIS payment data is missing. Please try again.";
|
||||
}
|
||||
return "Payment QR data is missing. Please try again.";
|
||||
}
|
||||
|
||||
export function getStripeClientSecret(
|
||||
@@ -143,27 +231,6 @@ export function getStripeClientSecret(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getStripeCustomerSessionClientSecret(
|
||||
payParams: Record<string, unknown>,
|
||||
): string | null {
|
||||
const provider = payParams.provider;
|
||||
const isStripeProvider =
|
||||
typeof provider !== "string" || provider.toLowerCase() === "stripe";
|
||||
const enabled = payParams.savedPaymentMethodsEnabled === true;
|
||||
const clientSecret = payParams.customerSessionClientSecret;
|
||||
|
||||
if (
|
||||
isStripeProvider &&
|
||||
enabled &&
|
||||
typeof clientSecret === "string" &&
|
||||
/^cuss_[^\s]+_secret_[^\s]+$/i.test(clientSecret)
|
||||
) {
|
||||
return clientSecret;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LaunchEzpayRedirectInput {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
@@ -187,18 +254,19 @@ export async function launchEzpayRedirect({
|
||||
onOpened,
|
||||
onFailed,
|
||||
}: LaunchEzpayRedirectInput): Promise<void> {
|
||||
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
|
||||
log.debug("[payment-launch] launchEzpayRedirect START", {
|
||||
hasOrderId: Boolean(orderId),
|
||||
orderId,
|
||||
subscriptionType,
|
||||
paymentUrl,
|
||||
paymentUrlHost,
|
||||
});
|
||||
|
||||
if (!orderId) {
|
||||
const errorMessage = "Missing order id before opening Ezpay.";
|
||||
log.error("[payment-launch] pending ezpay order save skipped", {
|
||||
subscriptionType,
|
||||
paymentUrl,
|
||||
paymentUrlHost,
|
||||
errorMessage,
|
||||
});
|
||||
onFailed(errorMessage);
|
||||
|
||||
@@ -22,15 +22,6 @@ export function parsePaymentPayChannel(
|
||||
return channel === "ezpay" || channel === "stripe" ? channel : null;
|
||||
}
|
||||
|
||||
export function parsePaymentAutoRenew(
|
||||
value: PaymentSearchParamValue,
|
||||
): boolean | null {
|
||||
const autoRenew = getFirstPaymentSearchParam(value);
|
||||
if (autoRenew === "1" || autoRenew === "true") return true;
|
||||
if (autoRenew === "0" || autoRenew === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSubscriptionReturnTo(
|
||||
value: PaymentSearchParamValue,
|
||||
): AppSubscriptionReturnTo {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import {
|
||||
createTestPaymentMachine,
|
||||
giftCatalog,
|
||||
lifetimePlan,
|
||||
monthlyPlan,
|
||||
quarterlyPlan,
|
||||
@@ -50,7 +51,7 @@ describe("payment catalog flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("restores a valid tip product across categories from checkout handoff", async () => {
|
||||
it("restores a valid first-category product and rejects another category", async () => {
|
||||
const actor = createActor(createTestPaymentMachine()).start();
|
||||
actor.send({
|
||||
type: "PaymentInit",
|
||||
@@ -78,9 +79,8 @@ describe("payment catalog flow", () => {
|
||||
snapshot.context.requestedGiftPlanId === null,
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||
"tip_flowers_usd_12_99",
|
||||
giftCatalog.plans[0]?.planId,
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -50,23 +50,15 @@ export function hydrateGiftProductsState(
|
||||
const giftProducts = response.plans.filter(
|
||||
(product) => product.characterId === characterId,
|
||||
);
|
||||
const restoredProduct = restoredPlanId
|
||||
? giftProducts.find((product) => product.planId === restoredPlanId)
|
||||
: null;
|
||||
const selectedGiftCategory =
|
||||
restoredProduct?.category ??
|
||||
(restoredCategory &&
|
||||
giftCategories.some((category) => category.category === restoredCategory)
|
||||
? restoredCategory
|
||||
: (giftCategories[0]?.category ?? null));
|
||||
const selectedGiftCategory = giftCategories[0]?.category ?? null;
|
||||
const visibleProducts = selectedGiftCategory
|
||||
? giftProducts.filter(
|
||||
(product) => product.category === selectedGiftCategory,
|
||||
)
|
||||
: [];
|
||||
const canRestoreSelection = visibleProducts.some(
|
||||
(product) => product.planId === restoredPlanId,
|
||||
);
|
||||
const canRestoreSelection =
|
||||
restoredCategory === selectedGiftCategory &&
|
||||
visibleProducts.some((product) => product.planId === restoredPlanId);
|
||||
const selectedPlanId = canRestoreSelection
|
||||
? (restoredPlanId ?? "")
|
||||
: (visibleProducts[0]?.planId ?? "");
|
||||
|
||||
@@ -38,18 +38,14 @@ function initializeCatalogState(
|
||||
giftCharacterId !== context.giftCharacterId ||
|
||||
supportCharacterId !== context.supportCharacterId;
|
||||
const selectionChanged =
|
||||
planCatalog === "tip"
|
||||
? (event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null)
|
||||
: (event.planId ?? null) !== (context.selectedPlanId || null);
|
||||
planCatalog === "tip" &&
|
||||
((event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null));
|
||||
const commercialOfferId =
|
||||
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
|
||||
const offerChanged = commercialOfferId !== context.commercialOfferId;
|
||||
const chatActionId = event.chatActionId ?? null;
|
||||
const chatActionChanged = chatActionId !== context.chatActionId;
|
||||
const initialAutoRenew =
|
||||
planCatalog === "tip" ? false : (event.autoRenew ?? true);
|
||||
const autoRenewChanged = initialAutoRenew !== context.autoRenew;
|
||||
|
||||
return {
|
||||
planCatalog,
|
||||
@@ -62,12 +58,7 @@ function initializeCatalogState(
|
||||
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||
requestedGiftPlanId:
|
||||
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||
...(catalogChanged ||
|
||||
characterChanged ||
|
||||
selectionChanged ||
|
||||
offerChanged ||
|
||||
chatActionChanged ||
|
||||
autoRenewChanged
|
||||
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
|
||||
? {
|
||||
...resetOrderState(),
|
||||
plans: [],
|
||||
@@ -78,7 +69,7 @@ function initializeCatalogState(
|
||||
planCatalog === "default" ? (event.planId ?? "") : "",
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
autoRenew: initialAutoRenew,
|
||||
autoRenew: planCatalog !== "tip",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -20,8 +20,6 @@ export interface PaymentContextState {
|
||||
selectedGiftCategory: string | null;
|
||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||
commercialOffer: MachineContext["commercialOffer"];
|
||||
commercialOfferId: string | null;
|
||||
chatActionId: string | null;
|
||||
selectedPlanId: string;
|
||||
payChannel: MachineContext["payChannel"];
|
||||
autoRenew: boolean;
|
||||
@@ -78,8 +76,6 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
||||
selectedGiftCategory: state.context.selectedGiftCategory,
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
commercialOffer: state.context.commercialOffer,
|
||||
commercialOfferId: state.context.commercialOfferId,
|
||||
chatActionId: state.context.chatActionId,
|
||||
selectedPlanId: state.context.selectedPlanId,
|
||||
payChannel: state.context.payChannel,
|
||||
autoRenew: state.context.autoRenew,
|
||||
|
||||
@@ -10,7 +10,6 @@ export type PaymentEvent =
|
||||
characterId?: string;
|
||||
category?: string | null;
|
||||
planId?: string | null;
|
||||
autoRenew?: boolean;
|
||||
commercialOfferId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Logger } from "../logger";
|
||||
|
||||
describe("Logger Stripe secret redaction", () => {
|
||||
it("redacts nested Stripe secret fields and values before formatting", () => {
|
||||
const output = Logger.formatValue({
|
||||
payParams: {
|
||||
clientSecret: "pi_123_secret_card",
|
||||
nested: {
|
||||
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||
handoffToken: "one-time-checkout-token",
|
||||
safe: "order-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(output).toContain("order-123");
|
||||
expect(output).not.toContain("secret_card");
|
||||
expect(output).not.toContain("secret_saved");
|
||||
expect(output).not.toContain("one-time-checkout-token");
|
||||
expect(output.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("redacts a secret embedded in a serialized response string", () => {
|
||||
const output = Logger.formatValue(
|
||||
JSON.stringify({ customerSessionClientSecret: "cuss_1_secret_value" }),
|
||||
);
|
||||
|
||||
expect(output).not.toContain("secret_value");
|
||||
expect(output).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
+16
-49
@@ -107,13 +107,9 @@ export class Logger {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
try {
|
||||
return JSON.stringify(
|
||||
Logger.toSerializableLogValue(JSON.parse(trimmed)),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
} catch {
|
||||
return Logger.redactSensitiveString(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,31 +149,28 @@ export class Logger {
|
||||
}
|
||||
|
||||
private write(level: LogLevel, args: LogArgs): void {
|
||||
const safeArgs = args.map((value) =>
|
||||
Logger.toSerializableLogValue(value),
|
||||
);
|
||||
Logger.reportImportantProductionLog(level, this.component, safeArgs);
|
||||
Logger.reportImportantProductionLog(level, this.component, args);
|
||||
|
||||
if (Logger.shouldUseBrowserConsole()) {
|
||||
Logger.writeBrowserConsole(level, this.component, safeArgs);
|
||||
Logger.writeBrowserConsole(level, this.component, args);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
this.logger.debug(...Logger.normalizeLogArgs(safeArgs));
|
||||
this.logger.debug(...Logger.normalizeLogArgs(args));
|
||||
return;
|
||||
case "info":
|
||||
this.logger.info(...Logger.normalizeLogArgs(safeArgs));
|
||||
this.logger.info(...Logger.normalizeLogArgs(args));
|
||||
return;
|
||||
case "warn":
|
||||
this.logger.warn(...Logger.normalizeLogArgs(safeArgs));
|
||||
this.logger.warn(...Logger.normalizeLogArgs(args));
|
||||
return;
|
||||
case "error":
|
||||
this.logger.error(...Logger.normalizeLogArgs(safeArgs));
|
||||
this.logger.error(...Logger.normalizeLogArgs(args));
|
||||
return;
|
||||
case "fatal":
|
||||
this.logger.fatal(...Logger.normalizeLogArgs(safeArgs));
|
||||
this.logger.fatal(...Logger.normalizeLogArgs(args));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -273,10 +266,6 @@ export class Logger {
|
||||
return value.map((item) => Logger.toSerializableLogValue(item, seen));
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return Logger.redactSensitiveString(value);
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
@@ -292,39 +281,19 @@ export class Logger {
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
output[key] = Logger.isSensitiveLogKey(key)
|
||||
? "[REDACTED]"
|
||||
: Logger.toSerializableLogValue(item, seen);
|
||||
output[key] = Logger.toSerializableLogValue(item, seen);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static isSensitiveLogKey(key: string): boolean {
|
||||
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||
return (
|
||||
normalized === "clientsecret" ||
|
||||
normalized === "customersessionclientsecret" ||
|
||||
normalized === "handofftoken" ||
|
||||
normalized === "accesstoken" ||
|
||||
normalized === "refreshtoken" ||
|
||||
normalized === "authorization"
|
||||
);
|
||||
}
|
||||
|
||||
private static redactSensitiveString(value: string): string {
|
||||
return /_secret_/i.test(value) ? "[REDACTED]" : value;
|
||||
}
|
||||
|
||||
private static serializeError(
|
||||
error: Error,
|
||||
seen: WeakSet<object>,
|
||||
): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {
|
||||
name: error.name,
|
||||
message: Logger.redactSensitiveString(error.message),
|
||||
stack: error.stack
|
||||
? Logger.redactSensitiveString(error.stack)
|
||||
: undefined,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
|
||||
if (seen.has(error)) {
|
||||
@@ -334,12 +303,10 @@ export class Logger {
|
||||
|
||||
for (const key of Object.getOwnPropertyNames(error)) {
|
||||
if (key in output) continue;
|
||||
output[key] = Logger.isSensitiveLogKey(key)
|
||||
? "[REDACTED]"
|
||||
: Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
output[key] = Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user