Compare commits
33 Commits
pre
..
9121b50e02
| Author | SHA1 | Date | |
|---|---|---|---|
| 9121b50e02 | |||
| e43912eebf | |||
| 38ae06fe04 | |||
| 995fdbda57 | |||
| ed822c159f | |||
| d5b7a1f36c | |||
| 9f829bbc0e | |||
| d01441b8c7 | |||
| ddacd03601 | |||
| 1b121a8ef8 | |||
| b7221f2f70 | |||
| 30f88d3a20 | |||
| 7b09a7f850 | |||
| 38a4645b9c | |||
| 76bffc1a0d | |||
| 19b8fc51d6 | |||
| 9fbf180df6 | |||
| 6721b6eb43 | |||
| 64ba720121 | |||
| b04ef58855 | |||
| 606e6d60ff | |||
| c659c5fc3f | |||
| 537a0a2c36 | |||
| b1f52c68e8 | |||
| 3c7b0c30e0 | |||
| 469512df18 | |||
| 318e4991be | |||
| 2d432e5367 | |||
| d6f104ee3f | |||
| 9ca56c2a04 | |||
| 4639acf232 | |||
| bdd53a6ea1 | |||
| fb9e30cfd1 |
@@ -1,138 +0,0 @@
|
||||
name: Deploy frontend to domestic test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build and deploy isolated domestic test
|
||||
runs-on: gitea-label
|
||||
container:
|
||||
image: cozsweet-act-runner-node24-docker:latest
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
SENTRY_UPLOAD_SOURCEMAPS: "0"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate domestic SSH configuration
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.DOMESTIC_TEST_SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.DOMESTIC_TEST_SSH_PORT }}
|
||||
SSH_PRIVATE_KEY_B64: ${{ secrets.DOMESTIC_TEST_SSH_PRIVATE_KEY_B64 }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.DOMESTIC_TEST_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "$SSH_HOST" ] || [ -z "$SSH_PRIVATE_KEY_B64" ] || [ -z "$SSH_KNOWN_HOSTS" ]; then
|
||||
echo "Missing domestic test SSH secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$HOME/.ssh"
|
||||
chmod 700 "$HOME/.ssh"
|
||||
printf '%s' "$SSH_PRIVATE_KEY_B64" | base64 -d > "$HOME/.ssh/domestic_test_deploy_key"
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
chmod 600 "$HOME/.ssh/domestic_test_deploy_key" "$HOME/.ssh/known_hosts"
|
||||
|
||||
- name: Prepare domestic test environment
|
||||
env:
|
||||
TEST_ENV_FILE_CONTENT: ${{ secrets.TEST_ENV_FILE }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "$TEST_ENV_FILE_CONTENT" ]; then
|
||||
echo "Missing existing secret: TEST_ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
umask 077
|
||||
printf '%s\n' "$TEST_ENV_FILE_CONTENT" > .env.domestic-test
|
||||
|
||||
replace_env() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { replaced = 0 }
|
||||
$0 ~ "^" key "=" { print key "=" value; replaced = 1; next }
|
||||
{ print }
|
||||
END { if (!replaced) print key "=" value }
|
||||
' .env.domestic-test > .env.domestic-test.next
|
||||
mv .env.domestic-test.next .env.domestic-test
|
||||
}
|
||||
|
||||
replace_env NEXT_PUBLIC_APP_ENV test
|
||||
replace_env NEXT_PUBLIC_API_BASE_URL https://testapi.banlv-ai.com
|
||||
replace_env NEXT_PUBLIC_WS_BASE_URL wss://testapi.banlv-ai.com/ws
|
||||
replace_env NEXTAUTH_URL http://127.0.0.1:9135
|
||||
replace_env NEXTAUTH_URL_INTERNAL http://127.0.0.1:3000
|
||||
|
||||
grep -Eq '^AUTH_SECRET=.+$' .env.domestic-test
|
||||
grep -Eq '^NEXT_PUBLIC_API_BASE_URL=https://testapi\.banlv-ai\.com/?$' .env.domestic-test
|
||||
grep -Eq '^NEXT_PUBLIC_WS_BASE_URL=wss://testapi\.banlv-ai\.com/ws/?$' .env.domestic-test
|
||||
|
||||
- name: Prepare image metadata
|
||||
run: |
|
||||
set -eu
|
||||
SHORT_SHA="$(git rev-parse --short HEAD)"
|
||||
IMAGE="cozsweet-frontend-domestic-test:test-$SHORT_SHA"
|
||||
ARCHIVE="cozsweet-frontend-domestic-test-$SHORT_SHA.tar.gz"
|
||||
{
|
||||
echo "SHORT_SHA=$SHORT_SHA"
|
||||
echo "IMAGE=$IMAGE"
|
||||
echo "ARCHIVE=$ARCHIVE"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run frontend quality checks
|
||||
run: |
|
||||
set -eu
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test:contracts
|
||||
|
||||
- name: Build and archive runtime image
|
||||
run: |
|
||||
set -eu
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--secret id=next_env,src=.env.domestic-test \
|
||||
--build-arg NEXT_ENV_FILE=.env.domestic-test \
|
||||
-t "$IMAGE" \
|
||||
.
|
||||
docker save "$IMAGE" | gzip -1 > "$ARCHIVE"
|
||||
test -s "$ARCHIVE"
|
||||
|
||||
- name: Deploy isolated domestic test container
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.DOMESTIC_TEST_SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.DOMESTIC_TEST_SSH_PORT }}
|
||||
run: |
|
||||
set -eu
|
||||
DEPLOY_PORT="${SSH_PORT:-22}"
|
||||
KEY_FILE="$HOME/.ssh/domestic_test_deploy_key"
|
||||
SSH_TARGET="lzf@$SSH_HOST"
|
||||
RUN_TOKEN="${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test"
|
||||
REMOTE_TMP="$DEPLOY_ROOT/tmp/actions-$RUN_TOKEN"
|
||||
REMOTE_ARCHIVE="$REMOTE_TMP/$ARCHIVE"
|
||||
REMOTE_ENV_FILE="$DEPLOY_ROOT/shared/test.env"
|
||||
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
|
||||
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
|
||||
|
||||
cleanup_remote() {
|
||||
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||
"rm -f '$REMOTE_ARCHIVE' && rmdir '$REMOTE_TMP' 2>/dev/null || true"
|
||||
}
|
||||
trap cleanup_remote EXIT
|
||||
|
||||
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||
"mkdir -p '$DEPLOY_ROOT/current' '$DEPLOY_ROOT/releases/docker' '$DEPLOY_ROOT/shared' '$REMOTE_TMP' && chmod 700 '$DEPLOY_ROOT/shared'"
|
||||
scp $SCP_OPTS scripts/server/deploy-domestic-test-archive.sh \
|
||||
"$SSH_TARGET:$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh"
|
||||
scp $SCP_OPTS .env.domestic-test "$SSH_TARGET:$REMOTE_ENV_FILE"
|
||||
scp $SCP_OPTS "$ARCHIVE" "$SSH_TARGET:$REMOTE_ARCHIVE"
|
||||
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||
"chmod 600 '$REMOTE_ENV_FILE' && chmod 700 '$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh' && '$DEPLOY_ROOT/current/deploy-domestic-test-archive.sh' '$REMOTE_ARCHIVE' '$IMAGE' '$REMOTE_ENV_FILE' 9135"
|
||||
|
||||
- name: Cleanup runner artifacts
|
||||
if: always()
|
||||
run: |
|
||||
rm -f .env.domestic-test .env.domestic-test.next "${ARCHIVE:-}" "$HOME/.ssh/domestic_test_deploy_key"
|
||||
@@ -1,11 +1,11 @@
|
||||
# 外部入口接入说明
|
||||
|
||||
Facebook 等外部应用统一通过 `/external-entry` 进入本应用。入口先保存外部身份和短期业务意图,再跳转到对应角色页面并清理地址栏中的入口查询参数。
|
||||
外部应用通过 `/external-entry` 进入本应用。
|
||||
|
||||
| 环境 | APP HOST |
|
||||
| --- | --- |
|
||||
| 预发环境 `pre` | `frontend-test.banlv-ai.com` |
|
||||
| 正式环境 `production` | `cozsweet.com` |
|
||||
| 测试环境 | `frontend-test.banlv-ai.com` |
|
||||
| 正式环境 | `cozsweet.com` |
|
||||
|
||||
入口格式:
|
||||
|
||||
@@ -13,78 +13,85 @@ Facebook 等外部应用统一通过 `/external-entry` 进入本应用。入口
|
||||
https://<APP_HOST>/external-entry?<参数>
|
||||
```
|
||||
|
||||
## 当前 Facebook 业务入口
|
||||
## 可选参数
|
||||
|
||||
当前正式投放固定为 4 类入口乘以 3 个角色,共 12 条链接。Facebook 业务名称与应用页面的对应关系如下:
|
||||
|
||||
| Facebook 入口 | 参数组合 | 落地行为 |
|
||||
| --- | --- | --- |
|
||||
| 私密空间 | `target=chat` | 进入普通聊天页,不插入任何锁定内容。 |
|
||||
| 语音 | `target=chat&mode=promotion&promotion_type=voice` | 进入聊天页,并插入一条待解锁语音。 |
|
||||
| 图片包 | `target=private-zone` | 进入角色的 `private-zone` 图片空间。 |
|
||||
| 帮忙买咖啡 | `target=tip` | 进入角色的咖啡打赏页。 |
|
||||
|
||||
每条正式链接还必须显式传入:
|
||||
|
||||
- `character=elio`、`character=maya` 或 `character=nayeli`;
|
||||
- `psid=<PSID>`,其中 `<PSID>` 必须由 Facebook 发送端替换为当前用户经过 URL 编码的 Page-scoped User ID。
|
||||
|
||||
完整的 12 条正式发送模板见 [Facebook 12 条外部入口链接清单](./links.md)。
|
||||
|
||||
## 参数说明
|
||||
所有参数均为可选参数。
|
||||
|
||||
| 参数 | 可选值或格式 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `target` | `chat`、`tip`、`private-zone` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
||||
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。正式投放链接必须显式传入。 |
|
||||
| `psid` | string | Facebook Page-scoped User ID。Facebook 正式投放链接必须动态传入。 |
|
||||
| `target` | `chat`、`tip`、`private-zone` | 指定进入的页面;不传或值无效时进入聊天页。历史值 `private-room` 兼容进入 `private-zone`,新链接不得继续使用。 |
|
||||
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
||||
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
||||
| `mode` | `promotion` | 开启聊天促销模式。 |
|
||||
| `promotion_type` | `voice`、`image`、`private` | 指定促销消息类型,仅与 `mode=promotion` 一起使用。 |
|
||||
|
||||
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。当前 12 条正式投放链接只使用 `promotion_type=voice`;`image` 和 `private` 保留为兼容能力,不属于本次业务清单。
|
||||
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
|
||||
|
||||
## 四类发送模板
|
||||
|
||||
发送前必须把 `<CHARACTER>` 替换为角色值,把 `<PSID>` 替换为当前 Facebook 用户的真实 PSID;不得把占位符原样发送给用户。
|
||||
|
||||
私密空间(普通聊天):
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=chat&character=<CHARACTER>&psid=<PSID>
|
||||
```
|
||||
|
||||
语音:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=chat&character=<CHARACTER>&mode=promotion&promotion_type=voice&psid=<PSID>
|
||||
```
|
||||
|
||||
图片包:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=private-zone&character=<CHARACTER>&psid=<PSID>
|
||||
```
|
||||
|
||||
帮忙买咖啡:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=tip&character=<CHARACTER>&psid=<PSID>
|
||||
```
|
||||
标准私密空间参数是 `target=private-zone`。前端仅为已经发布的旧链接保留 `target=private-room` 兼容;`private_zone` 等其他拼写仍按无效目标处理并回退聊天页。
|
||||
|
||||
## PSID 与登录状态流程
|
||||
|
||||
`psid` 可以和任意有效入口参数组合使用。应用会先保存 PSID,再执行登录状态判断和 Facebook Identity 绑定。详细流程见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
||||
PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
||||
|
||||
所有参数值都必须进行 URL 编码。入口中禁止传递登录 Token、Page Access Token、App Secret 或其他秘密。
|
||||
## 使用示例
|
||||
|
||||
## 兼容说明
|
||||
普通聊天:
|
||||
|
||||
- 历史参数 `target=private-room` 继续兼容进入 `private-zone`,但不得用于新投放链接。
|
||||
- `private_zone` 等其他拼写仍按无效目标处理并回退聊天页。
|
||||
- `promotion_type=image` 和 `promotion_type=private` 继续由现有代码支持,但不计入当前 12 条 Facebook 业务链接。
|
||||
- 无效或缺失的 `character` 继续回退 Elio;正式投放模板始终显式传入角色,不能依赖回退。
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=chat
|
||||
```
|
||||
|
||||
## 调试与正式清单
|
||||
进入 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)
|
||||
- [Facebook 12 条外部入口链接清单](./links.md)
|
||||
- [测试环境和正式环境链接清单](./links.md)
|
||||
|
||||
@@ -1,52 +1,66 @@
|
||||
# Facebook 12 条外部入口链接清单
|
||||
# 外部入口链接清单
|
||||
|
||||
本清单是 Facebook 正式投放的唯一业务链接清单,共 4 类入口乘以 3 个角色,合计 12 条模板。
|
||||
这份清单可以独立发送和直接点击。所有新链接统一使用标准参数 `target=private-zone`;历史参数 `target=private-room` 仅用于兼容已经发出的旧链接。
|
||||
|
||||
## 发送规则
|
||||
## 使用规则
|
||||
|
||||
- 发送前必须把每条模板中的 `<PSID>` 替换为当前用户经过 URL 编码的 Facebook Page-scoped User ID;不得把占位符原样发送。
|
||||
- 每条链接都显式传入 `character`,不得依赖缺省角色回退。
|
||||
- Facebook 业务名称“私密空间”进入普通聊天页;业务名称“图片包”才进入应用的 `private-zone`。
|
||||
- 不要在入口中传递登录 Token、Page Access Token、App Secret 或其他秘密。
|
||||
- 以下模板包含占位符,替换真实 PSID 后才能直接发送或点击。
|
||||
- `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。
|
||||
|
||||
## 正式投放模板
|
||||
## 测试环境
|
||||
|
||||
| 编号 | 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` |
|
||||
| 入口 | 可点击链接 | 预期落地页面 |
|
||||
| --- | --- | --- |
|
||||
| 普通聊天(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` |
|
||||
|
||||
## 预发验证
|
||||
## 旧链接兼容验证
|
||||
|
||||
预发验证不维护第二套业务清单。将上述 12 条模板的主机统一替换为 `frontend-test.banlv-ai.com`,其余路径和参数保持不变:
|
||||
以下链接仅用于验证已经发出的 `private-room` 旧链接仍能正确进入私密空间。新投放链接不要再使用这些地址。
|
||||
|
||||
```text
|
||||
https://frontend-test.banlv-ai.com/external-entry?<与正式模板相同的查询参数>
|
||||
```
|
||||
| 旧入口 | 可点击链接 | 预期落地页面 |
|
||||
| --- | --- | --- |
|
||||
| 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` |
|
||||
|
||||
预发环境是 `pre`,不是正式环境。预发验证通过不代表已经更新 `cozsweet.com` 或获得生产发布批准。
|
||||
## 正式环境
|
||||
|
||||
正式环境仅列标准链接,不再传播 `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` |
|
||||
|
||||
## 验收判断
|
||||
|
||||
- 最终浏览器地址必须是表格中的预期页面,且不再保留 `/external-entry` 的查询参数。
|
||||
- `<PSID>` 替换后的真实值必须由应用保存。
|
||||
- “私密空间”只进入普通聊天,不得出现锁定语音、锁定图片或私密文本促销卡。
|
||||
- “语音”进入聊天后必须只注入一条锁定语音。
|
||||
- “图片包”和“帮忙买咖啡”必须分别进入对应角色的 `private-zone` 与 `tip`,不能串到其他角色。
|
||||
打开入口后,浏览器地址最终应与“预期落地页面”一致。普通聊天和私密空间必须分别落在 `/chat` 与 `/private-zone`,不能再进入同一个页面。
|
||||
|
||||
## 不属于当前 12 条清单的兼容能力
|
||||
|
||||
- 历史 `target=private-room` 继续兼容,但不得创建新的投放链接。
|
||||
- `promotion_type=image`、`promotion_type=private` 和独立 PSID 示例不属于当前正式业务清单。
|
||||
- 兼容能力的保留不改变本页“只有 12 条正式投放模板”的口径。
|
||||
如需在其他入口携带 PSID,在已有查询参数的链接末尾追加 `&psid=27511427698460020`。
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MockCoreApisOptions {
|
||||
paidImageInsufficientCreditsFlow?: boolean;
|
||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||
topUpHandoffFlow?: boolean;
|
||||
checkoutHandoffFlow?: boolean;
|
||||
}
|
||||
|
||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||
@@ -23,6 +24,7 @@ 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 };
|
||||
|
||||
@@ -30,6 +32,7 @@ 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,15 +1,24 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { apiEnvelope } from "../data/common";
|
||||
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||
import { checkoutHandoffResponse, 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,3 +63,15 @@ 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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||
import { clearBrowserState } from "@e2e/fixtures/test-helpers";
|
||||
|
||||
const testPsid = "e2e-facebook-psid-27511427698460020";
|
||||
|
||||
const characters = [
|
||||
{
|
||||
id: "elio",
|
||||
slug: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
shortName: "Elio",
|
||||
},
|
||||
{
|
||||
id: "maya-tan",
|
||||
slug: "maya",
|
||||
displayName: "Maya Tan",
|
||||
shortName: "Maya",
|
||||
},
|
||||
{
|
||||
id: "nayeli-cervantes",
|
||||
slug: "nayeli",
|
||||
displayName: "Nayeli Cervantes",
|
||||
shortName: "Nayeli",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Character = (typeof characters)[number];
|
||||
type EntryKind = "private-space" | "voice" | "image-pack" | "coffee";
|
||||
|
||||
interface FacebookEntryCase {
|
||||
character: Character;
|
||||
kind: EntryKind;
|
||||
target: "chat" | "private-zone" | "tip";
|
||||
expectedPath: string;
|
||||
promotionType?: "voice";
|
||||
}
|
||||
|
||||
const entryCases: FacebookEntryCase[] = characters.flatMap((character) => [
|
||||
{
|
||||
character,
|
||||
kind: "private-space",
|
||||
target: "chat",
|
||||
expectedPath: `/characters/${character.slug}/chat`,
|
||||
},
|
||||
{
|
||||
character,
|
||||
kind: "voice",
|
||||
target: "chat",
|
||||
expectedPath: `/characters/${character.slug}/chat`,
|
||||
promotionType: "voice",
|
||||
},
|
||||
{
|
||||
character,
|
||||
kind: "image-pack",
|
||||
target: "private-zone",
|
||||
expectedPath: `/characters/${character.slug}/private-zone`,
|
||||
},
|
||||
{
|
||||
character,
|
||||
kind: "coffee",
|
||||
target: "tip",
|
||||
expectedPath: `/characters/${character.slug}/tip`,
|
||||
},
|
||||
]);
|
||||
|
||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
await clearBrowserState(context, page, baseURL);
|
||||
await mockCoreApis(page);
|
||||
await registerEntryPageMocks(page);
|
||||
});
|
||||
|
||||
for (const entryCase of entryCases) {
|
||||
test(`${entryCase.character.displayName} opens Facebook ${entryCase.kind} entry`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(buildEntryUrl(entryCase));
|
||||
|
||||
await expect(page).toHaveURL(entryCase.expectedPath, { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => localStorage.getItem("cozsweet:psid")),
|
||||
)
|
||||
.toBe(testPsid);
|
||||
|
||||
if (entryCase.kind === "private-space") {
|
||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Unlock voice message" }),
|
||||
).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entryCase.kind === "voice") {
|
||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Unlock voice message" }),
|
||||
).toHaveCount(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entryCase.kind === "image-pack") {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Private albums" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(entryCase.character.displayName).last(),
|
||||
).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: `Buy ${entryCase.character.shortName} a coffee`,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
function buildEntryUrl(entryCase: FacebookEntryCase): string {
|
||||
const params = new URLSearchParams({
|
||||
target: entryCase.target,
|
||||
character: entryCase.character.slug,
|
||||
psid: testPsid,
|
||||
});
|
||||
if (entryCase.promotionType) {
|
||||
params.set("mode", "promotion");
|
||||
params.set("promotion_type", entryCase.promotionType);
|
||||
}
|
||||
return `/external-entry?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function registerEntryPageMocks(page: Page): Promise<void> {
|
||||
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
characterId,
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
creditBalance: 1000,
|
||||
currency: "credits",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
creditBalance: 1000,
|
||||
pendingImageCount: 0,
|
||||
hasIncompleteContent: false,
|
||||
items: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/payment/gift-products**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||
const planPrefix =
|
||||
characterId === "elio"
|
||||
? "tip"
|
||||
: `tip_${characterId.replaceAll("-", "_")}`;
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
characterId,
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: `${planPrefix}_coffee_usd_4_99`,
|
||||
planName: "Small Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
category: "coffee",
|
||||
characterId,
|
||||
description: "A character-scoped coffee gift",
|
||||
imageUrl: null,
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -26,7 +26,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
||||
);
|
||||
await expect(page).toHaveURL(defaultCharacterChatUrl);
|
||||
|
||||
const unlockButton = page.getByRole("button", {
|
||||
const promotedImageCard = page
|
||||
.getByRole("group", { name: "Locked private image" })
|
||||
.last();
|
||||
const unlockButton = promotedImageCard.getByRole("button", {
|
||||
name: "Unlock private image",
|
||||
});
|
||||
await expect(unlockButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 });
|
||||
|
||||
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&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||
);
|
||||
|
||||
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||
await expect(page).toHaveURL(
|
||||
/\/subscription\?planId=vip_monthly&autoRenew=1&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");
|
||||
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Automatic Renewal Confirmation" })
|
||||
.getByRole("button", { name: "Confirm" })
|
||||
.click();
|
||||
const externalButton = page.getByRole("button", {
|
||||
name: "Open in browser for more payment methods",
|
||||
});
|
||||
await expect(externalButton).toBeVisible();
|
||||
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);
|
||||
});
|
||||
@@ -85,14 +85,11 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
||||
});
|
||||
await page.route("**/api/payment/create-order", async (route) => {
|
||||
createOrderCount += 1;
|
||||
const body = route.request().postDataJSON() as {
|
||||
planId: string;
|
||||
payChannel: "ezpay" | "stripe";
|
||||
};
|
||||
const body = route.request().postDataJSON() as { planId: string };
|
||||
const plan =
|
||||
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
||||
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
||||
const orderId = `order_${body.payChannel}_${body.planId}`;
|
||||
const orderId = `order_qris_${body.planId}`;
|
||||
orderStatuses.set(orderId, "pending");
|
||||
orderPlans.set(orderId, {
|
||||
planId: body.planId,
|
||||
@@ -101,21 +98,15 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
orderId,
|
||||
payParams:
|
||||
body.payChannel === "stripe"
|
||||
? {
|
||||
provider: "stripe",
|
||||
clientSecret: "pi_e2e_secret_mock",
|
||||
}
|
||||
: {
|
||||
provider: "ezpay",
|
||||
countryCode: "ID",
|
||||
channelCode: "ID_QRIS_DYNAMIC_QR",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
firstChargeAmountCents: plan?.amountCents ?? 0,
|
||||
currency: "IDR",
|
||||
},
|
||||
payParams: {
|
||||
provider: "ezpay",
|
||||
countryCode: "ID",
|
||||
channelCode: "ID_QRIS_DYNAMIC_QR",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
firstChargeAmountCents: plan?.amountCents ?? 0,
|
||||
currency: "IDR",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -192,7 +183,7 @@ async function expectQrisOrder(
|
||||
await expect(
|
||||
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
||||
).toBeVisible();
|
||||
return `order_ezpay_${planId}`;
|
||||
return `order_qris_${planId}`;
|
||||
}
|
||||
|
||||
async function expectCheckoutButtonLayout(page: Page) {
|
||||
@@ -219,13 +210,11 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
||||
}) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=vip&character=elio");
|
||||
await page.goto("/subscription?type=vip");
|
||||
|
||||
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
|
||||
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Elio", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText("Choose who you want to support")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
||||
await expect(page.getByText("Supporting Elio")).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
@@ -266,8 +255,8 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
||||
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=topup&character=elio");
|
||||
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||
await page.goto("/subscription?type=topup");
|
||||
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
||||
await expectCheckoutButtonLayout(page);
|
||||
|
||||
const orderId = await expectQrisOrder(
|
||||
@@ -283,74 +272,6 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
|
||||
page,
|
||||
}) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=topup&character=elio");
|
||||
|
||||
await expectQrisOrder(
|
||||
page,
|
||||
/Pay and Top Up/i,
|
||||
"dol_1000",
|
||||
/Rp\s*88[.,]900/,
|
||||
);
|
||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Stripe" }).click();
|
||||
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
|
||||
expect(payment.getCreateOrderCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
|
||||
page,
|
||||
}) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=topup&character=elio");
|
||||
|
||||
await expectQrisOrder(
|
||||
page,
|
||||
/Pay and Top Up/i,
|
||||
"dol_1000",
|
||||
/Rp\s*88[.,]900/,
|
||||
);
|
||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
const stripeButton = page.getByRole("button", {
|
||||
name: "Stripe",
|
||||
exact: true,
|
||||
});
|
||||
await expect(stripeButton).toBeEnabled();
|
||||
await stripeButton.click();
|
||||
await expect(stripeButton).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Resume QRIS payment" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const stripeRequestPromise = page.waitForRequest(
|
||||
"**/api/payment/create-order",
|
||||
);
|
||||
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||
const stripeRequest = await stripeRequestPromise;
|
||||
expect(stripeRequest.postDataJSON()).toMatchObject({
|
||||
planId: "dol_1000",
|
||||
payChannel: "stripe",
|
||||
recipientCharacterId: "elio",
|
||||
});
|
||||
expect(payment.getCreateOrderCount()).toBe(2);
|
||||
});
|
||||
|
||||
test("payment issue submits Other to the feedback API without creating an order", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -402,21 +323,17 @@ 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();
|
||||
await expectQrisOrder(
|
||||
const orderId = await expectQrisOrder(
|
||||
page,
|
||||
/Order and Buy/i,
|
||||
"tip_coffee_usd_4_99",
|
||||
/Rp\s*89[.,]299/,
|
||||
);
|
||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
payment.markPaid(orderId);
|
||||
|
||||
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);
|
||||
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,153 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||
import { e2eEmailUser } from "@e2e/fixtures/data/user";
|
||||
import {
|
||||
clearBrowserState,
|
||||
seedEmailSession,
|
||||
} from "@e2e/fixtures/test-helpers";
|
||||
|
||||
const phpPlans = {
|
||||
plans: [
|
||||
{
|
||||
planId: "dol_1000",
|
||||
planName: "1,000 Credits",
|
||||
orderType: "dol",
|
||||
amountCents: 49_990,
|
||||
originalAmountCents: 74_990,
|
||||
dailyPriceCents: null,
|
||||
currency: "PHP",
|
||||
vipDays: null,
|
||||
dolAmount: 1_000,
|
||||
creditBalance: 1_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function registerPhilippinesPaymentMocks(
|
||||
page: Page,
|
||||
response: "hosted" | "qr",
|
||||
) {
|
||||
let createOrderCount = 0;
|
||||
|
||||
await page.route("**/api/user/profile", async (route) => {
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/payment/plans**", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(phpPlans) });
|
||||
});
|
||||
await page.route("**/api/payment/create-order", async (route) => {
|
||||
createOrderCount += 1;
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
orderId: `order_gcash_${response}`,
|
||||
payParams: {
|
||||
provider: "ezpay",
|
||||
countryCode: "PH",
|
||||
channelCode: "PH_QRPH_DYNAMIC",
|
||||
channelType: "QR",
|
||||
payData: "000201010212ph-qr-payload",
|
||||
...(response === "hosted"
|
||||
? { cashierUrl: "https://pay.example/gcash-hosted" }
|
||||
: {}),
|
||||
firstChargeAmountCents: 49_990,
|
||||
currency: "PHP",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/payment/order-status**", async (route) => {
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
orderId: `order_gcash_${response}`,
|
||||
status: "pending",
|
||||
orderType: "dol",
|
||||
planId: "dol_1000",
|
||||
creditsAdded: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getCreateOrderCount: () => createOrderCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function preparePhilippinesUser(page: Page) {
|
||||
await seedEmailSession(page);
|
||||
await page.evaluate(() => {
|
||||
const rawUser = localStorage.getItem("cozsweet:user");
|
||||
const user = rawUser ? JSON.parse(rawUser) : {};
|
||||
localStorage.setItem(
|
||||
"cozsweet:user",
|
||||
JSON.stringify({ ...user, countryCode: "PH" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
await clearBrowserState(context, page, baseURL);
|
||||
await mockCoreApis(page);
|
||||
});
|
||||
|
||||
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
|
||||
page,
|
||||
}) => {
|
||||
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
|
||||
await preparePhilippinesUser(page);
|
||||
await page.goto("/subscription?type=topup&character=elio");
|
||||
|
||||
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("alertdialog", { name: "Continue to payment?" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||
).toHaveCount(0);
|
||||
expect(payment.getCreateOrderCount()).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||
expect(payment.getCreateOrderCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const payment = await registerPhilippinesPaymentMocks(page, "qr");
|
||||
await preparePhilippinesUser(page);
|
||||
await page.goto("/subscription?type=topup&character=elio");
|
||||
|
||||
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||
const dialog = page.getByRole("dialog", {
|
||||
name: "Pay with GCash / QR Ph",
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(/₱\s*499\.90/);
|
||||
await expect(dialog).not.toContainText("QRIS");
|
||||
await expect(
|
||||
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Stripe" }).click();
|
||||
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
expect(payment.getCreateOrderCount()).toBe(1);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const workflowUrl = new URL(
|
||||
"../../../.gitea/workflows/domestic-test.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const deployScriptUrl = new URL(
|
||||
"../../server/deploy-domestic-test-archive.sh",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test("domestic frontend workflow is manual and registry-free", async () => {
|
||||
const workflow = await readFile(workflowUrl, "utf8");
|
||||
|
||||
assert.match(workflow, /workflow_dispatch:/);
|
||||
assert.doesNotMatch(workflow, /\bpush:/);
|
||||
assert.doesNotMatch(workflow, /REGISTRY_IMAGE/);
|
||||
assert.match(workflow, /docker save/);
|
||||
assert.match(workflow, /DOMESTIC_TEST_SSH_PRIVATE_KEY_B64/);
|
||||
assert.match(workflow, /base64 -d/);
|
||||
});
|
||||
|
||||
test("domestic frontend workflow pins the China test API and isolated port", async () => {
|
||||
const workflow = await readFile(workflowUrl, "utf8");
|
||||
const deployScript = await readFile(deployScriptUrl, "utf8");
|
||||
|
||||
assert.match(workflow, /https:\/\/testapi\.banlv-ai\.com/);
|
||||
assert.match(workflow, /wss:\/\/testapi\.banlv-ai\.com\/ws/);
|
||||
assert.match(workflow, /9135/);
|
||||
assert.match(deployScript, /127\.0\.0\.1:\$HOST_PORT/);
|
||||
assert.match(deployScript, /cozsweet-frontend-domestic-test/);
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
IMAGE="${2:-}"
|
||||
ENV_FILE="${3:-}"
|
||||
HOST_PORT="${4:-9135}"
|
||||
CONTAINER_NAME="cozsweet-frontend-domestic-test"
|
||||
INTERNAL_PORT="3000"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVICE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SHARED_ROOT="$SERVICE_ROOT/shared"
|
||||
RELEASE_ROOT="$SERVICE_ROOT/releases/docker"
|
||||
|
||||
if [ -z "$ARCHIVE" ] || [ -z "$IMAGE" ] || [ -z "$ENV_FILE" ]; then
|
||||
echo "Usage: $0 <image-archive> <image> <env-file> [host-port]" >&2
|
||||
exit 64
|
||||
fi
|
||||
if [ "$HOST_PORT" != "9135" ]; then
|
||||
echo "Domestic frontend test must use isolated host port 9135" >&2
|
||||
exit 64
|
||||
fi
|
||||
case "$ENV_FILE" in
|
||||
"$SHARED_ROOT"/*) ;;
|
||||
*)
|
||||
echo "Environment file must be stored under $SHARED_ROOT" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
if [ ! -s "$ARCHIVE" ] || [ ! -s "$ENV_FILE" ]; then
|
||||
echo "Image archive or environment file is missing" >&2
|
||||
exit 66
|
||||
fi
|
||||
|
||||
mkdir -p "$RELEASE_ROOT"
|
||||
chmod 700 "$SHARED_ROOT"
|
||||
gzip -dc "$ARCHIVE" | docker load >/dev/null
|
||||
docker image inspect "$IMAGE" >/dev/null
|
||||
|
||||
old_image_id=""
|
||||
old_image_ref=""
|
||||
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
|
||||
old_image_id="$(docker inspect -f '{{.Image}}' "$CONTAINER_NAME")"
|
||||
old_image_ref="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")"
|
||||
elif ss -ltnH "sport = :$HOST_PORT" 2>/dev/null | grep -q .; then
|
||||
echo "Host port $HOST_PORT is already used by another service" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
run_container() {
|
||||
local image_ref="$1"
|
||||
docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
-p "127.0.0.1:$HOST_PORT:$INTERNAL_PORT" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-e "PORT=$INTERNAL_PORT" \
|
||||
"$image_ref" >/dev/null
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local deadline=$((SECONDS + 180))
|
||||
while [ "$SECONDS" -le "$deadline" ]; do
|
||||
state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || true)"
|
||||
status="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$HOST_PORT/" 2>/dev/null || true)"
|
||||
if [[ "$status" =~ ^(200|204|301|302|303|307|308)$ ]] && { [ "$state" = "healthy" ] || [ "$state" = "running" ]; }; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
deployment_ok="1"
|
||||
if ! run_container "$IMAGE"; then
|
||||
deployment_ok="0"
|
||||
elif ! wait_healthy; then
|
||||
deployment_ok="0"
|
||||
fi
|
||||
|
||||
if [ "$deployment_ok" != "1" ]; then
|
||||
docker logs --tail 120 "$CONTAINER_NAME" >&2 || true
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
if [ -n "$old_image_id" ] && docker image inspect "$old_image_id" >/dev/null 2>&1; then
|
||||
echo "New container failed health checks; restoring previous image" >&2
|
||||
run_container "$old_image_id"
|
||||
wait_healthy || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
image_id="$(docker image inspect "$IMAGE" -f '{{.Id}}')"
|
||||
record="{\"createdAt\":\"$created_at\",\"environment\":\"domestic-test\",\"image\":\"$IMAGE\",\"imageId\":\"$image_id\",\"container\":\"$CONTAINER_NAME\",\"hostPort\":$HOST_PORT}"
|
||||
if [ -f "$RELEASE_ROOT/current.json" ]; then
|
||||
cp "$RELEASE_ROOT/current.json" "$RELEASE_ROOT/previous.json"
|
||||
fi
|
||||
printf '%s\n' "$record" > "$RELEASE_ROOT/current.json"
|
||||
printf '%s\n' "$record" >> "$RELEASE_ROOT/releases.jsonl"
|
||||
|
||||
echo "Domestic frontend test is healthy"
|
||||
echo "container=$CONTAINER_NAME"
|
||||
echo "host_port=$HOST_PORT"
|
||||
echo "image=$IMAGE"
|
||||
if [ -n "$old_image_ref" ]; then
|
||||
echo "previous_image=$old_image_ref"
|
||||
fi
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,17 +7,19 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||
const hiddenLaunch = {
|
||||
handleEzpayCancel: vi.fn(),
|
||||
handleEzpayConfirm: vi.fn(),
|
||||
handleRegionalQrClose: vi.fn(),
|
||||
handleQrisClose: vi.fn(),
|
||||
handleStripeClose: vi.fn(),
|
||||
handleStripeConfirmed: vi.fn(),
|
||||
isConfirmingEzpay: false,
|
||||
regionalQrErrorMessage: null,
|
||||
regionalQrPayment: null,
|
||||
regionalQrStatus: null,
|
||||
qrisErrorMessage: null,
|
||||
qrisPayment: null,
|
||||
qrisStatus: null,
|
||||
shouldShowEzpayConfirmDialog: false,
|
||||
shouldShowRegionalQrDialog: false,
|
||||
shouldShowQrisDialog: false,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
stripeCustomerSessionClientSecret: null,
|
||||
savedPaymentMethodsEnabled: false,
|
||||
};
|
||||
|
||||
describe("PaymentLaunchDialogs", () => {
|
||||
@@ -96,15 +98,14 @@ describe("PaymentLaunchDialogs", () => {
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
regionalQrPayment: {
|
||||
qrisPayment: {
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
experience: "qris",
|
||||
},
|
||||
regionalQrStatus: "pending",
|
||||
shouldShowRegionalQrDialog: true,
|
||||
qrisStatus: "pending",
|
||||
shouldShowQrisDialog: true,
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
@@ -123,44 +124,6 @@ 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(
|
||||
@@ -170,16 +133,15 @@ describe("PaymentLaunchDialogs", () => {
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
regionalQrErrorMessage: "Payment failed or was cancelled.",
|
||||
regionalQrPayment: {
|
||||
qrisErrorMessage: "Payment failed or was cancelled.",
|
||||
qrisPayment: {
|
||||
qrData: "",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
experience: "qris",
|
||||
},
|
||||
regionalQrStatus: "failed",
|
||||
shouldShowRegionalQrDialog: true,
|
||||
qrisStatus: "failed",
|
||||
shouldShowQrisDialog: true,
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -2,18 +2,57 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
|
||||
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||
const elementsSubmit = vi.fn(async () => ({}));
|
||||
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||
});
|
||||
|
||||
vi.mock("@stripe/stripe-js", () => ({
|
||||
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("@stripe/react-stripe-js", () => ({
|
||||
Elements: ({
|
||||
children,
|
||||
options,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
options: Record<string, unknown>;
|
||||
}) => {
|
||||
capturedElementsOptions = options;
|
||||
return <>{children}</>;
|
||||
},
|
||||
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||
capturedExpressProps = props;
|
||||
return <div data-testid="express-checkout" />;
|
||||
},
|
||||
PaymentElement: (props: Record<string, unknown>) => {
|
||||
capturedPaymentProps = props;
|
||||
return <div data-testid="payment-element" />;
|
||||
},
|
||||
useElements: () => ({ submit: elementsSubmit }),
|
||||
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||
}));
|
||||
|
||||
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||
|
||||
describe("Stripe payment portal states", () => {
|
||||
describe("StripePaymentDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedElementsOptions = null;
|
||||
capturedExpressProps = null;
|
||||
capturedPaymentProps = null;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
container.style.transform = "translateX(50%)";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
@@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => {
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
it("portals the unavailable state and keeps its explicit close action", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="client-secret"
|
||||
onClose={onClose}
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="cuss_123_secret_saved"
|
||||
savedPaymentMethodsEnabled
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
expect(capturedElementsOptions).toMatchObject({
|
||||
clientSecret: "pi_123_secret_payment",
|
||||
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||
});
|
||||
expect(capturedExpressProps?.options).toMatchObject({
|
||||
paymentMethodOrder: [
|
||||
"apple_pay",
|
||||
"google_pay",
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "always",
|
||||
googlePay: "always",
|
||||
link: "auto",
|
||||
paypal: "auto",
|
||||
amazonPay: "auto",
|
||||
klarna: "auto",
|
||||
},
|
||||
});
|
||||
expect(capturedPaymentProps?.options).toMatchObject({
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["card"],
|
||||
wallets: { applePay: "never", googlePay: "never", link: "never" },
|
||||
});
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("Pay by card");
|
||||
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||
expect(express).not.toBeNull();
|
||||
expect(card).not.toBeNull();
|
||||
expect(
|
||||
express!.compareDocumentPosition(card!) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("portals the lazy loading state", () => {
|
||||
act(() => root.render(<StripePaymentDialogLoading />));
|
||||
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()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
expect(capturedElementsOptions).not.toHaveProperty(
|
||||
"customerSessionClientSecret",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||
const onConfirmed = vi.fn();
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
onClose={vi.fn()}
|
||||
onConfirmed={onConfirmed}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const onConfirm = capturedExpressProps?.onConfirm as
|
||||
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||
| undefined;
|
||||
expect(onConfirm).toBeTypeOf("function");
|
||||
await act(async () => {
|
||||
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||
});
|
||||
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
elements: expect.any(Object),
|
||||
redirect: "if_required",
|
||||
}),
|
||||
);
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
|
||||
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||
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;
|
||||
}
|
||||
|
||||
export function ExternalBrowserCheckoutButton({
|
||||
checkoutIntent,
|
||||
disabled = false,
|
||||
}: 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;
|
||||
}
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(result.data.externalUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-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,17 +13,19 @@ type PaymentLaunchDialogFlow = Pick<
|
||||
PaymentLaunchFlow,
|
||||
| "handleEzpayCancel"
|
||||
| "handleEzpayConfirm"
|
||||
| "handleRegionalQrClose"
|
||||
| "handleQrisClose"
|
||||
| "handleStripeClose"
|
||||
| "handleStripeConfirmed"
|
||||
| "isConfirmingEzpay"
|
||||
| "regionalQrErrorMessage"
|
||||
| "regionalQrPayment"
|
||||
| "regionalQrStatus"
|
||||
| "qrisErrorMessage"
|
||||
| "qrisPayment"
|
||||
| "qrisStatus"
|
||||
| "shouldShowEzpayConfirmDialog"
|
||||
| "shouldShowRegionalQrDialog"
|
||||
| "shouldShowQrisDialog"
|
||||
| "shouldShowStripeDialog"
|
||||
| "stripeClientSecret"
|
||||
| "stripeCustomerSessionClientSecret"
|
||||
| "savedPaymentMethodsEnabled"
|
||||
>;
|
||||
|
||||
export interface PaymentLaunchDialogsProps {
|
||||
@@ -53,17 +55,21 @@ export function PaymentLaunchDialogs({
|
||||
onConfirm={launch.handleEzpayConfirm}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
|
||||
<RegionalQrPaymentDialog
|
||||
payment={launch.regionalQrPayment}
|
||||
status={launch.regionalQrStatus}
|
||||
errorMessage={launch.regionalQrErrorMessage}
|
||||
onClose={launch.handleRegionalQrClose}
|
||||
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
|
||||
<QrisPaymentDialog
|
||||
payment={launch.qrisPayment}
|
||||
status={launch.qrisStatus}
|
||||
errorMessage={launch.qrisErrorMessage}
|
||||
onClose={launch.handleQrisClose}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||
<LazyStripePaymentDialog
|
||||
clientSecret={launch.stripeClientSecret}
|
||||
customerSessionClientSecret={
|
||||
launch.stripeCustomerSessionClientSecret
|
||||
}
|
||||
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||
returnPath={stripeReturnPath}
|
||||
onClose={launch.handleStripeClose}
|
||||
onConfirmed={launch.handleStripeConfirmed}
|
||||
@@ -73,56 +79,43 @@ export function PaymentLaunchDialogs({
|
||||
);
|
||||
}
|
||||
|
||||
interface RegionalQrPaymentDialogProps {
|
||||
interface QrisPaymentDialogProps {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
|
||||
status: PaymentLaunchFlow["regionalQrStatus"];
|
||||
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
|
||||
status: PaymentLaunchFlow["qrisStatus"];
|
||||
}
|
||||
|
||||
function formatRegionalQrAmount(amountCents: number, currency: string): string {
|
||||
function formatQrisAmount(amountCents: number, currency: string): string {
|
||||
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
||||
try {
|
||||
return new Intl.NumberFormat(
|
||||
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
|
||||
{
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||
},
|
||||
).format(amountCents / 100);
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||
}).format(amountCents / 100);
|
||||
} catch {
|
||||
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function regionalQrStatusMessage(
|
||||
status: PaymentLaunchFlow["regionalQrStatus"],
|
||||
function qrisStatusMessage(
|
||||
status: PaymentLaunchFlow["qrisStatus"],
|
||||
errorMessage: string | null,
|
||||
paymentName: string,
|
||||
): string {
|
||||
if (status === "failed") {
|
||||
return errorMessage || "Payment failed. Please try again.";
|
||||
}
|
||||
if (status === "expired") {
|
||||
return errorMessage || `This ${paymentName} order has expired.`;
|
||||
}
|
||||
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
|
||||
if (status === "expired") return errorMessage || "This QRIS order has expired.";
|
||||
return "Waiting for payment";
|
||||
}
|
||||
|
||||
function RegionalQrPaymentDialog({
|
||||
function QrisPaymentDialog({
|
||||
errorMessage,
|
||||
onClose,
|
||||
payment,
|
||||
status,
|
||||
}: RegionalQrPaymentDialogProps) {
|
||||
}: QrisPaymentDialogProps) {
|
||||
const titleId = useId();
|
||||
const copy = regionalQrCopy(payment.experience);
|
||||
const statusMessage = regionalQrStatusMessage(
|
||||
status,
|
||||
errorMessage,
|
||||
copy.paymentName,
|
||||
);
|
||||
const statusMessage = qrisStatusMessage(status, errorMessage);
|
||||
|
||||
return (
|
||||
<ModalPortal
|
||||
@@ -136,17 +129,17 @@ function RegionalQrPaymentDialog({
|
||||
>
|
||||
<div className={`${styles.header} text-center`}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{copy.title}
|
||||
Scan to pay with QRIS
|
||||
</h2>
|
||||
<p className={styles.content}>
|
||||
{copy.description}
|
||||
Open a QRIS-compatible banking or wallet app and scan this code.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
|
||||
{payment.qrData ? (
|
||||
<QRCodeSVG
|
||||
value={payment.qrData}
|
||||
title={copy.qrTitle}
|
||||
title="QRIS payment QR code"
|
||||
size={256}
|
||||
level="M"
|
||||
marginSize={2}
|
||||
@@ -154,14 +147,14 @@ function RegionalQrPaymentDialog({
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.error} role="alert">
|
||||
{copy.unavailableMessage}
|
||||
QRIS data is unavailable. Please close this dialog and try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col gap-2 text-center">
|
||||
<p className={styles.content}>Order No. {payment.orderId}</p>
|
||||
<p className="m-0 text-xl font-bold text-text-foreground">
|
||||
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
|
||||
{formatQrisAmount(payment.amountCents, payment.currency)}
|
||||
</p>
|
||||
<p
|
||||
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
||||
@@ -183,41 +176,6 @@ function RegionalQrPaymentDialog({
|
||||
);
|
||||
}
|
||||
|
||||
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,6 +9,11 @@ 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,19 +4,28 @@
|
||||
*
|
||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||
*/
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Elements,
|
||||
ExpressCheckoutElement,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import {
|
||||
loadStripe,
|
||||
type AvailablePaymentMethods,
|
||||
type StripeExpressCheckoutElementConfirmEvent,
|
||||
} from "@stripe/stripe-js";
|
||||
|
||||
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import { 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";
|
||||
|
||||
@@ -26,8 +35,16 @@ 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;
|
||||
@@ -35,12 +52,19 @@ 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 (
|
||||
@@ -100,6 +124,9 @@ export function StripePaymentDialog({
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
...(savedCardClientSecret
|
||||
? { customerSessionClientSecret: savedCardClientSecret }
|
||||
: {}),
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
@@ -132,6 +159,20 @@ 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"]>
|
||||
@@ -150,38 +191,118 @@ function StripePaymentForm({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements || isSubmitting) return;
|
||||
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 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);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
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 returnUrl = new URL(
|
||||
returnPath ?? ROUTES.subscription + "/success",
|
||||
window.location.origin,
|
||||
);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
let confirmationError: unknown;
|
||||
try {
|
||||
({ error: confirmationError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
}));
|
||||
} catch (error) {
|
||||
confirmationError = error;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
||||
if (confirmationError) {
|
||||
const message = ExceptionHandler.message(
|
||||
confirmationError,
|
||||
"Payment confirmation failed.",
|
||||
);
|
||||
setErrorMessage(message);
|
||||
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||
submittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -189,9 +310,74 @@ function StripePaymentForm({
|
||||
onConfirmed?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await confirmPayment({ submitPaymentElement: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<PaymentElement onLoadError={handleLoadError} />
|
||||
<section
|
||||
className={styles.expressSection}
|
||||
hidden={hasExpressPaymentMethods === false}
|
||||
aria-label="Express payment methods"
|
||||
>
|
||||
<ExpressCheckoutElement
|
||||
options={{
|
||||
paymentMethodOrder: [
|
||||
"apple_pay",
|
||||
"google_pay",
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "always",
|
||||
googlePay: "always",
|
||||
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="pay-by-card-title">
|
||||
<h3 id="pay-by-card-title" className={styles.cardTitle}>
|
||||
Pay by card
|
||||
</h3>
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["card"],
|
||||
wallets: {
|
||||
applePay: "never",
|
||||
googlePay: "never",
|
||||
link: "never",
|
||||
},
|
||||
}}
|
||||
onLoadError={handleLoadError}
|
||||
/>
|
||||
</section>
|
||||
{errorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getPaymentUrl,
|
||||
getPaymentUrlHostname,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -42,31 +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;
|
||||
handleRegionalQrClose: () => void;
|
||||
handleQrisClose: () => void;
|
||||
handleStripeClose: () => void;
|
||||
handleStripeConfirmed: () => void;
|
||||
isConfirmingEzpay: boolean;
|
||||
regionalQrErrorMessage: string | null;
|
||||
regionalQrPayment: RegionalQrPaymentDetails | null;
|
||||
regionalQrStatus: PaymentContextState["orderStatus"];
|
||||
qrisErrorMessage: string | null;
|
||||
qrisPayment: QrisPaymentDetails | null;
|
||||
qrisStatus: PaymentContextState["orderStatus"];
|
||||
resetLaunchState: () => void;
|
||||
shouldShowEzpayConfirmDialog: boolean;
|
||||
shouldShowRegionalQrDialog: boolean;
|
||||
shouldShowQrisDialog: boolean;
|
||||
shouldShowStripeDialog: boolean;
|
||||
stripeClientSecret: string | null;
|
||||
stripeCustomerSessionClientSecret: string | null;
|
||||
savedPaymentMethodsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface RegionalQrPaymentDetails {
|
||||
export interface QrisPaymentDetails {
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
experience: "gcashQrPh" | "qris" | "paymentQr";
|
||||
orderId: string;
|
||||
qrData: string;
|
||||
}
|
||||
@@ -156,36 +156,33 @@ 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 selectedPlan = payment.plans.find(
|
||||
(item) => item.planId === payment.selectedPlanId,
|
||||
);
|
||||
const paymentCurrency = payment.payParams
|
||||
? paymentParamString(payment.payParams, "currency") ??
|
||||
selectedPlan?.currency ??
|
||||
null
|
||||
: selectedPlan?.currency ?? null;
|
||||
const stripeCustomerSessionClientSecret = payment.payParams
|
||||
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||
: null;
|
||||
const ezpayLaunchTarget =
|
||||
payment.payParams && isEzpayPayment(payment.payParams)
|
||||
? resolveEzpayLaunchTarget(payment.payParams, {
|
||||
countryCode,
|
||||
currency: paymentCurrency,
|
||||
})
|
||||
? resolveEzpayLaunchTarget(payment.payParams)
|
||||
: null;
|
||||
const ezpayPaymentUrl =
|
||||
ezpayLaunchTarget?.kind === "url"
|
||||
? ezpayLaunchTarget.paymentUrl
|
||||
: null;
|
||||
const regionalQrPayment: RegionalQrPaymentDetails | null =
|
||||
const selectedPlan = payment.plans.find(
|
||||
(item) => item.planId === payment.selectedPlanId,
|
||||
);
|
||||
const qrisPayment: QrisPaymentDetails | null =
|
||||
payment.payParams &&
|
||||
payment.currentOrderId &&
|
||||
ezpayLaunchTarget?.kind === "qr"
|
||||
@@ -195,9 +192,9 @@ export function usePaymentLaunchFlow({
|
||||
selectedPlan?.amountCents ??
|
||||
0,
|
||||
currency:
|
||||
paymentCurrency ??
|
||||
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
|
||||
experience: ezpayLaunchTarget.experience,
|
||||
paymentParamString(payment.payParams, "currency") ??
|
||||
selectedPlan?.currency ??
|
||||
"IDR",
|
||||
orderId: payment.currentOrderId,
|
||||
qrData: ezpayLaunchTarget.qrData,
|
||||
}
|
||||
@@ -216,33 +213,7 @@ export function usePaymentLaunchFlow({
|
||||
|
||||
const isEzpay = isEzpayPayment(payment.payParams);
|
||||
if (isEzpay) {
|
||||
const target = resolveEzpayLaunchTarget(payment.payParams, {
|
||||
countryCode,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
const channelType = paymentParamString(
|
||||
payment.payParams,
|
||||
"channelType",
|
||||
"channel_type",
|
||||
);
|
||||
const channelCode = paymentParamString(
|
||||
payment.payParams,
|
||||
"channelCode",
|
||||
"channel_code",
|
||||
);
|
||||
const namedPaymentUrl = getPaymentUrl(payment.payParams);
|
||||
log.debug(`[${logScope}] ezpay launch target resolved`, {
|
||||
countryCode: countryCode?.trim().toUpperCase() ?? null,
|
||||
currency: paymentCurrency?.trim().toUpperCase() ?? null,
|
||||
channelType: channelType?.toUpperCase() ?? null,
|
||||
channelCode,
|
||||
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
|
||||
hasQrData: target.kind === "qr",
|
||||
paymentUrlHost:
|
||||
target.kind === "url"
|
||||
? getPaymentUrlHostname(target.paymentUrl)
|
||||
: null,
|
||||
});
|
||||
const target = resolveEzpayLaunchTarget(payment.payParams);
|
||||
if (target.kind === "error") {
|
||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||
paymentDispatch({
|
||||
@@ -257,18 +228,11 @@ export function usePaymentLaunchFlow({
|
||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage: "Missing order id before showing payment QR code.",
|
||||
errorMessage: "Missing order id before showing QRIS.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackPaymentCheckoutOpened(
|
||||
payment,
|
||||
target.experience === "qris"
|
||||
? "qris_embedded"
|
||||
: target.experience === "gcashQrPh"
|
||||
? "gcash_qrph_embedded"
|
||||
: "payment_qr_embedded",
|
||||
);
|
||||
trackPaymentCheckoutOpened(payment, "qris_embedded");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,7 +240,7 @@ export function usePaymentLaunchFlow({
|
||||
if (!AppEnvUtil.isProduction()) {
|
||||
log.debug(`[${logScope}] ezpay confirmation required`, {
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
|
||||
paymentUrl,
|
||||
subscriptionType,
|
||||
});
|
||||
return;
|
||||
@@ -302,7 +266,7 @@ export function usePaymentLaunchFlow({
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (paymentUrl) {
|
||||
try {
|
||||
window.location.assign(paymentUrl);
|
||||
window.location.href = paymentUrl;
|
||||
trackPaymentCheckoutOpened(payment, paymentUrl);
|
||||
} catch {
|
||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||
@@ -323,7 +287,6 @@ export function usePaymentLaunchFlow({
|
||||
log,
|
||||
logScope,
|
||||
characterSlug,
|
||||
countryCode,
|
||||
payment.currentOrderId,
|
||||
payment,
|
||||
payment.launchNonce,
|
||||
@@ -332,7 +295,6 @@ export function usePaymentLaunchFlow({
|
||||
payment.plans,
|
||||
payment.selectedPlanId,
|
||||
paymentDispatch,
|
||||
paymentCurrency,
|
||||
returnTo,
|
||||
subscriptionType,
|
||||
giftCategory,
|
||||
@@ -349,13 +311,16 @@ export function usePaymentLaunchFlow({
|
||||
payParams: payment.payParams,
|
||||
paymentUrl: ezpayPaymentUrl,
|
||||
});
|
||||
const shouldShowRegionalQrDialog = Boolean(
|
||||
regionalQrPayment && !payment.isPaid,
|
||||
const shouldShowQrisDialog = Boolean(
|
||||
qrisPayment &&
|
||||
qrisPayment.orderId !== hiddenQrisOrderId &&
|
||||
!payment.isPaid,
|
||||
);
|
||||
|
||||
function resetLaunchState(): void {
|
||||
setIsConfirmingEzpay(false);
|
||||
setHiddenStripeClientSecret(null);
|
||||
setHiddenQrisOrderId(null);
|
||||
}
|
||||
|
||||
function handleStripeClose(): void {
|
||||
@@ -402,30 +367,32 @@ export function usePaymentLaunchFlow({
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
|
||||
function handleRegionalQrClose(): void {
|
||||
log.debug(`[${logScope}] regional payment QR dialog closed`, {
|
||||
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
|
||||
experience: regionalQrPayment?.experience ?? null,
|
||||
function handleQrisClose(): void {
|
||||
log.debug(`[${logScope}] qris dialog closed`, {
|
||||
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||
subscriptionType,
|
||||
});
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
||||
}
|
||||
|
||||
return {
|
||||
ezpayPaymentUrl,
|
||||
handleEzpayCancel,
|
||||
handleEzpayConfirm,
|
||||
handleRegionalQrClose,
|
||||
handleQrisClose,
|
||||
handleStripeClose,
|
||||
handleStripeConfirmed,
|
||||
isConfirmingEzpay,
|
||||
regionalQrErrorMessage: payment.errorMessage,
|
||||
regionalQrPayment,
|
||||
regionalQrStatus: payment.orderStatus,
|
||||
qrisErrorMessage: payment.errorMessage,
|
||||
qrisPayment,
|
||||
qrisStatus: payment.orderStatus,
|
||||
resetLaunchState,
|
||||
shouldShowEzpayConfirmDialog,
|
||||
shouldShowRegionalQrDialog,
|
||||
shouldShowQrisDialog,
|
||||
shouldShowStripeDialog,
|
||||
stripeClientSecret,
|
||||
stripeCustomerSessionClientSecret,
|
||||
savedPaymentMethodsEnabled:
|
||||
stripeCustomerSessionClientSecret !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
|
||||
characterId?: string;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
|
||||
characterId,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
|
||||
characterId ?? "",
|
||||
initialCategory ?? "",
|
||||
initialPlanId ?? "",
|
||||
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||
commercialOfferId ?? "",
|
||||
chatActionId ?? "",
|
||||
].join(":");
|
||||
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
|
||||
...(characterId ? { characterId } : {}),
|
||||
...(initialCategory ? { category: initialCategory } : {}),
|
||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||
...(initialAutoRenew === null
|
||||
? {}
|
||||
: { autoRenew: initialAutoRenew }),
|
||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||
...(chatActionId ? { chatActionId } : {}),
|
||||
});
|
||||
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
|
||||
chatActionId,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
initialPayChannel,
|
||||
paymentDispatch,
|
||||
]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { deriveChatSupportCta } from "../chat-support-cta";
|
||||
describe("deriveChatSupportCta", () => {
|
||||
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||
|
||||
it("never invents a first-recharge discount and shows the active account offer", () => {
|
||||
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: true,
|
||||
@@ -23,10 +23,10 @@ describe("deriveChatSupportCta", () => {
|
||||
nowMs: now,
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "commercialOffer",
|
||||
label: "Support me · 30% OFF · 23:59:59",
|
||||
commercialOfferId: "offer-1",
|
||||
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||
kind: "firstRecharge",
|
||||
label: "Support me · 50% OFF · First recharge",
|
||||
commercialOfferId: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,14 +63,6 @@ describe("deriveChatSupportCta", () => {
|
||||
nowMs: now,
|
||||
}).label,
|
||||
).toBe("Support me");
|
||||
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: true,
|
||||
commercialOffer: null,
|
||||
nowMs: now,
|
||||
}).label,
|
||||
).toBe("Support me");
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: false,
|
||||
@@ -89,30 +81,4 @@ describe("deriveChatSupportCta", () => {
|
||||
}).label,
|
||||
).toBe("Support me");
|
||||
});
|
||||
|
||||
it("shows the final 50% account offer with the same countdown", () => {
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-2",
|
||||
characterId: "elio",
|
||||
planId: "dol_2500_t3",
|
||||
discountPercent: 50,
|
||||
pricePercent: 50,
|
||||
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||
triggerReason: "expired_order",
|
||||
message: "This is the last offer.",
|
||||
scope: "account",
|
||||
stage: "halfDiscount",
|
||||
},
|
||||
nowMs: now,
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "commercialOffer",
|
||||
label: "Support me · 50% OFF · 23:59:59",
|
||||
commercialOfferId: "offer-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommercialOfferSummary } from "@/data/schemas/payment";
|
||||
|
||||
export type ChatSupportCtaKind =
|
||||
| "support"
|
||||
| "firstRecharge"
|
||||
| "commercialOffer";
|
||||
|
||||
export interface ChatSupportCtaView {
|
||||
@@ -17,6 +18,16 @@ export function deriveChatSupportCta(input: {
|
||||
commercialOffer: CommercialOfferSummary | null;
|
||||
nowMs: number;
|
||||
}): ChatSupportCtaView {
|
||||
if (input.isFirstRecharge) {
|
||||
return {
|
||||
kind: "firstRecharge",
|
||||
label: "Support me · 50% OFF · First recharge",
|
||||
commercialOfferId: null,
|
||||
planId: null,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const offer = input.commercialOffer;
|
||||
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
|
||||
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
margin:
|
||||
clamp(5px, 1.111vw, 6px)
|
||||
var(--chat-inline-padding, 16px)
|
||||
clamp(8px, 1.852vw, 10px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.34);
|
||||
border-radius: var(--responsive-card-radius-sm, 22px);
|
||||
background:
|
||||
radial-gradient(circle at 14% 0%, rgba(255, 255, 255, 0.72), transparent 34%),
|
||||
linear-gradient(135deg, #fff0b8 0%, #ffd1df 45%, #ff61a7 100%);
|
||||
box-shadow: 0 14px 34px rgba(114, 21, 63, 0.22);
|
||||
color: #2c111d;
|
||||
animation: firstRechargeBannerIn 260ms ease-out both;
|
||||
}
|
||||
|
||||
.banner::after {
|
||||
position: absolute;
|
||||
right: clamp(-34px, -6.296vw, -28px);
|
||||
bottom: clamp(-52px, -9.63vw, -42px);
|
||||
width: clamp(104px, 23.333vw, 126px);
|
||||
height: clamp(104px, 23.333vw, 126px);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.contentButton {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: clamp(8px, 1.852vw, 10px);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding:
|
||||
clamp(10px, 2.222vw, 12px)
|
||||
clamp(7px, 1.481vw, 8px)
|
||||
clamp(10px, 2.222vw, 12px)
|
||||
clamp(12px, 2.593vw, 14px);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
display: inline-flex;
|
||||
width: clamp(30px, 6.296vw, 34px);
|
||||
height: clamp(30px, 6.296vw, 34px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: clamp(12px, 2.593vw, 14px);
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: #fb2f89;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: rgba(44, 17, 29, 0.74);
|
||||
font-size: var(--responsive-micro, 11px);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--responsive-inline-gap-xs, 5px);
|
||||
align-items: baseline;
|
||||
color: #241019;
|
||||
font-size: var(--responsive-caption, 13px);
|
||||
font-weight: 750;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.title strong {
|
||||
color: #f90073;
|
||||
font-size: var(--responsive-section-title, 20px);
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: rgba(44, 17, 29, 0.58);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: var(--responsive-icon-button-size, 42px);
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: rgba(44, 17, 29, 0.68);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compactButton {
|
||||
display: grid;
|
||||
width: min(100%, 240px);
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
min-height: 50px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.68), transparent 38%),
|
||||
linear-gradient(135deg, #fff0b8 0%, #ffd0df 50%, #ff6cab 100%);
|
||||
color: #2c111d;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
box-shadow: 0 9px 24px rgba(114, 21, 63, 0.2);
|
||||
animation: firstRechargeBannerIn 260ms ease-out both;
|
||||
}
|
||||
|
||||
.compactIcon {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: #f90073;
|
||||
}
|
||||
|
||||
.compactCopy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.compactCopy span,
|
||||
.compactCopy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compactCopy span {
|
||||
color: rgba(44, 17, 29, 0.68);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.compactCopy strong {
|
||||
color: #ec006d;
|
||||
font-size: 17px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.contentButton:focus-visible,
|
||||
.closeButton:focus-visible,
|
||||
.compactButton:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.94);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
@keyframes firstRechargeBannerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.title {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.compactButton {
|
||||
gap: 5px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.compactIcon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Sparkles, X } from "lucide-react";
|
||||
|
||||
import styles from "./first-recharge-offer-banner.module.css";
|
||||
|
||||
export interface FirstRechargeOfferBannerProps {
|
||||
visible: boolean;
|
||||
discountPercent: number;
|
||||
onClick: () => void;
|
||||
onClose: () => void;
|
||||
variant?: "banner" | "compact";
|
||||
}
|
||||
|
||||
export function FirstRechargeOfferBanner({
|
||||
visible,
|
||||
discountPercent,
|
||||
onClick,
|
||||
onClose,
|
||||
variant = "banner",
|
||||
}: FirstRechargeOfferBannerProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-key="chat.first_recharge_offer"
|
||||
data-analytics-label="Open first recharge offer"
|
||||
className={styles.compactButton}
|
||||
aria-label={`First recharge offer, ${discountPercent}% off`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={styles.compactIcon} aria-hidden="true">
|
||||
<Sparkles size={14} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className={styles.compactCopy}>
|
||||
<span>First recharge</span>
|
||||
<strong>{discountPercent}% OFF</strong>
|
||||
</span>
|
||||
<ChevronRight size={14} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.banner} aria-label="First recharge offer">
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-key="chat.first_recharge_offer"
|
||||
data-analytics-label="Open first recharge offer"
|
||||
className={styles.contentButton}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={styles.iconWrap} aria-hidden="true">
|
||||
<Sparkles className={styles.icon} size={18} />
|
||||
</span>
|
||||
<span className={styles.copy}>
|
||||
<span className={styles.eyebrow}>First Recharge Offer</span>
|
||||
<span className={styles.title}>
|
||||
<strong>{discountPercent}% OFF</strong>
|
||||
<span>Claim your discount now</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className={styles.arrow} size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Hide first recharge offer"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export * from "./chat-send-button";
|
||||
export * from "./chat-support-button";
|
||||
export * from "./chat-unlock-dialogs";
|
||||
export * from "./date-header";
|
||||
export * from "./first-recharge-offer-banner";
|
||||
export * from "./fullscreen-image-viewer";
|
||||
export * from "./history-unlock-dialog";
|
||||
export * from "./image-bubble";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldShowFirstRechargeOfferBanner } from "../use-first-recharge-offer-banner";
|
||||
|
||||
describe("shouldShowFirstRechargeOfferBanner", () => {
|
||||
it("shows for authenticated users when first recharge offer is available", () => {
|
||||
expect(
|
||||
shouldShowFirstRechargeOfferBanner({
|
||||
historyLoaded: true,
|
||||
loginStatus: "facebook",
|
||||
isFirstRecharge: true,
|
||||
dismissed: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show for guest users", () => {
|
||||
expect(
|
||||
shouldShowFirstRechargeOfferBanner({
|
||||
historyLoaded: true,
|
||||
loginStatus: "guest",
|
||||
isFirstRecharge: true,
|
||||
dismissed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show unless isFirstRecharge is true", () => {
|
||||
expect(
|
||||
shouldShowFirstRechargeOfferBanner({
|
||||
historyLoaded: true,
|
||||
loginStatus: "google",
|
||||
isFirstRecharge: false,
|
||||
dismissed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show after the user dismisses it", () => {
|
||||
expect(
|
||||
shouldShowFirstRechargeOfferBanner({
|
||||
historyLoaded: true,
|
||||
loginStatus: "email",
|
||||
isFirstRecharge: true,
|
||||
dismissed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("waits until chat history has loaded", () => {
|
||||
expect(
|
||||
shouldShowFirstRechargeOfferBanner({
|
||||
historyLoaded: false,
|
||||
loginStatus: "email",
|
||||
isFirstRecharge: true,
|
||||
dismissed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,12 @@ import { createChatWebSocket } from "@/core/net/chat-websocket";
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import { getSessionAccessToken } from "@/lib/auth/auth_session";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { usePaymentDispatch } from "@/stores/payment/payment-context";
|
||||
|
||||
export function useChatCommercialMessages(input: {
|
||||
characterId: string;
|
||||
loginStatus: LoginStatus;
|
||||
}) {
|
||||
const chatDispatch = useChatDispatch();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
|
||||
@@ -26,10 +24,6 @@ export function useChatCommercialMessages(input: {
|
||||
if (disposed || !token) return;
|
||||
socket = createChatWebSocket(token);
|
||||
socket.onCommercialMessage = (message) => {
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
characterId: input.characterId,
|
||||
});
|
||||
if (message.characterId !== input.characterId) return;
|
||||
chatDispatch({ type: "ChatCommercialMessageReceived", message });
|
||||
};
|
||||
@@ -40,5 +34,5 @@ export function useChatCommercialMessages(input: {
|
||||
disposed = true;
|
||||
socket?.disconnect();
|
||||
};
|
||||
}, [chatDispatch, input.characterId, input.loginStatus, paymentDispatch]);
|
||||
}, [chatDispatch, input.characterId, input.loginStatus]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
@@ -36,20 +36,17 @@ export function useChatSupportCta(input: {
|
||||
);
|
||||
const isAuthenticated =
|
||||
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
|
||||
const refreshCatalog = useCallback(() => {
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
characterId: input.characterId,
|
||||
payChannel: getDefaultPayChannelForCountryCode(countryCode),
|
||||
});
|
||||
}, [countryCode, input.characterId, paymentDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
if (initializedCharacterRef.current === input.characterId) return;
|
||||
initializedCharacterRef.current = input.characterId;
|
||||
refreshCatalog();
|
||||
}, [input.characterId, isAuthenticated, refreshCatalog]);
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
characterId: input.characterId,
|
||||
payChannel: getDefaultPayChannelForCountryCode(countryCode),
|
||||
});
|
||||
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payment.commercialOffer) return;
|
||||
@@ -57,30 +54,6 @@ export function useChatSupportCta(input: {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [payment.commercialOffer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") refreshCatalog();
|
||||
};
|
||||
window.addEventListener("focus", refreshCatalog);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener("focus", refreshCatalog);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [isAuthenticated, refreshCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !payment.commercialOffer) return;
|
||||
const expiresAtMs = Date.parse(payment.commercialOffer.expiresAt);
|
||||
if (!Number.isFinite(expiresAtMs)) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setNowMs(Date.now());
|
||||
refreshCatalog();
|
||||
}, Math.max(0, expiresAtMs - Date.now()) + 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isAuthenticated, payment.commercialOffer, refreshCatalog]);
|
||||
|
||||
const cta = useMemo(
|
||||
() =>
|
||||
deriveChatSupportCta({
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentSelector,
|
||||
} from "@/stores/payment/payment-context";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import {
|
||||
getFirstRechargeOfferBannerDismissed,
|
||||
recordFirstRechargeOfferBannerDismissed,
|
||||
} from "@/lib/chat/first_recharge_offer_banner";
|
||||
|
||||
export interface FirstRechargeOfferBannerEligibility {
|
||||
historyLoaded: boolean;
|
||||
loginStatus: LoginStatus;
|
||||
isFirstRecharge: boolean;
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
export interface UseFirstRechargeOfferBannerInput {
|
||||
historyLoaded: boolean;
|
||||
loginStatus: LoginStatus;
|
||||
}
|
||||
|
||||
export interface UseFirstRechargeOfferBannerOutput {
|
||||
visible: boolean;
|
||||
discountPercent: number;
|
||||
close: () => void;
|
||||
claim: () => void;
|
||||
}
|
||||
|
||||
interface DismissalState {
|
||||
userId: string | null;
|
||||
loaded: boolean;
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
const FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT = 50;
|
||||
|
||||
export function shouldShowFirstRechargeOfferBanner(
|
||||
input: FirstRechargeOfferBannerEligibility,
|
||||
): boolean {
|
||||
if (!input.historyLoaded) return false;
|
||||
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
|
||||
return false;
|
||||
}
|
||||
if (!input.isFirstRecharge) return false;
|
||||
return !input.dismissed;
|
||||
}
|
||||
|
||||
export function useFirstRechargeOfferBanner({
|
||||
historyLoaded,
|
||||
loginStatus,
|
||||
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
|
||||
const navigator = useAppNavigator();
|
||||
const payment = usePaymentSelector(
|
||||
(state) => ({
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
status: String(state.value),
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const user = useUserSelector(
|
||||
(state) => ({
|
||||
countryCode: state.context.currentUser?.countryCode,
|
||||
id: state.context.currentUser?.id ?? null,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const isAuthenticatedUser =
|
||||
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
|
||||
const userId = user.id;
|
||||
const hasUserIdentity = !isAuthenticatedUser || Boolean(userId);
|
||||
const [dismissalState, setDismissalState] = useState<DismissalState>(() => ({
|
||||
userId,
|
||||
loaded: false,
|
||||
dismissed: false,
|
||||
}));
|
||||
const discountPercent = FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticatedUser) return;
|
||||
if (payment.status !== "idle") return;
|
||||
const defaultPayChannel = getDefaultPayChannelForCountryCode(
|
||||
user.countryCode,
|
||||
);
|
||||
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
|
||||
}, [
|
||||
payment.status,
|
||||
paymentDispatch,
|
||||
isAuthenticatedUser,
|
||||
user.countryCode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadDismissal = async () => {
|
||||
const dismissed = await getFirstRechargeOfferBannerDismissed(userId);
|
||||
if (cancelled) return;
|
||||
|
||||
setDismissalState({
|
||||
userId,
|
||||
loaded: true,
|
||||
dismissed,
|
||||
});
|
||||
};
|
||||
|
||||
void loadDismissal();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const dismissalLoaded =
|
||||
dismissalState.userId === userId && dismissalState.loaded;
|
||||
const visible =
|
||||
hasUserIdentity &&
|
||||
dismissalLoaded &&
|
||||
shouldShowFirstRechargeOfferBanner({
|
||||
historyLoaded,
|
||||
loginStatus,
|
||||
isFirstRecharge: payment.isFirstRecharge,
|
||||
dismissed: dismissalState.dismissed,
|
||||
});
|
||||
|
||||
function close(): void {
|
||||
setDismissalState({
|
||||
userId,
|
||||
loaded: true,
|
||||
dismissed: true,
|
||||
});
|
||||
void recordFirstRechargeOfferBannerDismissed(userId);
|
||||
}
|
||||
|
||||
function claim(): void {
|
||||
navigator.openSubscription({
|
||||
type: "vip",
|
||||
returnTo: "chat",
|
||||
analytics: {
|
||||
entryPoint: "chat_offer_banner",
|
||||
triggerReason: "vip_cta",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
discountPercent,
|
||||
close,
|
||||
claim,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
persistExternalEntryPayload,
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
@@ -22,6 +23,7 @@ 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,
|
||||
@@ -61,6 +63,9 @@ 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 });
|
||||
@@ -71,11 +76,14 @@ export default function ExternalEntryPersist({
|
||||
mode,
|
||||
promotionType,
|
||||
});
|
||||
const isTopUpHandoff = targetRoute === ROUTES.subscription;
|
||||
const normalizedTarget = target?.trim().toLowerCase() ?? "";
|
||||
const isTopUpHandoff = normalizedTarget === "topup";
|
||||
const isCheckoutHandoff = normalizedTarget === "checkout";
|
||||
const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff;
|
||||
const displayedHandoffError =
|
||||
handoffError ??
|
||||
(isTopUpHandoff && !hasValue(handoffToken)
|
||||
? "This top-up link is invalid. Please request a new link in Messenger."
|
||||
(isLoginHandoff && !hasValue(handoffToken)
|
||||
? "This checkout link is invalid. Please return and request a new link."
|
||||
: null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,7 +136,7 @@ export default function ExternalEntryPersist({
|
||||
useEffect(() => {
|
||||
if (hasNavigatedRef.current || !hasPersisted) return;
|
||||
|
||||
if (isTopUpHandoff) {
|
||||
if (isLoginHandoff) {
|
||||
if (!authState.hasInitialized || authState.isLoading) return;
|
||||
if (handoffCompleted) {
|
||||
if (
|
||||
@@ -138,25 +146,60 @@ export default function ExternalEntryPersist({
|
||||
return;
|
||||
}
|
||||
hasNavigatedRef.current = true;
|
||||
router.replace(destination);
|
||||
router.replace(checkoutDestination ?? destination);
|
||||
return;
|
||||
}
|
||||
if (handoffStartedRef.current) return;
|
||||
if (!hasValue(handoffToken)) return;
|
||||
handoffStartedRef.current = true;
|
||||
void (async () => {
|
||||
const result = await consumeTopUpHandoff(handoffToken);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
|
||||
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),
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
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" });
|
||||
@@ -189,11 +232,13 @@ export default function ExternalEntryPersist({
|
||||
authState.hasInitialized,
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
character,
|
||||
destination,
|
||||
checkoutDestination,
|
||||
hasPersisted,
|
||||
handoffCompleted,
|
||||
handoffToken,
|
||||
isCheckoutHandoff,
|
||||
isLoginHandoff,
|
||||
isTopUpHandoff,
|
||||
psid,
|
||||
router,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* `/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 并跳转到最终页面。
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("SubscriptionPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the recipient empty when source navigation has no character", async () => {
|
||||
it("requires an explicit selection when source navigation has no character", async () => {
|
||||
const page = await SubscriptionPage({
|
||||
searchParams: Promise.resolve({}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommercialOfferSummary } from "@/data/schemas/payment";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const characters = [
|
||||
@@ -49,7 +48,7 @@ const mocks = vi.hoisted(() => {
|
||||
},
|
||||
],
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null as CommercialOfferSummary | null,
|
||||
commercialOffer: null,
|
||||
selectedPlanId: "vip_monthly",
|
||||
payChannel: "stripe" as const,
|
||||
agreed: true,
|
||||
@@ -217,11 +216,10 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("confirms VIP selection before enabling the separate checkout button", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => root.render(<SubscriptionScreen />));
|
||||
const checkout = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="checkout"]',
|
||||
);
|
||||
@@ -252,7 +250,7 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
});
|
||||
|
||||
it("keeps the original selection when VIP confirmation is cancelled", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => root.render(<SubscriptionScreen />));
|
||||
act(() => clickButton(container, "vip_quarterly"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
@@ -267,21 +265,21 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
});
|
||||
|
||||
it("requires VIP confirmation again after the page is remounted", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => root.render(<SubscriptionScreen />));
|
||||
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(() => root.render(<SubscriptionScreen />));
|
||||
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(() => root.render(<SubscriptionScreen />));
|
||||
act(() => clickButton(container, "coin_1000"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
@@ -321,114 +319,15 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
promotionType: "first_recharge_half_price",
|
||||
});
|
||||
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => root.render(<SubscriptionScreen />));
|
||||
|
||||
expect(container.textContent).not.toContain("First Recharge Offer");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Your first recharge price is already applied",
|
||||
);
|
||||
expect(container.textContent).not.toContain("50% OFF");
|
||||
});
|
||||
|
||||
it("refreshes the catalog without a stale offer id when the page regains focus", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
mocks.paymentDispatch.mockClear();
|
||||
|
||||
act(() => window.dispatchEvent(new Event("focus")));
|
||||
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentInit",
|
||||
characterId: "elio",
|
||||
payChannel: "stripe",
|
||||
planId: "vip_monthly",
|
||||
commercialOfferId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("describes an account offer without attributing it to the current role", () => {
|
||||
mocks.payment.commercialOffer = {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-account-1",
|
||||
characterId: "elio",
|
||||
planId: "vip_monthly",
|
||||
discountPercent: 50,
|
||||
pricePercent: 50,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
triggerReason: "expired_order",
|
||||
message: "Everything in top-up and VIP is 50% OFF.",
|
||||
scope: "account",
|
||||
stage: "halfDiscount",
|
||||
};
|
||||
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="maya" />));
|
||||
|
||||
expect(container.textContent).toContain("Private offer · 50% OFF");
|
||||
expect(container.textContent).toContain("Your private price is active");
|
||||
expect(container.textContent).not.toContain("Maya got this price for you");
|
||||
});
|
||||
|
||||
it("refreshes the catalog when the active offer expires", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-30T08:00:00Z"));
|
||||
mocks.payment.commercialOffer = {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-expiring-1",
|
||||
characterId: "elio",
|
||||
planId: "vip_monthly",
|
||||
discountPercent: 30,
|
||||
pricePercent: 70,
|
||||
expiresAt: "2026-07-30T08:00:01Z",
|
||||
triggerReason: "expired_order",
|
||||
message: "30% OFF",
|
||||
scope: "account",
|
||||
stage: "sevenDiscount",
|
||||
};
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
mocks.paymentDispatch.mockClear();
|
||||
|
||||
act(() => vi.advanceTimersByTime(1_250));
|
||||
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "PaymentInit",
|
||||
characterId: "elio",
|
||||
commercialOfferId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["elio", "maya", "nayeli"])(
|
||||
"inherits the %s chat character without rendering a recipient selector",
|
||||
(sourceCharacterSlug) => {
|
||||
mocks.payment.selectedPlanId = "coin_1000";
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
<SubscriptionScreen
|
||||
subscriptionType="topup"
|
||||
sourceCharacterSlug={sourceCharacterSlug}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const checkout = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="checkout"]',
|
||||
);
|
||||
expect(container.textContent).not.toContain("Supporting ");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Choose who you want to support",
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Your VIP or credit purchase supports the character you choose",
|
||||
);
|
||||
expect(checkout?.disabled).toBe(false);
|
||||
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sourceCharacterSlug }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not invent a recipient when opened without a source character", () => {
|
||||
it("requires a support character selection when opened without a character", () => {
|
||||
mocks.payment.selectedPlanId = "coin_1000";
|
||||
|
||||
act(() =>
|
||||
@@ -443,17 +342,19 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
const checkout = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="checkout"]',
|
||||
);
|
||||
expect(container.textContent).not.toContain("Supporting ");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Choose who you want to support",
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Your VIP or credit purchase supports the character you choose",
|
||||
);
|
||||
expect(container.textContent).toContain("Choose who you want to support");
|
||||
expect(checkout?.disabled).toBe(true);
|
||||
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sourceCharacterSlug: null }),
|
||||
);
|
||||
|
||||
act(() => clickButton(container, "Maya"));
|
||||
|
||||
expect(container.textContent).toContain("Supporting Maya");
|
||||
expect(checkout?.disabled).toBe(false);
|
||||
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sourceCharacterSlug: "maya" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
canCheckoutSubscriptionPlan,
|
||||
findSelectedSubscriptionPlan,
|
||||
getDefaultSubscriptionPlanId,
|
||||
getFirstRechargeOfferView,
|
||||
requiresVipPlanConfirmation,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
@@ -100,7 +101,7 @@ describe("subscription screen helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores legacy first-recharge flags while retaining quoted and original prices", () => {
|
||||
it("maps first recharge offer fields to plan views", () => {
|
||||
const vipViews = toVipOfferPlanViews([
|
||||
makePlan({
|
||||
planId: "vip_monthly",
|
||||
@@ -125,18 +126,51 @@ describe("subscription screen helpers", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(vipViews[0]).toEqual(expect.objectContaining({
|
||||
expect(vipViews[0]).toMatchObject({
|
||||
price: "9.95",
|
||||
originalPrice: "19.9",
|
||||
}));
|
||||
expect(vipViews[0]).not.toHaveProperty("isFirstRechargeOffer");
|
||||
expect(vipViews[0]).not.toHaveProperty("firstRechargeDiscountPercent");
|
||||
expect(coinViews[0]).toEqual(expect.objectContaining({
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
});
|
||||
expect(coinViews[0]).toMatchObject({
|
||||
price: "4.95",
|
||||
originalPrice: "9.9",
|
||||
}));
|
||||
expect(coinViews[0]).not.toHaveProperty("isFirstRechargeOffer");
|
||||
expect(coinViews[0]).not.toHaveProperty("firstRechargeDiscountPercent");
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
});
|
||||
expect(
|
||||
getFirstRechargeOfferView({
|
||||
isFirstRecharge: true,
|
||||
subscriptionType: "vip",
|
||||
vipPlans: vipViews,
|
||||
coinPlans: coinViews,
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeText: "50% OFF",
|
||||
title: "First Recharge Offer",
|
||||
renewalNotice:
|
||||
"First month 50% off. Renews at the regular price from the second month.",
|
||||
});
|
||||
expect(
|
||||
getFirstRechargeOfferView({
|
||||
isFirstRecharge: true,
|
||||
subscriptionType: "topup",
|
||||
vipPlans: vipViews,
|
||||
coinPlans: coinViews,
|
||||
}),
|
||||
).toMatchObject({
|
||||
badgeText: "50% OFF",
|
||||
title: "First Recharge Offer",
|
||||
renewalNotice: null,
|
||||
});
|
||||
expect(
|
||||
getFirstRechargeOfferView({
|
||||
isFirstRecharge: false,
|
||||
subscriptionType: "vip",
|
||||
vipPlans: vipViews,
|
||||
coinPlans: coinViews,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("maps the backend most popular flag to VIP and coin views", () => {
|
||||
@@ -157,7 +191,7 @@ describe("subscription screen helpers", () => {
|
||||
expect(popularCoins[0]?.mostPopular).toBe(true);
|
||||
});
|
||||
|
||||
it("maps regular VIP original price without exposing legacy first-recharge state", () => {
|
||||
it("maps normal VIP original price outside first recharge activity", () => {
|
||||
expect(
|
||||
toVipOfferPlanViews([
|
||||
makePlan({
|
||||
@@ -172,6 +206,7 @@ describe("subscription screen helpers", () => {
|
||||
id: "vip_monthly",
|
||||
price: "19.9",
|
||||
originalPrice: "24.99",
|
||||
isFirstRechargeOffer: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -71,6 +71,8 @@ describe("subscription Tailwind components", () => {
|
||||
currency: "usd",
|
||||
originalPrice: "19.99",
|
||||
mostPopular: true,
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
},
|
||||
]}
|
||||
selectedPlanId="vip_monthly"
|
||||
@@ -94,7 +96,7 @@ describe("subscription Tailwind components", () => {
|
||||
);
|
||||
|
||||
expect(vipHtml).toContain("Most Popular");
|
||||
expect(vipHtml).not.toContain("First Recharge");
|
||||
expect(vipHtml).toContain("50% OFF");
|
||||
expect(coinsHtml).toContain("Most Popular");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
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 {
|
||||
@@ -24,7 +25,6 @@ export interface SubscriptionCheckoutButtonProps {
|
||||
subscriptionType: "vip" | "topup";
|
||||
returnTo?: SubscriptionReturnTo;
|
||||
sourceCharacterSlug?: string;
|
||||
countryCode?: string | null;
|
||||
}
|
||||
|
||||
export function SubscriptionCheckoutButton({
|
||||
@@ -32,7 +32,6 @@ export function SubscriptionCheckoutButton({
|
||||
subscriptionType,
|
||||
returnTo = null,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
countryCode = null,
|
||||
}: SubscriptionCheckoutButtonProps) {
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
@@ -44,7 +43,6 @@ export function SubscriptionCheckoutButton({
|
||||
returnTo: returnTo ?? undefined,
|
||||
characterSlug: sourceCharacterSlug,
|
||||
subscriptionType,
|
||||
countryCode,
|
||||
});
|
||||
|
||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||
@@ -60,6 +58,7 @@ export function SubscriptionCheckoutButton({
|
||||
paymentLaunch.resetLaunchState();
|
||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionCtaButton
|
||||
@@ -72,6 +71,21 @@ export function SubscriptionCheckoutButton({
|
||||
>
|
||||
{label}
|
||||
</SubscriptionCtaButton>
|
||||
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||
<ExternalBrowserCheckoutButton
|
||||
disabled={disabled || isLoading}
|
||||
checkoutIntent={{
|
||||
planId: payment.selectedPlanId,
|
||||
autoRenew: payment.autoRenew,
|
||||
...(payment.commercialOfferId
|
||||
? { commercialOfferId: payment.commercialOfferId }
|
||||
: {}),
|
||||
...(payment.chatActionId
|
||||
? { chatActionId: payment.chatActionId }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{payment.errorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface CoinsOfferPlanView {
|
||||
price: string;
|
||||
currency: string;
|
||||
originalPrice?: string;
|
||||
isFirstRechargeOffer?: boolean;
|
||||
mostPopular?: boolean;
|
||||
firstRechargeDiscountPercent?: number | null;
|
||||
promotionType?: string | null;
|
||||
}
|
||||
|
||||
@@ -54,6 +56,11 @@ export function SubscriptionCoinsOfferSection({
|
||||
{plan.mostPopular ? (
|
||||
<span className={styles.popularBadge}>Most Popular</span>
|
||||
) : null}
|
||||
{plan.isFirstRechargeOffer ? (
|
||||
<span className={styles.offerBadge}>
|
||||
{plan.firstRechargeDiscountPercent ?? 50}% OFF
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.priceGroup}>
|
||||
<span className={styles.price}>
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--app-viewport-height, 100dvh);
|
||||
min-height: var(--app-viewport-height, 100dvh);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px),
|
||||
linear-gradient(180deg, #fff9fb 0%, #fcf3f4 52%, #fffefe 100%);
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding:
|
||||
calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px))
|
||||
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
|
||||
var(--page-section-gap-lg, 22px)
|
||||
calc(104px + var(--app-safe-bottom, 0px))
|
||||
calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
|
||||
}
|
||||
|
||||
@@ -71,6 +61,52 @@
|
||||
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12);
|
||||
}
|
||||
|
||||
.supportCharacterSelector {
|
||||
margin-top: var(--page-section-gap, 14px);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(246, 87, 160, 0.2);
|
||||
border-radius: var(--responsive-card-radius-sm, 22px);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #4a3340;
|
||||
}
|
||||
|
||||
.supportCharacterSelector h2 {
|
||||
margin: 0;
|
||||
color: #24151d;
|
||||
font-size: var(--responsive-card-title, 17px);
|
||||
}
|
||||
|
||||
.supportCharacterSelector p {
|
||||
margin: 6px 0 10px;
|
||||
font-size: var(--responsive-caption, 13px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.supportCharacterOptions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.supportCharacterOption,
|
||||
.supportCharacterOptionActive {
|
||||
border: 1px solid rgba(246, 87, 160, 0.24);
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
background: #fff;
|
||||
color: #8e315e;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.supportCharacterOptionActive {
|
||||
border-color: #f657a0;
|
||||
background: #f657a0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.characterSupportBanner {
|
||||
margin-top: var(--page-section-gap, 14px);
|
||||
padding: 14px 16px;
|
||||
@@ -201,12 +237,11 @@
|
||||
}
|
||||
|
||||
.ctaSlot {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
max-height: min(50dvh, 360px);
|
||||
overflow-y: auto;
|
||||
right: 50%;
|
||||
bottom: 0;
|
||||
width: min(100%, var(--app-max-width, 540px));
|
||||
padding:
|
||||
12px
|
||||
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
|
||||
@@ -220,4 +255,5 @@
|
||||
);
|
||||
box-shadow: 0 -12px 30px rgba(87, 35, 59, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ export interface VipOfferPlanView {
|
||||
price: string;
|
||||
currency: string;
|
||||
originalPrice: string;
|
||||
isFirstRechargeOffer?: boolean;
|
||||
mostPopular?: boolean;
|
||||
firstRechargeDiscountPercent?: number | null;
|
||||
promotionType?: string | null;
|
||||
}
|
||||
|
||||
@@ -41,7 +43,9 @@ export function SubscriptionVipOfferSection({
|
||||
<div className={styles.planGrid}>
|
||||
{plans.map((plan) => {
|
||||
const selected = selectedPlanId === plan.id;
|
||||
const badgeCount = Number(plan.mostPopular === true);
|
||||
const badgeCount =
|
||||
Number(plan.mostPopular === true) +
|
||||
Number(plan.isFirstRechargeOffer === true);
|
||||
return (
|
||||
<button
|
||||
key={plan.id}
|
||||
@@ -58,6 +62,11 @@ export function SubscriptionVipOfferSection({
|
||||
{plan.mostPopular ? (
|
||||
<span className={styles.popularBadge}>Most Popular</span>
|
||||
) : null}
|
||||
{plan.isFirstRechargeOffer ? (
|
||||
<span className={styles.offerBadge}>
|
||||
{plan.firstRechargeDiscountPercent ?? 50}% OFF
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.planTitle}>{plan.title}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/lib/analytics/payment_analytics_context";
|
||||
import {
|
||||
getFirstPaymentSearchParam,
|
||||
parsePaymentAutoRenew,
|
||||
parsePaymentReturnSearchParams,
|
||||
parseSubscriptionReturnTo,
|
||||
type PaymentSearchParams,
|
||||
@@ -55,6 +56,7 @@ export default async function SubscriptionPage({
|
||||
analyticsContext={analyticsContext}
|
||||
sourceCharacterSlug={sourceCharacterSlug}
|
||||
initialPlanId={initialPlanId}
|
||||
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
|
||||
commercialOfferId={commercialOfferId}
|
||||
resumeOrderId={resumeOrderId}
|
||||
chatActionId={chatActionId}
|
||||
|
||||
@@ -5,9 +5,17 @@ import type { VipOfferPlanView } from "./components/subscription-vip-offer-secti
|
||||
|
||||
export type SubscriptionType = "vip" | "topup";
|
||||
|
||||
export interface FirstRechargeOfferView {
|
||||
badgeText: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
renewalNotice: string | null;
|
||||
}
|
||||
|
||||
export function isVipPlan(plan: PaymentPlan): boolean {
|
||||
return plan.vipDays !== null;
|
||||
}
|
||||
|
||||
export function isCreditPlan(plan: PaymentPlan): boolean {
|
||||
return plan.dolAmount !== null;
|
||||
}
|
||||
@@ -52,7 +60,9 @@ export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
|
||||
price: formatOfferAmount(plan.amountCents),
|
||||
currency: formatOfferCurrency(plan.currency),
|
||||
originalPrice: formatOfferAmount(plan.originalAmountCents),
|
||||
isFirstRechargeOffer: plan.isFirstRechargeOffer,
|
||||
mostPopular: plan.mostPopular,
|
||||
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
|
||||
promotionType: plan.promotionType,
|
||||
};
|
||||
}
|
||||
@@ -64,7 +74,9 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView {
|
||||
price: formatOfferAmount(plan.amountCents),
|
||||
currency: formatCoinCurrency(plan.currency),
|
||||
originalPrice: formatOfferAmount(plan.originalAmountCents),
|
||||
isFirstRechargeOffer: plan.isFirstRechargeOffer,
|
||||
mostPopular: plan.mostPopular,
|
||||
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
|
||||
promotionType: plan.promotionType,
|
||||
};
|
||||
}
|
||||
@@ -150,3 +162,30 @@ export function getDefaultSubscriptionPlanId(input: {
|
||||
if (input.selectedPlan !== null) return null;
|
||||
return input.coinPlans[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function getFirstRechargeOfferView(input: {
|
||||
isFirstRecharge: boolean;
|
||||
subscriptionType: SubscriptionType;
|
||||
vipPlans: readonly VipOfferPlanView[];
|
||||
coinPlans: readonly CoinsOfferPlanView[];
|
||||
}): FirstRechargeOfferView | null {
|
||||
if (!input.isFirstRecharge) return null;
|
||||
|
||||
const promotedPlan = [...input.vipPlans, ...input.coinPlans].find(
|
||||
(plan) => plan.isFirstRechargeOffer,
|
||||
);
|
||||
|
||||
const discountPercent =
|
||||
promotedPlan?.firstRechargeDiscountPercent ?? 50;
|
||||
const badgeText = `${discountPercent}% OFF`;
|
||||
return {
|
||||
badgeText,
|
||||
title: "First Recharge Offer",
|
||||
subtitle:
|
||||
"Your first recharge price is already applied. No code needed.",
|
||||
renewalNotice:
|
||||
input.subscriptionType === "vip"
|
||||
? "First month 50% off. Renews at the regular price from the second month."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selec
|
||||
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||
import {
|
||||
behaviorAnalytics,
|
||||
@@ -31,6 +34,7 @@ import styles from "./components/subscription-screen.module.css";
|
||||
import {
|
||||
canCheckoutSubscriptionPlan,
|
||||
findSelectedSubscriptionPlan,
|
||||
getFirstRechargeOfferView,
|
||||
getDefaultSubscriptionPlanId,
|
||||
requiresVipPlanConfirmation,
|
||||
toCoinsOfferPlanViews,
|
||||
@@ -49,6 +53,7 @@ export interface SubscriptionScreenProps {
|
||||
analyticsContext?: PaymentAnalyticsContext;
|
||||
sourceCharacterSlug?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -60,8 +65,9 @@ export function SubscriptionScreen({
|
||||
returnTo = null,
|
||||
initialPayChannel = null,
|
||||
analyticsContext: providedAnalyticsContext,
|
||||
sourceCharacterSlug = null,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -75,8 +81,14 @@ export function SubscriptionScreen({
|
||||
null,
|
||||
);
|
||||
const characterCatalog = useCharacterCatalog();
|
||||
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
|
||||
useState<string | null>(() =>
|
||||
getCharacterBySlug(sourceCharacterSlug)?.slug ?? null,
|
||||
);
|
||||
const userState = useUserState();
|
||||
const sourceCharacter = characterCatalog.getBySlug(sourceCharacterSlug);
|
||||
const sourceCharacter = characterCatalog.getBySlug(
|
||||
selectedSupportCharacterSlug,
|
||||
);
|
||||
const hasHydrated = useHasHydrated();
|
||||
const countryCode = userState.currentUser?.countryCode;
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
@@ -99,48 +111,11 @@ export function SubscriptionScreen({
|
||||
sourceCharacterSlug: sourceCharacter?.slug ?? null,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!sourceCharacter) return;
|
||||
const refreshCatalog = () => {
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
characterId: sourceCharacter.id,
|
||||
payChannel: payment.payChannel,
|
||||
planId: payment.selectedPlanId || null,
|
||||
commercialOfferId: null,
|
||||
});
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") refreshCatalog();
|
||||
};
|
||||
window.addEventListener("focus", refreshCatalog);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const expiresAtMs = payment.commercialOffer
|
||||
? Date.parse(payment.commercialOffer.expiresAt)
|
||||
: Number.NaN;
|
||||
const expiryTimer = Number.isFinite(expiresAtMs)
|
||||
? window.setTimeout(
|
||||
refreshCatalog,
|
||||
Math.max(0, expiresAtMs - Date.now()) + 250,
|
||||
)
|
||||
: null;
|
||||
return () => {
|
||||
window.removeEventListener("focus", refreshCatalog);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
if (expiryTimer !== null) window.clearTimeout(expiryTimer);
|
||||
};
|
||||
}, [
|
||||
payment.commercialOffer,
|
||||
payment.payChannel,
|
||||
payment.selectedPlanId,
|
||||
paymentDispatch,
|
||||
sourceCharacter,
|
||||
]);
|
||||
const canSubscribeVip = subscriptionType === "vip";
|
||||
const analyticsContext =
|
||||
providedAnalyticsContext ??
|
||||
@@ -165,6 +140,22 @@ export function SubscriptionScreen({
|
||||
});
|
||||
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
|
||||
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
|
||||
const hasFirstRechargeOffer = useMemo(
|
||||
() =>
|
||||
getFirstRechargeOfferView({
|
||||
isFirstRecharge: payment.isFirstRecharge,
|
||||
subscriptionType,
|
||||
vipPlans: vipOfferPlans,
|
||||
coinPlans: directCoinsPlans,
|
||||
}) !== null,
|
||||
[
|
||||
directCoinsPlans,
|
||||
payment.isFirstRecharge,
|
||||
subscriptionType,
|
||||
vipOfferPlans,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedPlan = findSelectedSubscriptionPlan({
|
||||
canSubscribeVip,
|
||||
selectedPlanId: payment.selectedPlanId,
|
||||
@@ -262,8 +253,7 @@ export function SubscriptionScreen({
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
<div className={styles.scrollArea}>
|
||||
<header className={styles.header}>
|
||||
<header className={styles.header}>
|
||||
<BackButton
|
||||
className={styles.backSlot}
|
||||
onClick={handleBackClick}
|
||||
@@ -280,7 +270,7 @@ export function SubscriptionScreen({
|
||||
>
|
||||
Payment issue?
|
||||
</button>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
{paymentIssueNotice ? (
|
||||
<p className={styles.paymentIssueNotice} role="status">
|
||||
@@ -288,6 +278,35 @@ export function SubscriptionScreen({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className={styles.supportCharacterSelector}>
|
||||
<h2>
|
||||
{sourceCharacter
|
||||
? `Supporting ${sourceCharacter.shortName}`
|
||||
: "Choose who you want to support"}
|
||||
</h2>
|
||||
<p>
|
||||
Your VIP or credit purchase supports the character you choose, and
|
||||
they will thank you after the payment and benefits are confirmed.
|
||||
</p>
|
||||
<div className={styles.supportCharacterOptions}>
|
||||
{characterCatalog.characters.map((character) => (
|
||||
<button
|
||||
key={character.id}
|
||||
type="button"
|
||||
className={
|
||||
sourceCharacter?.id === character.id
|
||||
? styles.supportCharacterOptionActive
|
||||
: styles.supportCharacterOption
|
||||
}
|
||||
aria-pressed={sourceCharacter?.id === character.id}
|
||||
onClick={() => setSelectedSupportCharacterSlug(character.slug)}
|
||||
>
|
||||
{character.shortName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{chatActionId && sourceCharacter ? (
|
||||
<section
|
||||
className={styles.characterSupportBanner}
|
||||
@@ -304,19 +323,15 @@ export function SubscriptionScreen({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{payment.commercialOffer && sourceCharacter ? (
|
||||
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
|
||||
<section
|
||||
className={styles.firstRechargeBanner}
|
||||
aria-label={`${sourceCharacter.shortName} private offer`}
|
||||
>
|
||||
<span className={styles.firstRechargeBadge}>
|
||||
Private offer · {payment.commercialOffer.discountPercent}% OFF
|
||||
</span>
|
||||
<span className={styles.firstRechargeBadge}>Private offer</span>
|
||||
<div className={styles.firstRechargeCopy}>
|
||||
<h2 className={styles.firstRechargeTitle}>
|
||||
{payment.commercialOffer.scope === "account"
|
||||
? "Your private price is active"
|
||||
: `${sourceCharacter.shortName} got this price for you`}
|
||||
{sourceCharacter.shortName} got this price for you
|
||||
</h2>
|
||||
<p className={styles.firstRechargeSubtitle}>
|
||||
{payment.commercialOffer.message ||
|
||||
@@ -341,20 +356,19 @@ export function SubscriptionScreen({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={payment.isCreatingOrder}
|
||||
caption={
|
||||
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
||||
? "QRIS by default in Indonesia"
|
||||
: "GCash by default in the Philippines"
|
||||
}
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
</div>
|
||||
<PaymentMethodSelector
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
caption={
|
||||
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
||||
? "QRIS by default in Indonesia"
|
||||
: "GCash by default in the Philippines"
|
||||
}
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
|
||||
<div className={styles.ctaSlot}>
|
||||
<SubscriptionCheckoutButton
|
||||
@@ -362,7 +376,6 @@ export function SubscriptionScreen({
|
||||
subscriptionType={subscriptionType}
|
||||
returnTo={returnTo}
|
||||
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ export interface UseSubscriptionPaymentFlowInput {
|
||||
initialPayChannel: PayChannel;
|
||||
sourceCharacterSlug?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -34,8 +36,9 @@ export function useSubscriptionPaymentFlow({
|
||||
shouldResumePendingOrder,
|
||||
returnTo,
|
||||
initialPayChannel,
|
||||
sourceCharacterSlug = null,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -49,6 +52,7 @@ export function useSubscriptionPaymentFlow({
|
||||
shouldResumePendingOrder,
|
||||
characterId: supportCharacter?.id,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
|
||||
@@ -359,6 +359,8 @@ function makePaymentState(
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
commercialOfferId: null,
|
||||
chatActionId: null,
|
||||
selectedPlanId: giftPlan.planId,
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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,
|
||||
@@ -19,7 +20,6 @@ export interface TipCheckoutButtonProps {
|
||||
disabled?: boolean;
|
||||
onOrder: () => void;
|
||||
returnPath: string;
|
||||
countryCode?: string | null;
|
||||
}
|
||||
|
||||
export function TipCheckoutButton({
|
||||
@@ -28,7 +28,6 @@ export function TipCheckoutButton({
|
||||
disabled = false,
|
||||
onOrder,
|
||||
returnPath,
|
||||
countryCode = null,
|
||||
}: TipCheckoutButtonProps) {
|
||||
const character = useActiveCharacter();
|
||||
const payment = usePaymentState();
|
||||
@@ -42,17 +41,16 @@ export function TipCheckoutButton({
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
characterSlug: character.slug,
|
||||
countryCode,
|
||||
});
|
||||
|
||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||
const label = payment.isPollingOrder
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
: payment.isPaid
|
||||
? "Thanks for the coffee"
|
||||
: "Order and Buy";
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
: payment.isPaid
|
||||
? "Thanks for the coffee"
|
||||
: "Order and Buy";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -66,6 +64,19 @@ export function TipCheckoutButton({
|
||||
>
|
||||
{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}
|
||||
|
||||
@@ -292,7 +292,7 @@ export function TipScreen({
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
density="compact"
|
||||
disabled={payment.isCreatingOrder}
|
||||
disabled={isPaymentBusy}
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
@@ -305,7 +305,6 @@ export function TipScreen({
|
||||
disabled={!canCreateOrder}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
countryCode={userState.currentUser?.countryCode}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
CheckoutHandoffConsumeRequestSchema,
|
||||
CheckoutHandoffCreateRequestSchema,
|
||||
type CheckoutHandoffCreateResponse,
|
||||
type CheckoutIntent,
|
||||
FacebookIdentityRequestSchema,
|
||||
FacebookLoginRequestSchema,
|
||||
FbIdLoginRequestSchema,
|
||||
@@ -276,6 +280,30 @@ 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,6 +13,8 @@
|
||||
|
||||
import type { Result } from "@/utils/result";
|
||||
import type {
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutIntent,
|
||||
GuestLoginResponse,
|
||||
LoginStatus,
|
||||
LoginResponse,
|
||||
@@ -74,6 +76,16 @@ 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>>;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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,6 +3,7 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
@@ -206,8 +206,6 @@ describe("PaymentPlansResponse", () => {
|
||||
expiresAt: "2026-07-24T08:00:00+00:00",
|
||||
triggerReason: "price_objection",
|
||||
message: "I got this private price for you.",
|
||||
scope: "account",
|
||||
stage: "sevenDiscount",
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
@@ -231,8 +229,6 @@ describe("PaymentPlansResponse", () => {
|
||||
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
|
||||
expect(response.supportCharacterId).toBe("maya-tan");
|
||||
expect(response.commercialOffer?.characterId).toBe("maya-tan");
|
||||
expect(response.commercialOffer?.scope).toBe("account");
|
||||
expect(response.commercialOffer?.stage).toBe("sevenDiscount");
|
||||
expect(response.plans[0]).toMatchObject({
|
||||
planId: "vip_annual",
|
||||
commercialOfferId: "offer-1",
|
||||
|
||||
@@ -31,8 +31,6 @@ export const CommercialOfferSummarySchema = z
|
||||
expiresAt: z.string().min(1),
|
||||
triggerReason: stringOrEmpty,
|
||||
message: stringOrEmpty,
|
||||
scope: z.enum(["account", "character"]).optional(),
|
||||
stage: z.enum(["sevenDiscount", "halfDiscount"]).optional(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"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,6 +30,12 @@ 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,6 +7,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CheckoutHandoffConsumeRequest,
|
||||
CheckoutHandoffConsumeResponse,
|
||||
CheckoutHandoffConsumeResponseSchema,
|
||||
CheckoutHandoffCreateRequest,
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutHandoffCreateResponseSchema,
|
||||
FacebookIdentityRequest,
|
||||
FacebookIdentityResponse,
|
||||
FacebookIdentityResponseSchema,
|
||||
@@ -104,6 +110,32 @@ 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 到当前用户
|
||||
*/
|
||||
|
||||
@@ -50,6 +50,27 @@ export class AppStorage {
|
||||
return SpAsyncUtil.setString(StorageKeys.lastAppInfoReported, todayString);
|
||||
}
|
||||
|
||||
// ---- first recharge offer banner ----
|
||||
|
||||
static async getFirstRechargeOfferBannerDismissed(
|
||||
userId: string | null | undefined,
|
||||
): Promise<ResultT<boolean>> {
|
||||
const r = await SpAsyncUtil.getBool(
|
||||
AppStorage.firstRechargeOfferBannerDismissedKey(userId),
|
||||
);
|
||||
if (!r.success) return r;
|
||||
return Result.ok(r.data === true);
|
||||
}
|
||||
|
||||
static recordFirstRechargeOfferBannerDismissed(
|
||||
userId: string | null | undefined,
|
||||
): Promise<ResultT<void>> {
|
||||
return SpAsyncUtil.setBool(
|
||||
AppStorage.firstRechargeOfferBannerDismissedKey(userId),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
/**
|
||||
@@ -68,4 +89,11 @@ export class AppStorage {
|
||||
return Result.ok(r.data === null || r.data !== todayString);
|
||||
}
|
||||
|
||||
private static firstRechargeOfferBannerDismissedKey(
|
||||
userId: string | null | undefined,
|
||||
): string {
|
||||
return `${StorageKeys.firstRechargeOfferBannerDismissed}:${
|
||||
userId || "anonymous"
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const StorageKeys = {
|
||||
pwaDialogShown: "pwa_dialog_shown",
|
||||
lastPwaEventReported: "last_pwa_event_reported",
|
||||
lastAppInfoReported: "last_app_info_reported",
|
||||
firstRechargeOfferBannerDismissed: "first_recharge_offer_banner_dismissed",
|
||||
|
||||
// payment
|
||||
pendingPaymentOrder: "pending_payment_order",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { AppStorage } from "@/data/storage/app/app_storage";
|
||||
|
||||
export async function getFirstRechargeOfferBannerDismissed(
|
||||
userId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
const result = await AppStorage.getFirstRechargeOfferBannerDismissed(userId);
|
||||
return result.success ? result.data : false;
|
||||
}
|
||||
|
||||
export function recordFirstRechargeOfferBannerDismissed(
|
||||
userId: string | null | undefined,
|
||||
): Promise<unknown> {
|
||||
return AppStorage.recordFirstRechargeOfferBannerDismissed(userId);
|
||||
}
|
||||
@@ -3,26 +3,37 @@ import { describe, expect, it } from "vitest";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
|
||||
import {
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
shouldBindExternalFacebookIdentity,
|
||||
} from "../external_entry";
|
||||
|
||||
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("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",
|
||||
}),
|
||||
).toBe(
|
||||
"/subscription?planId=vip_monthly&autoRenew=1&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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("external entry navigation", () => {
|
||||
it("defaults to chat", () => {
|
||||
@@ -77,26 +88,6 @@ 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,8 +4,10 @@ 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";
|
||||
@@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({
|
||||
return routes.chat;
|
||||
}
|
||||
|
||||
export function resolveCheckoutIntentDestination(
|
||||
intent: CheckoutIntent,
|
||||
): 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");
|
||||
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,
|
||||
@@ -138,6 +164,7 @@ 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,
|
||||
getPaymentUrlHostname,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -44,103 +44,58 @@ describe("payment launch helpers", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritizes a hosted GCash URL for Philippine checkout", () => {
|
||||
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", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "URL",
|
||||
payData: "https://pay.example/gcash",
|
||||
}, { countryCode: "PH", currency: "PHP" }),
|
||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
|
||||
|
||||
payData: "https://pay.example/qris",
|
||||
}),
|
||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
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" },
|
||||
),
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
}),
|
||||
).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: "",
|
||||
},
|
||||
{ countryCode: "PH" },
|
||||
),
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage:
|
||||
"GCash / QR Ph payment data is missing. Please try again.",
|
||||
errorMessage: "QRIS 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" }),
|
||||
|
||||
@@ -13,20 +13,9 @@ const log = new Logger("LibPaymentPaymentLaunch");
|
||||
|
||||
export type EzpayLaunchTarget =
|
||||
| { kind: "url"; paymentUrl: string }
|
||||
| {
|
||||
kind: "qr";
|
||||
experience: EzpayQrExperience;
|
||||
qrData: string;
|
||||
}
|
||||
| { kind: "qr"; 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[]
|
||||
@@ -52,12 +41,6 @@ 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",
|
||||
@@ -88,7 +71,6 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
||||
|
||||
export function resolveEzpayLaunchTarget(
|
||||
payParams: Record<string, unknown>,
|
||||
context: ResolveEzpayLaunchContext = {},
|
||||
): EzpayLaunchTarget {
|
||||
const rawChannelType = getNonEmptyString(
|
||||
payParams,
|
||||
@@ -97,33 +79,13 @@ 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") {
|
||||
if (qrData) {
|
||||
return {
|
||||
kind: "qr",
|
||||
experience: resolveEzpayQrExperience(region),
|
||||
qrData,
|
||||
};
|
||||
}
|
||||
return paymentUrl
|
||||
? { kind: "url", paymentUrl }
|
||||
return payData
|
||||
? { kind: "qr", qrData: payData }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage: regionalQrMissingMessage(region),
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +97,8 @@ export function resolveEzpayLaunchTarget(
|
||||
}
|
||||
|
||||
if (channelType === "URL") {
|
||||
const paymentUrl =
|
||||
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return paymentUrl
|
||||
? { kind: "url", paymentUrl }
|
||||
: {
|
||||
@@ -150,66 +114,14 @@ export function resolveEzpayLaunchTarget(
|
||||
};
|
||||
}
|
||||
|
||||
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.";
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
export function getStripeClientSecret(
|
||||
@@ -231,6 +143,27 @@ 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;
|
||||
@@ -254,19 +187,18 @@ export async function launchEzpayRedirect({
|
||||
onOpened,
|
||||
onFailed,
|
||||
}: LaunchEzpayRedirectInput): Promise<void> {
|
||||
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
|
||||
log.debug("[payment-launch] launchEzpayRedirect START", {
|
||||
hasOrderId: Boolean(orderId),
|
||||
orderId,
|
||||
subscriptionType,
|
||||
paymentUrlHost,
|
||||
paymentUrl,
|
||||
});
|
||||
|
||||
if (!orderId) {
|
||||
const errorMessage = "Missing order id before opening Ezpay.";
|
||||
log.error("[payment-launch] pending ezpay order save skipped", {
|
||||
subscriptionType,
|
||||
paymentUrlHost,
|
||||
paymentUrl,
|
||||
errorMessage,
|
||||
});
|
||||
onFailed(errorMessage);
|
||||
|
||||
@@ -22,6 +22,15 @@ 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,7 +8,6 @@ import {
|
||||
|
||||
import {
|
||||
createTestPaymentMachine,
|
||||
giftCatalog,
|
||||
lifetimePlan,
|
||||
monthlyPlan,
|
||||
quarterlyPlan,
|
||||
@@ -51,7 +50,7 @@ describe("payment catalog flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("restores a valid first-category product and rejects another category", async () => {
|
||||
it("restores a valid tip product across categories from checkout handoff", async () => {
|
||||
const actor = createActor(createTestPaymentMachine()).start();
|
||||
actor.send({
|
||||
type: "PaymentInit",
|
||||
@@ -79,8 +78,9 @@ describe("payment catalog flow", () => {
|
||||
snapshot.context.requestedGiftPlanId === null,
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||
giftCatalog.plans[0]?.planId,
|
||||
"tip_flowers_usd_12_99",
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -105,28 +105,25 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it.each(["elio", "maya-tan", "nayeli-cervantes"])(
|
||||
"binds a default VIP or credit order to the source chat character %s",
|
||||
async (characterId) => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ createOrderSpy }),
|
||||
).start();
|
||||
actor.send({ type: "PaymentInit", characterId });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
it("binds a default VIP or credit order to the selected support character", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ createOrderSpy }),
|
||||
).start();
|
||||
actor.send({ type: "PaymentInit", characterId: "maya-tan" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
expect(createOrderSpy).toHaveBeenCalledWith({
|
||||
planId: "vip_monthly",
|
||||
payChannel: "stripe",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: characterId,
|
||||
});
|
||||
actor.stop();
|
||||
},
|
||||
);
|
||||
expect(createOrderSpy).toHaveBeenCalledWith({
|
||||
planId: "vip_monthly",
|
||||
payChannel: "stripe",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "maya-tan",
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("carries the originating chat action into order creation", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
@@ -301,37 +298,6 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("can abandon a pending QRIS order and switch to Stripe", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ createOrderSpy, orderStatus: "pending" }),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" });
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
|
||||
|
||||
actor.send({ type: "PaymentPayChannelChanged", payChannel: "stripe" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
payChannel: "stripe",
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
orderPollingStartedAt: null,
|
||||
});
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
|
||||
expect(createOrderSpy).toHaveBeenNthCalledWith(2, {
|
||||
planId: "vip_monthly",
|
||||
payChannel: "stripe",
|
||||
autoRenew: true,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("moves to failed when polling reports failure", async () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ orderStatus: "failed" }),
|
||||
|
||||
@@ -50,15 +50,23 @@ export function hydrateGiftProductsState(
|
||||
const giftProducts = response.plans.filter(
|
||||
(product) => product.characterId === characterId,
|
||||
);
|
||||
const selectedGiftCategory = giftCategories[0]?.category ?? null;
|
||||
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 visibleProducts = selectedGiftCategory
|
||||
? giftProducts.filter(
|
||||
(product) => product.category === selectedGiftCategory,
|
||||
)
|
||||
: [];
|
||||
const canRestoreSelection =
|
||||
restoredCategory === selectedGiftCategory &&
|
||||
visibleProducts.some((product) => product.planId === restoredPlanId);
|
||||
const canRestoreSelection = visibleProducts.some(
|
||||
(product) => product.planId === restoredPlanId,
|
||||
);
|
||||
const selectedPlanId = canRestoreSelection
|
||||
? (restoredPlanId ?? "")
|
||||
: (visibleProducts[0]?.planId ?? "");
|
||||
|
||||
@@ -38,14 +38,18 @@ function initializeCatalogState(
|
||||
giftCharacterId !== context.giftCharacterId ||
|
||||
supportCharacterId !== context.supportCharacterId;
|
||||
const selectionChanged =
|
||||
planCatalog === "tip" &&
|
||||
((event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null));
|
||||
planCatalog === "tip"
|
||||
? (event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null)
|
||||
: (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,
|
||||
@@ -58,7 +62,12 @@ function initializeCatalogState(
|
||||
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||
requestedGiftPlanId:
|
||||
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
|
||||
...(catalogChanged ||
|
||||
characterChanged ||
|
||||
selectionChanged ||
|
||||
offerChanged ||
|
||||
chatActionChanged ||
|
||||
autoRenewChanged
|
||||
? {
|
||||
...resetOrderState(),
|
||||
plans: [],
|
||||
@@ -69,7 +78,7 @@ function initializeCatalogState(
|
||||
planCatalog === "default" ? (event.planId ?? "") : "",
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
autoRenew: planCatalog !== "tip",
|
||||
autoRenew: initialAutoRenew,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -265,10 +265,6 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({
|
||||
},
|
||||
},
|
||||
on: {
|
||||
PaymentPayChannelChanged: {
|
||||
target: "ready",
|
||||
actions: "changePayChannel",
|
||||
},
|
||||
PaymentReset: {
|
||||
target: "ready",
|
||||
actions: "resetOrder",
|
||||
@@ -281,10 +277,6 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({
|
||||
[POLL_DELAY_MS]: "pollingOrder",
|
||||
},
|
||||
on: {
|
||||
PaymentPayChannelChanged: {
|
||||
target: "ready",
|
||||
actions: "changePayChannel",
|
||||
},
|
||||
PaymentReset: {
|
||||
target: "ready",
|
||||
actions: "resetOrder",
|
||||
|
||||
@@ -20,6 +20,8 @@ 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;
|
||||
@@ -76,6 +78,8 @@ 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,6 +10,7 @@ export type PaymentEvent =
|
||||
characterId?: string;
|
||||
category?: string | null;
|
||||
planId?: string | null;
|
||||
autoRenew?: boolean;
|
||||
commercialOfferId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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]");
|
||||
});
|
||||
});
|
||||
+49
-16
@@ -107,9 +107,13 @@ export class Logger {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
return JSON.stringify(
|
||||
Logger.toSerializableLogValue(JSON.parse(trimmed)),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch {
|
||||
return value;
|
||||
return Logger.redactSensitiveString(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,28 +153,31 @@ export class Logger {
|
||||
}
|
||||
|
||||
private write(level: LogLevel, args: LogArgs): void {
|
||||
Logger.reportImportantProductionLog(level, this.component, args);
|
||||
const safeArgs = args.map((value) =>
|
||||
Logger.toSerializableLogValue(value),
|
||||
);
|
||||
Logger.reportImportantProductionLog(level, this.component, safeArgs);
|
||||
|
||||
if (Logger.shouldUseBrowserConsole()) {
|
||||
Logger.writeBrowserConsole(level, this.component, args);
|
||||
Logger.writeBrowserConsole(level, this.component, safeArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
this.logger.debug(...Logger.normalizeLogArgs(args));
|
||||
this.logger.debug(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "info":
|
||||
this.logger.info(...Logger.normalizeLogArgs(args));
|
||||
this.logger.info(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "warn":
|
||||
this.logger.warn(...Logger.normalizeLogArgs(args));
|
||||
this.logger.warn(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "error":
|
||||
this.logger.error(...Logger.normalizeLogArgs(args));
|
||||
this.logger.error(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "fatal":
|
||||
this.logger.fatal(...Logger.normalizeLogArgs(args));
|
||||
this.logger.fatal(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -266,6 +273,10 @@ 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;
|
||||
}
|
||||
@@ -281,19 +292,39 @@ export class Logger {
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
output[key] = Logger.toSerializableLogValue(item, seen);
|
||||
output[key] = Logger.isSensitiveLogKey(key)
|
||||
? "[REDACTED]"
|
||||
: 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: error.message,
|
||||
stack: error.stack,
|
||||
message: Logger.redactSensitiveString(error.message),
|
||||
stack: error.stack
|
||||
? Logger.redactSensitiveString(error.stack)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (seen.has(error)) {
|
||||
@@ -303,10 +334,12 @@ export class Logger {
|
||||
|
||||
for (const key of Object.getOwnPropertyNames(error)) {
|
||||
if (key in output) continue;
|
||||
output[key] = Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
output[key] = Logger.isSensitiveLogKey(key)
|
||||
? "[REDACTED]"
|
||||
: Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user