Compare commits
129 Commits
b6fdc912ae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aeebd7f704 | |||
| 089259e5e0 | |||
| 4bfede7dfd | |||
| 47c5f4098f | |||
| 196f9e39dc | |||
| 9121b50e02 | |||
| e43912eebf | |||
| 38ae06fe04 | |||
| 995fdbda57 | |||
| ed822c159f | |||
| d5b7a1f36c | |||
| 9f829bbc0e | |||
| a2cf62f78b | |||
| 67f292353e | |||
| 8c0d1ad3ce | |||
| 019caae598 | |||
| 73e6f341fb | |||
| d01441b8c7 | |||
| ddacd03601 | |||
| 1b121a8ef8 | |||
| b7221f2f70 | |||
| 30f88d3a20 | |||
| 7b09a7f850 | |||
| 38a4645b9c | |||
| 76bffc1a0d | |||
| 19b8fc51d6 | |||
| 9fbf180df6 | |||
| 6721b6eb43 | |||
| d6f6bc2d87 | |||
| b34d3a3a67 | |||
| 9602fdd94d | |||
| bb1f0d225c | |||
| 0d5b5c17fa | |||
| 30ab2c2c97 | |||
| 17236bd14e | |||
| 64ba720121 | |||
| b04ef58855 | |||
| 5e0361a199 | |||
| 8660fe7b7e | |||
| a20a333665 | |||
| 92768047e9 | |||
| 606e6d60ff | |||
| c659c5fc3f | |||
| 537a0a2c36 | |||
| b1f52c68e8 | |||
| 3c7b0c30e0 | |||
| 469512df18 | |||
| 318e4991be | |||
| 2d432e5367 | |||
| 4dae805a88 | |||
| 02f6964484 | |||
| 79397af739 | |||
| d6f104ee3f | |||
| 9ca56c2a04 | |||
| 4639acf232 | |||
| 770ce6b8fd | |||
| 0357fbcaff | |||
| 30122a44db | |||
| 2bb3da829e | |||
| 163ba78f06 | |||
| fd2291fa62 | |||
| 4e71dbd5f8 | |||
| bdd53a6ea1 | |||
| 36e5dd3c96 | |||
| 40184c7631 | |||
| fb9e30cfd1 | |||
| 7103652829 | |||
| 4fceab537d | |||
| dff112eadc | |||
| e1454cd002 | |||
| 437ac3460b | |||
| 1e25279e8f | |||
| 35939e7681 | |||
| a0b2d44ec6 | |||
| a39d3c25ce | |||
| ed3b34ce1c | |||
| a317a46e87 | |||
| e813333607 | |||
| b531b02a85 | |||
| 8bd67b83c5 | |||
| 91e4a074c8 | |||
| adfb230908 | |||
| 291f268cce | |||
| 06af88dc44 | |||
| 56ee6f7b96 | |||
| eec4aa2c9f | |||
| ca58e53beb | |||
| a9cdf31d28 | |||
| 9a51acd636 | |||
| 3e3d0ced85 | |||
| 7f271ef2e0 | |||
| 9fff5b16fd | |||
| 9deb320cf6 | |||
| 6cd3a0c2d2 | |||
| fe954f7098 | |||
| 3d3f7e725b | |||
| efd3ea84d8 | |||
| 36d8fcf2c3 | |||
| 0381090867 | |||
| 612c4139af | |||
| fa08cc114f | |||
| a990f5082b | |||
| 37ff69020b | |||
| 55cb98ed14 | |||
| e512a42483 | |||
| 7789673fff | |||
| 17e5656f53 | |||
| 8d6fe1e550 | |||
| 9f176272c1 | |||
| edf50e9cc4 | |||
| c187f0b817 | |||
| 1e13f94b5d | |||
| 159e8bfd59 | |||
| 10132652c7 | |||
| 8580042c26 | |||
| 3a97b0436e | |||
| f1b81ef764 | |||
| f44db8d8b2 | |||
| 9920f97029 | |||
| 9b90f5c83d | |||
| 1f8bbe9022 | |||
| bc6373f4c0 | |||
| 7967486af7 | |||
| b2d10bfba8 | |||
| bfe48439b3 | |||
| 4d1c85727a | |||
| 1f7ab2be04 | |||
| b216b53f2e | |||
| cf67b06fb9 |
@@ -4,43 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- test
|
- pre
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
|
||||||
name: Quality and Bundle Budgets
|
|
||||||
runs-on: gitea-label
|
|
||||||
container:
|
|
||||||
image: cozsweet-act-runner-node24-docker:latest
|
|
||||||
env:
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
|
||||||
SENTRY_UPLOAD_SOURCEMAPS: "0"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Skip quality gate for test branch
|
|
||||||
if: ${{ github.ref == 'refs/heads/test' }}
|
|
||||||
run: echo "Quality gate is disabled for the test branch."
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
if: ${{ github.ref != 'refs/heads/test' }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: ${{ github.ref != 'refs/heads/test' }}
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run quality checks
|
|
||||||
if: ${{ github.ref != 'refs/heads/test' }}
|
|
||||||
run: pnpm quality
|
|
||||||
|
|
||||||
- name: Enforce bundle budgets
|
|
||||||
if: ${{ github.ref != 'refs/heads/test' }}
|
|
||||||
run: pnpm perf:bundle:check
|
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
needs: quality
|
|
||||||
runs-on: gitea-label
|
runs-on: gitea-label
|
||||||
container:
|
container:
|
||||||
image: cozsweet-act-runner-node24-docker:latest
|
image: cozsweet-act-runner-node24-docker:latest
|
||||||
@@ -79,7 +48,7 @@ jobs:
|
|||||||
DEPLOY_ENV="prod"
|
DEPLOY_ENV="prod"
|
||||||
NEXT_ENV_FILE=".env.production"
|
NEXT_ENV_FILE=".env.production"
|
||||||
;;
|
;;
|
||||||
test)
|
pre)
|
||||||
DEPLOY_ENV="test"
|
DEPLOY_ENV="test"
|
||||||
NEXT_ENV_FILE=".env.local"
|
NEXT_ENV_FILE=".env.local"
|
||||||
;;
|
;;
|
||||||
@@ -183,12 +152,9 @@ jobs:
|
|||||||
- name: Deploy image over SSH
|
- name: Deploy image over SSH
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
TEST_DEPLOY_DIR: ${{ secrets.TEST_DEPLOY_DIR }}
|
|
||||||
PROD_DEPLOY_DIR: ${{ secrets.PROD_DEPLOY_DIR }}
|
|
||||||
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
|
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
@@ -197,16 +163,18 @@ jobs:
|
|||||||
|
|
||||||
case "$DEPLOY_ENV" in
|
case "$DEPLOY_ENV" in
|
||||||
test)
|
test)
|
||||||
DEPLOY_DIR="${TEST_DEPLOY_DIR:-/opt/cozsweet-web-test}"
|
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test"
|
||||||
HOST_PORT="9135"
|
HOST_PORT="9135"
|
||||||
CONTAINER_NAME="cozsweet-web-test"
|
CONTAINER_NAME="cozsweet-web-test"
|
||||||
PROJECT_NAME="cozsweet-web-test"
|
PROJECT_NAME="cozsweet-web-test"
|
||||||
|
SERVER_RETAIN_COUNT="1"
|
||||||
;;
|
;;
|
||||||
prod)
|
prod)
|
||||||
DEPLOY_DIR="${PROD_DEPLOY_DIR:-/opt/cozsweet-web-prod}"
|
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-prod"
|
||||||
HOST_PORT="9185"
|
HOST_PORT="9185"
|
||||||
CONTAINER_NAME="cozsweet-web-prod"
|
CONTAINER_NAME="cozsweet-web-prod"
|
||||||
PROJECT_NAME="cozsweet-web-prod"
|
PROJECT_NAME="cozsweet-web-prod"
|
||||||
|
SERVER_RETAIN_COUNT="2"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unsupported deploy env: $DEPLOY_ENV" >&2
|
echo "Unsupported deploy env: $DEPLOY_ENV" >&2
|
||||||
@@ -214,8 +182,11 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
DEPLOY_DIR="$DEPLOY_ROOT/current"
|
||||||
|
DEPLOY_SHARED_DIR="$DEPLOY_ROOT/shared"
|
||||||
|
REMOTE_ENV_FILE="$DEPLOY_SHARED_DIR/$NEXT_ENV_FILE"
|
||||||
DEPLOY_HOST="$SSH_HOST"
|
DEPLOY_HOST="$SSH_HOST"
|
||||||
DEPLOY_USER="$SSH_USER"
|
DEPLOY_USER="lzf"
|
||||||
DEPLOY_PORT="${SSH_PORT:-22}"
|
DEPLOY_PORT="${SSH_PORT:-22}"
|
||||||
DEPLOY_KEY="$SSH_PRIVATE_KEY"
|
DEPLOY_KEY="$SSH_PRIVATE_KEY"
|
||||||
DEPLOY_KNOWN_HOSTS="$SSH_KNOWN_HOSTS"
|
DEPLOY_KNOWN_HOSTS="$SSH_KNOWN_HOSTS"
|
||||||
@@ -241,19 +212,19 @@ jobs:
|
|||||||
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes"
|
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes"
|
||||||
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes"
|
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
ssh $SSH_OPTS "$SSH_TARGET" "mkdir -p '$DEPLOY_DIR'"
|
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
|
"mkdir -p '$DEPLOY_DIR' '$DEPLOY_SHARED_DIR' '$DEPLOY_ROOT/releases' '$DEPLOY_ROOT/backups' '$DEPLOY_ROOT/tmp' && chmod 700 '$DEPLOY_SHARED_DIR'"
|
||||||
scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml"
|
scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml"
|
||||||
scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh"
|
scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh"
|
||||||
scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$DEPLOY_DIR/$NEXT_ENV_FILE"
|
scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_FILE"
|
||||||
|
ssh $SSH_OPTS "$SSH_TARGET" "chmod 600 '$REMOTE_ENV_FILE'"
|
||||||
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" \
|
printf '%s' "$REGISTRY_PASSWORD" \
|
||||||
| ssh $SSH_OPTS "$SSH_TARGET" \
|
| ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
"docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin"
|
"docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin"
|
||||||
|
|
||||||
SERVER_RETAIN_COUNT="1"
|
|
||||||
|
|
||||||
ssh $SSH_OPTS "$SSH_TARGET" \
|
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
"cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
|
"cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$REMOTE_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
|
||||||
|
|
||||||
- name: Purge Cloudflare cache
|
- name: Purge Cloudflare cache
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ copy_env_by_branch() {
|
|||||||
START_PORT=9185
|
START_PORT=9185
|
||||||
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
test)
|
pre)
|
||||||
cp -f env-example/.env.local.example .env.local
|
cp -f env-example/.env.local.example .env.local
|
||||||
ENV_FILE=".env.local"
|
ENV_FILE=".env.local"
|
||||||
DEPLOY_ENV="test"
|
DEPLOY_ENV="test"
|
||||||
START_PORT=9135
|
START_PORT=9135
|
||||||
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
cp -f env-example/.env.development.example .env.local
|
cp -f env-example/.env.development.example .env.local
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ ENV PNPM_HOME=/pnpm
|
|||||||
ENV PNPM_STORE_PATH=/pnpm/store
|
ENV PNPM_STORE_PATH=/pnpm/store
|
||||||
ENV PATH=$PNPM_HOME:$PATH
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
|
||||||
|
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat \
|
RUN apk add --no-cache libc6-compat \
|
||||||
&& corepack enable \
|
&& npm install --global "pnpm@10.30.3" --registry="$NPM_REGISTRY" \
|
||||||
&& corepack prepare pnpm@10.30.3 --activate \
|
&& pnpm config set store-dir "$PNPM_STORE_PATH" \
|
||||||
&& pnpm config set store-dir "$PNPM_STORE_PATH"
|
&& pnpm config set registry "$NPM_REGISTRY"
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# 虚拟礼物商品与付款后文案 API
|
||||||
|
|
||||||
|
## 一、用途
|
||||||
|
|
||||||
|
前端按当前角色一次获取全部启用的礼物品类和商品,在本地完成品类切换与商品筛选。页面不得写死咖啡、鲜花、服装等品类,也不得根据 `planId` 推算名称、图片或价格。付款成功后,前端使用订单号获取稳定的角色感谢文案。
|
||||||
|
|
||||||
|
## 三、推荐接入流程
|
||||||
|
|
||||||
|
1. 当前角色确定后,只请求一次 `GET /api/payment/gift-products?characterId=<CHARACTER_ID>`。
|
||||||
|
2. 使用 `data.categories` 渲染品类栏,使用 `data.plans` 保存完整商品目录。
|
||||||
|
3. 用户切换品类时只在前端按 `product.category` 本地筛选,不再请求后端。
|
||||||
|
4. 使用商品自身的 `planName`、`description`、`imageUrl`、`amountCents` 和 `currency` 渲染通用商品卡片。
|
||||||
|
5. 用户选择商品后,使用该商品原始 `planId` 创建订单。
|
||||||
|
6. 用户切换商品时清除旧 `orderId`、`clientSecret` 和支付组件状态,再创建新订单。
|
||||||
|
7. Stripe 使用本次订单返回的 `payParams.clientSecret` 挂载 Payment Element。
|
||||||
|
8. 每 3 至 5 秒轮询订单状态,直到状态变成 `paid`、`failed` 或 `expired`。
|
||||||
|
9. 状态为 `paid` 后调用 `POST /api/payment/tip-message`,展示 `data.message`。
|
||||||
|
|
||||||
|
> `amountCents`、`currency` 和 `planId` 必须来自同一条商品数据。后端下单时会再次按 `planId` 校验真实价格,前端展示值不得作为收费依据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、一次获取完整礼物目录
|
||||||
|
|
||||||
|
### 4.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/payment/gift-products?characterId=elio
|
||||||
|
```
|
||||||
|
|
||||||
|
- 完整地址:`https://proapi.banlv-ai.com/api/payment/gift-products`
|
||||||
|
- 兼容别名:`GET /api/payment/tip-plans`
|
||||||
|
- 登录鉴权:不需要
|
||||||
|
- 请求格式:Query String
|
||||||
|
|
||||||
|
### 4.2 查询参数
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `characterId` | string | 前端必传 | 否 | `elio` | 当前收礼角色 ID。后端仍兼容省略,但多角色前端不得省略。 |
|
||||||
|
| `category` | string | 否 | 否 | `coffee` | 兼容旧的按品类懒加载方式。新版前端首屏不传此字段。 |
|
||||||
|
|
||||||
|
### 4.3 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://proapi.banlv-ai.com/api/payment/gift-products?characterId=elio'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"characterId": "elio",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"category": "coffee",
|
||||||
|
"name": "Coffee",
|
||||||
|
"productCount": 3,
|
||||||
|
"imageUrl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "flowers",
|
||||||
|
"name": "Flowers",
|
||||||
|
"productCount": 2,
|
||||||
|
"imageUrl": "https://example.com/flowers.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plans": [
|
||||||
|
{
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"planName": "Velvet Espresso",
|
||||||
|
"orderType": "tip",
|
||||||
|
"tipType": "coffee_small",
|
||||||
|
"category": "coffee",
|
||||||
|
"characterId": "elio",
|
||||||
|
"description": "Buy Elio a small coffee",
|
||||||
|
"imageUrl": null,
|
||||||
|
"amountCents": 499,
|
||||||
|
"currency": "USD",
|
||||||
|
"autoRenew": false,
|
||||||
|
"isFirstRechargeOffer": false,
|
||||||
|
"firstRechargeDiscountPercent": 0,
|
||||||
|
"promotionType": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `data.characterId` | string | 是 | 本次查询的角色;省略请求参数时为 `null`。 |
|
||||||
|
| `data.categories` | array | 否 | 当前结果中的品类,顺序与 Manager 商品排序一致。 |
|
||||||
|
| `categories[].category` | string | 否 | 稳定品类值,只用于关联和筛选。 |
|
||||||
|
| `categories[].name` | string | 否 | 品类展示名称。 |
|
||||||
|
| `categories[].productCount` | number(整数) | 否 | 当前品类中的启用商品数量。 |
|
||||||
|
| `categories[].imageUrl` | string | 是 | 该品类第一张可用商品图;没有图片时为 `null`。 |
|
||||||
|
| `data.plans` | array | 否 | 当前角色全部启用商品;没有商品时为空数组。 |
|
||||||
|
| `plans[].planId` | string | 否 | 商品唯一标识,创建订单时必须原样传回。 |
|
||||||
|
| `plans[].planName` | string | 否 | 商品展示名称。 |
|
||||||
|
| `plans[].orderType` | string enum | 否 | 礼物固定为 `tip`。 |
|
||||||
|
| `plans[].tipType` | string | 否 | 支付成功事件使用的礼物细分类型。 |
|
||||||
|
| `plans[].category` | string | 否 | 所属品类,与 `categories[].category` 对应。 |
|
||||||
|
| `plans[].characterId` | string | 否 | 收礼角色 ID。 |
|
||||||
|
| `plans[].description` | string | 否 | 商品说明,可能为空字符串。 |
|
||||||
|
| `plans[].imageUrl` | string | 是 | 完整公开图片 URL;没有图片时为 `null`。 |
|
||||||
|
| `plans[].amountCents` | number(整数) | 否 | 展示金额的百分之一;USD 下 `499` 表示 `$4.99`。 |
|
||||||
|
| `plans[].currency` | string | 否 | 三位大写币种代码,例如 `USD`。 |
|
||||||
|
| `plans[].autoRenew` | boolean | 否 | 礼物固定为 `false`。 |
|
||||||
|
| `plans[].isFirstRechargeOffer` | boolean | 否 | 礼物固定为 `false`。 |
|
||||||
|
| `plans[].firstRechargeDiscountPercent` | number(整数) | 否 | 礼物固定为 `0`。 |
|
||||||
|
| `plans[].promotionType` | string | 是 | 礼物没有促销时为 `null`。 |
|
||||||
|
|
||||||
|
## 五、前端通用品类与商品显示逻辑
|
||||||
|
|
||||||
|
### 5.1 建议类型
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GiftCategory {
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
productCount: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiftProduct {
|
||||||
|
planId: string;
|
||||||
|
planName: string;
|
||||||
|
orderType: "tip";
|
||||||
|
tipType: string;
|
||||||
|
category: string;
|
||||||
|
characterId: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
amountCents: number;
|
||||||
|
currency: string;
|
||||||
|
autoRenew: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiftCatalog {
|
||||||
|
characterId: string | null;
|
||||||
|
categories: GiftCategory[];
|
||||||
|
plans: GiftProduct[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 状态和筛选
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [catalog, setCatalog] = useState<GiftCatalog | null>(null);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const visibleProducts = (catalog?.plans ?? []).filter(
|
||||||
|
product => product.category === selectedCategory,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- 角色变化时重新请求,并以 `characterId` 作为缓存键,不能复用另一个角色的目录。
|
||||||
|
- 请求完成后默认选择 `categories[0].category`;深链指定的品类存在时优先选择深链值。
|
||||||
|
- 切换品类只更新 `selectedCategory`,不请求第二个接口。
|
||||||
|
- 只有一个品类时可以隐藏品类切换栏。
|
||||||
|
- 新增未知品类时仍使用同一套通用商品卡片,禁止编写 `if (category === "coffee")` 一类业务分支。
|
||||||
|
- 商品图片优先级:`product.imageUrl` → 当前 `category.imageUrl` → 通用礼物占位图。
|
||||||
|
- 商品名称和说明使用 `planName`、`description`;不要使用前端本地咖啡常量覆盖。
|
||||||
|
- 价格使用 `Intl.NumberFormat`,金额为 `amountCents / 100`。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const displayPrice = new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: product.currency,
|
||||||
|
}).format(product.amountCents / 100);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 页面状态
|
||||||
|
|
||||||
|
| 状态 | 前端行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 加载中 | 显示稳定尺寸的商品骨架,不显示旧角色缓存。 |
|
||||||
|
| `categories=[]`、`plans=[]` | 显示“该角色暂时没有可用礼物”,不要回退到写死咖啡。 |
|
||||||
|
| 某品类没有商品 | 自动切换到第一个有商品的品类;全部为空时显示空状态。 |
|
||||||
|
| `imageUrl=null` | 使用品类图或通用占位图。 |
|
||||||
|
| 请求失败 | 显示重试操作,不使用可能过期的价格创建订单。 |
|
||||||
|
| 角色切换 | 取消旧请求,清空品类、商品、选中商品、订单和 Stripe 状态。 |
|
||||||
|
|
||||||
|
### 5.4 兼容接口
|
||||||
|
|
||||||
|
- `GET /api/payment/gift-categories` 保留给只需要品类的旧客户端,新版前端主流程不调用。
|
||||||
|
- `GET /api/payment/gift-products?characterId=elio&category=coffee` 仍支持按品类过滤,适用于未来商品数很大时懒加载。
|
||||||
|
- `GET /api/payment/tip-plans` 与 `/gift-products` 返回同一协议,旧前端继续读取 `data.plans` 不受影响。
|
||||||
|
- 新增的 `data.characterId` 和 `data.categories` 是向后兼容字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、创建礼物支付订单
|
||||||
|
|
||||||
|
### 6.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payment/create-order
|
||||||
|
```
|
||||||
|
|
||||||
|
- 完整地址:`https://proapi.banlv-ai.com/api/payment/create-order`
|
||||||
|
- 登录鉴权:需要
|
||||||
|
- Header:`Authorization: Bearer <USER_TOKEN>`
|
||||||
|
- Content-Type:`application/json`
|
||||||
|
|
||||||
|
### 6.2 请求字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `planId` | string | 是 | 否 | `tip_coffee_usd_4_99` | 必须使用本次商品接口返回的原始值。 |
|
||||||
|
| `payChannel` | enum string | 是 | 否 | `stripe` | 可选值:`stripe`、`ezpay`。 |
|
||||||
|
| `autoRenew` | boolean | 是 | 否 | `false` | 礼物是一次性付款,固定发送 `false`。 |
|
||||||
|
| `recipientCharacterId` | string | 否 | 否 | `elio` | 可省略;省略时使用商品所属角色。传入其他角色会被拒绝。 |
|
||||||
|
|
||||||
|
### 6.3 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://proapi.banlv-ai.com/api/payment/create-order' \
|
||||||
|
-H 'Authorization: Bearer <USER_TOKEN>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"payChannel": "stripe",
|
||||||
|
"autoRenew": false,
|
||||||
|
"recipientCharacterId": "elio"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Stripe 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"orderId": "pay_xxx",
|
||||||
|
"payParams": {
|
||||||
|
"clientSecret": "<STRIPE_CLIENT_SECRET>",
|
||||||
|
"provider": "stripe",
|
||||||
|
"automaticRenewal": false,
|
||||||
|
"firstChargeAmountCents": 499,
|
||||||
|
"renewalAmountCents": null
|
||||||
|
},
|
||||||
|
"expiresAt": "2026-07-20T10:30:00+00:00",
|
||||||
|
"expiresInSeconds": 1800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Stripe 前端处理要求
|
||||||
|
|
||||||
|
- 使用 `data.payParams.clientSecret` 挂载 Stripe Payment Element。
|
||||||
|
- `firstChargeAmountCents` 应与所选商品的 `amountCents` 一致,可用于提交支付前的防错校验。
|
||||||
|
- 用户切换商品或重新创建订单后,必须销毁旧 Payment Element,并使用新的 `orderId + clientSecret` 重新挂载。
|
||||||
|
- 不要复用上一件商品的 `clientSecret`,否则页面商品名称、显示价格和实际 PaymentIntent 可能不属于同一订单。
|
||||||
|
- 礼物不会增加积分或 VIP,不参加首充折扣,也不会自动续费。
|
||||||
|
|
||||||
|
EzPay 响应可能在 `payParams` 中返回 `cashierUrl` 或 `payData`,前端按对应支付渠道处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、轮询订单状态
|
||||||
|
|
||||||
|
### 7.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/payment/order-status?order_id=<ORDER_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 完整地址:`https://proapi.banlv-ai.com/api/payment/order-status`
|
||||||
|
- 登录鉴权:需要,必须与创建订单的用户一致
|
||||||
|
- Header:`Authorization: Bearer <USER_TOKEN>`
|
||||||
|
- 查询字段兼容性说明:当前字段名是 `order_id`,不是 `orderId`
|
||||||
|
|
||||||
|
### 7.2 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://proapi.banlv-ai.com/api/payment/order-status?order_id=<ORDER_ID>' \
|
||||||
|
-H 'Authorization: Bearer <USER_TOKEN>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 关键响应字段
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"orderId": "pay_xxx",
|
||||||
|
"status": "paid",
|
||||||
|
"orderType": "tip",
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"creditsAdded": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `orderId` | string | 否 | 当前订单号。 |
|
||||||
|
| `status` | enum string | 否 | `pending`、`paid`、`failed` 或 `expired`。 |
|
||||||
|
| `orderType` | string | 否 | 礼物订单为 `tip`。 |
|
||||||
|
| `planId` | string | 是 | 当前订单对应的商品 `planId`。 |
|
||||||
|
| `creditsAdded` | number(整数) | 否 | 礼物订单固定为 `0`。 |
|
||||||
|
|
||||||
|
建议每 3 至 5 秒轮询一次;出现 `paid`、`failed` 或 `expired` 后立即停止。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、获取付款后感谢文案
|
||||||
|
|
||||||
|
### 8.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payment/tip-message
|
||||||
|
```
|
||||||
|
|
||||||
|
仅在订单状态已经变为 `paid` 后调用。
|
||||||
|
|
||||||
|
- 完整地址:`https://proapi.banlv-ai.com/api/payment/tip-message`
|
||||||
|
- 登录鉴权:需要,必须是订单所属用户
|
||||||
|
- Header:`Authorization: Bearer <USER_TOKEN>`
|
||||||
|
- Content-Type:`application/json`
|
||||||
|
|
||||||
|
### 8.2 请求字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `orderId` | string | 是 | 否 | `pay_xxx` | 当前用户已经支付成功的礼物订单号。 |
|
||||||
|
|
||||||
|
### 8.3 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://proapi.banlv-ai.com/api/payment/tip-message' \
|
||||||
|
-H 'Authorization: Bearer <USER_TOKEN>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"orderId":"<PAID_ORDER_ID>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"orderId": "pay_xxx",
|
||||||
|
"characterId": "elio",
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"productName": "Velvet Espresso",
|
||||||
|
"tipCount": 1,
|
||||||
|
"poolIndex": 37,
|
||||||
|
"message": "This is the 1st time you've sent me \"Velvet Espresso\". You have a knack for making me smile."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `orderId` | string | 否 | 已支付订单号。 |
|
||||||
|
| `characterId` | string | 否 | 收礼角色 ID。 |
|
||||||
|
| `planId` | string | 否 | 本次实际支付商品的 `planId`。 |
|
||||||
|
| `productName` | string | 否 | 本次实际支付商品名称。 |
|
||||||
|
| `tipCount` | number(整数) | 否 | 当前用户对同一 `planId` 的成功支付次数。 |
|
||||||
|
| `poolIndex` | number(整数) | 否 | 本次选中的预生成文案下标,范围为 `0` 至 `99`。 |
|
||||||
|
| `message` | string | 否 | 可直接展示的完整英文文案。 |
|
||||||
|
|
||||||
|
每个角色在 Manager 中预先保存正好 100 条英文感谢语。后端使用 `characterId + orderId` 稳定选择一条,因此同一个 `orderId` 重复请求时,`poolIndex` 和 `message` 保持一致。该接口不会在请求时调用 AI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、前端验收步骤
|
||||||
|
|
||||||
|
1. 在预发请求 Elio 的品类,确认页面根据接口返回结果渲染,没有写死咖啡品类。
|
||||||
|
2. 请求选中品类的商品,确认商品名称、`planId`、图片、`amountCents` 和币种全部来自接口。
|
||||||
|
3. 分别选择 `$4.99`、`$9.99` 商品,确认创建订单请求发送的是对应商品自己的 `planId`。
|
||||||
|
4. 创建订单后校验 `payParams.firstChargeAmountCents === product.amountCents`;不一致时阻止支付并重新创建订单。
|
||||||
|
5. 切换商品后确认旧 Stripe Payment Element 已销毁,新的组件使用新 `clientSecret`。
|
||||||
|
6. 使用预发测试账号完成一笔允许的支付,轮询状态直到 `paid`。
|
||||||
|
7. 连续两次调用付款后文案接口,确认同一订单返回内容完全一致。
|
||||||
|
8. 再次购买同一商品,确认 `tipCount` 增加 1。
|
||||||
|
9. 确认礼物付款后没有增加积分、VIP 或自动续费。
|
||||||
|
|
||||||
|
## 十一、注意事项
|
||||||
|
|
||||||
|
- 前端不要缓存并跨商品复用 `orderId`、`clientSecret` 或 `payParams`。
|
||||||
|
- `planId` 是商品身份,`amountCents` 是商品价格;二者必须来自同一次商品接口响应。
|
||||||
|
- 品类或商品为空时展示空状态,不要回退到写死的旧咖啡商品。
|
||||||
|
- `imageUrl=null` 是合法结果,应显示本地占位图。
|
||||||
|
- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
"./src/data/schemas/payment",
|
"./src/data/schemas/payment",
|
||||||
"./src/data/schemas/payment/request",
|
"./src/data/schemas/payment/request",
|
||||||
"./src/data/schemas/payment/response",
|
"./src/data/schemas/payment/response",
|
||||||
"./src/data/schemas/private-room",
|
"./src/data/schemas/private-zone",
|
||||||
"./src/data/schemas/private-room/request",
|
"./src/data/schemas/private-zone/request",
|
||||||
"./src/data/schemas/private-room/response",
|
"./src/data/schemas/private-zone/response",
|
||||||
"./src/data/schemas/user",
|
"./src/data/schemas/user",
|
||||||
"./src/data/storage/app",
|
"./src/data/storage/app",
|
||||||
"./src/data/storage/auth",
|
"./src/data/storage/auth",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
| 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||||
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
||||||
|
|
||||||
当前 remote 配置示例:
|
当前 remote 配置示例:
|
||||||
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
|
|
||||||
## 本地发布流程
|
## 本地发布流程
|
||||||
|
|
||||||
旧测试环境发布流程(脚本迁移前):
|
旧预发环境发布流程(脚本迁移前):
|
||||||
|
|
||||||
1. 进入 test worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test`
|
1. 进入 pre worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
|
||||||
2. 确认当前分支。
|
2. 确认当前分支。
|
||||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。
|
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
|
||||||
4. 复制测试环境图标到 `public/`。
|
4. 复制测试环境图标到 `public/`。
|
||||||
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
||||||
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
||||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。
|
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
|
||||||
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
||||||
|
|
||||||
旧生产环境发布流程(脚本迁移前):
|
旧生产环境发布流程(脚本迁移前):
|
||||||
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
||||||
| `test` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
| `pre` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||||
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
||||||
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
||||||
|
|
||||||
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
|
|||||||
|
|
||||||
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
||||||
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
||||||
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
|
||||||
4. 生产推送当前使用 `git push --force production main`。
|
4. 生产推送当前使用 `git push --force production main`。
|
||||||
5. 构建失败时不会重启容器,旧容器继续运行。
|
5. 构建失败时不会重启容器,旧容器继续运行。
|
||||||
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
||||||
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
||||||
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||||
|
|
||||||
## 后续优化建议
|
## 后续优化建议
|
||||||
|
|
||||||
|
|||||||
@@ -1,496 +0,0 @@
|
|||||||
# 前端锁消息解锁 API 接入文档
|
|
||||||
|
|
||||||
## 1. 文档状态
|
|
||||||
|
|
||||||
本文档描述 `POST /api/chat/unlock-private` 与 `GET /api/chat/history` 的最新接口协议,覆盖后端已有锁消息和前端临时创建锁消息两种场景。
|
|
||||||
|
|
||||||
当前协议已经实现并通过自动化测试。前端联调前可通过目标环境的 `/openapi.json` 确认 `UnlockPrivateRequest` 已包含 `messageId`、`lockType` 和 `clientLockId`。
|
|
||||||
|
|
||||||
`unlock-private` 响应已收敛为紧凑结构。历史兼容别名不再返回,前端必须使用本文列出的标准 camelCase 字段。
|
|
||||||
|
|
||||||
环境地址:
|
|
||||||
|
|
||||||
| 环境 | API Base URL |
|
|
||||||
| --- | --- |
|
|
||||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
|
||||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
|
||||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
|
||||||
|
|
||||||
## 2. 功能目标
|
|
||||||
|
|
||||||
前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。
|
|
||||||
|
|
||||||
核心规则:
|
|
||||||
|
|
||||||
1. 余额不足时不生成真实语音、图片或私密内容,不扣积分,但会保存锁消息并返回真实 `messageId`。
|
|
||||||
2. 用户充值后,前端使用同一个 `clientLockId` 再次解锁,后端更新原消息,不新增另一条聊天记录。
|
|
||||||
3. 成功扣积分并持久化解锁后,`history` 才返回解锁状态。
|
|
||||||
4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`。
|
|
||||||
5. 前端只能使用 `unlocked` 或 `lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。
|
|
||||||
|
|
||||||
## 3. 身份认证
|
|
||||||
|
|
||||||
两个接口都使用当前聊天用户的登录 Token:
|
|
||||||
|
|
||||||
```http
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
前端不得保存或传递 Supabase service key。
|
|
||||||
|
|
||||||
## 4. 解锁接口
|
|
||||||
|
|
||||||
### 4.1 请求地址
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST <API_BASE_URL>/api/chat/unlock-private
|
|
||||||
```
|
|
||||||
|
|
||||||
生产环境完整地址:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST https://api.banlv-ai.com/api/chat/unlock-private
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 请求参数
|
|
||||||
|
|
||||||
请求体为 JSON:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"messageId": "可选,有就传",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"clientLockId": "前端生成的唯一ID,可选但强烈建议"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 字段 | 类型 | 是否必填 | 示例 | 说明 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `messageId` | string | 否 | `"9a43..."` | 后端真实消息 ID。已有消息时传;前端临时锁消息首次解锁时可以不传。 |
|
|
||||||
| `lockType` | string | 临时锁消息必填 | `"voice_message"` | 锁类型。没有有效 `messageId` 时必须传。 |
|
|
||||||
| `clientLockId` | string | 否,强烈建议 | `"fb_voice_3737_001"` | 前端锁卡片唯一 ID,用于充值后重试、刷新找回和避免重复创建。 |
|
|
||||||
|
|
||||||
`lockType` 支持以下值:
|
|
||||||
|
|
||||||
| 标准值 | 兼容别名 | 积分价格 |
|
|
||||||
| --- | --- | ---: |
|
|
||||||
| `voice_message` | `voice`、`audio` | 20 |
|
|
||||||
| `image_paywall` | `image`、`photo`、`picture` | 40 |
|
|
||||||
| `private_message` | `private`、`private_topic`、`private_room` | 10 |
|
|
||||||
|
|
||||||
建议前端只使用标准值。
|
|
||||||
|
|
||||||
### 4.3 `clientLockId` 生成规则
|
|
||||||
|
|
||||||
前端在创建锁卡片时生成一次,之后不得改变:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const clientLockId = crypto.randomUUID();
|
|
||||||
```
|
|
||||||
|
|
||||||
同一条锁消息在以下场景中必须继续使用同一个值:
|
|
||||||
|
|
||||||
- 第一次点击解锁;
|
|
||||||
- 余额不足后进入充值;
|
|
||||||
- 充值成功后重新点击解锁;
|
|
||||||
- 网络超时后重试;
|
|
||||||
- 前端重新渲染同一条本地锁卡片。
|
|
||||||
|
|
||||||
首次响应拿到后端 `messageId` 后,前端应同时保存 `messageId` 和 `clientLockId`。
|
|
||||||
|
|
||||||
### 4.4 最小请求示例
|
|
||||||
|
|
||||||
解锁已有后端消息:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
|
||||||
-H 'Authorization: Bearer <TOKEN>' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"messageId":"<MESSAGE_ID>"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
解锁前端临时语音锁消息:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
|
||||||
-H 'Authorization: Bearer <TOKEN>' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"lockType":"voice_message",
|
|
||||||
"clientLockId":"codex_test_voice_001"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
充值后建议把三个字段都传回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"messageId": "<FIRST_RESPONSE_MESSAGE_ID>",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"clientLockId": "codex_test_voice_001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 统一响应结构
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
业务解锁失败通常也返回 HTTP 200。前端必须读取 `data.unlocked` 和 `data.reason`,不能只检查 HTTP 状态或顶层 `success`。
|
|
||||||
|
|
||||||
重要字段:
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `unlocked` | boolean | 本次操作后是否已真实解锁。 |
|
|
||||||
| `reason` | string/null | 本次操作结果原因。 |
|
|
||||||
| `messageId` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 |
|
|
||||||
| `clientLockId` | string/null | 后端识别到的前端锁 ID。 |
|
|
||||||
| `lockType` | string/null | 规范化后的锁类型。 |
|
|
||||||
| `content` | string/null | 当前消息文字;私密内容未解锁时可以为 `null`。 |
|
|
||||||
| `type` | string | `text` 或 `voice`。 |
|
|
||||||
| `audioUrl` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 |
|
|
||||||
| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 |
|
|
||||||
| `creditBalance` | number/null | 操作后的当前积分。 |
|
|
||||||
| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 |
|
|
||||||
| `requiredCredits` | number | 当前锁类型需要的积分。 |
|
|
||||||
| `shortfallCredits` | number | 余额不足时还差多少积分。 |
|
|
||||||
| `lockDetail.locked` | boolean | 前端锁卡片的最终展示开关。 |
|
|
||||||
| `lockDetail.showContent` | boolean | 是否允许展示文字内容。 |
|
|
||||||
| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 |
|
|
||||||
| `lockDetail.reason` | string/null | 锁定原因。 |
|
|
||||||
|
|
||||||
以下重复或与解锁无关的字段已经删除:`message_id`、`messageType`、`reply`、`response`、`audio_url`、`displayMessage`、`localizedMessage`、`paywallTriggered`、`showUpgrade`、`timestamp`、`isGuest`、亲密度字段以及 `blocked` 系列字段。
|
|
||||||
|
|
||||||
## 6. 余额不足
|
|
||||||
|
|
||||||
请求:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"clientLockId": "fb_voice_37370387172559600_001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
代表性响应:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"unlocked": false,
|
|
||||||
"reason": "insufficient_credits",
|
|
||||||
"type": "voice",
|
|
||||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"clientLockId": "fb_voice_37370387172559600_001",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"content": "I left a voice message for you. Unlock it to listen.",
|
|
||||||
"audioUrl": "",
|
|
||||||
"creditBalance": 5,
|
|
||||||
"creditsCharged": 0,
|
|
||||||
"requiredCredits": 20,
|
|
||||||
"shortfallCredits": 15,
|
|
||||||
"lockDetail": {
|
|
||||||
"locked": true,
|
|
||||||
"showContent": true,
|
|
||||||
"showUpgrade": true,
|
|
||||||
"reason": "voice_message",
|
|
||||||
"detail": {
|
|
||||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"clientLockId": "fb_voice_37370387172559600_001",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"requiredCredits": 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
前端处理:
|
|
||||||
|
|
||||||
1. 使用返回的 `messageId` 替换本地临时消息 ID。
|
|
||||||
2. 保留原 `clientLockId`。
|
|
||||||
3. 保持锁定状态并打开充值页面。
|
|
||||||
4. 充值完成后再次调用解锁接口。
|
|
||||||
5. 请求期间禁用解锁按钮,避免用户连续点击。
|
|
||||||
|
|
||||||
## 7. 解锁成功
|
|
||||||
|
|
||||||
### 7.1 语音成功
|
|
||||||
|
|
||||||
后端先确认并扣除 20 积分,然后生成真实文字和语音,更新同一条聊天记录:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"unlocked": true,
|
|
||||||
"reason": "ok",
|
|
||||||
"type": "voice",
|
|
||||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"clientLockId": "fb_voice_37370387172559600_001",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"content": "I made this voice just for you.",
|
|
||||||
"audioUrl": "https://api.banlv-ai.com/audio/<FILE>.mp3",
|
|
||||||
"creditBalance": 10,
|
|
||||||
"creditsCharged": 20,
|
|
||||||
"requiredCredits": 20,
|
|
||||||
"shortfallCredits": 0,
|
|
||||||
"lockDetail": {
|
|
||||||
"locked": false,
|
|
||||||
"showContent": true,
|
|
||||||
"showUpgrade": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
前端收到后直接用本次响应替换锁卡片,播放地址读取 `data.audioUrl`。
|
|
||||||
|
|
||||||
### 7.2 图片成功
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"unlocked": true,
|
|
||||||
"reason": "ok",
|
|
||||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"clientLockId": "fb_image_001",
|
|
||||||
"lockType": "image_paywall",
|
|
||||||
"image": {
|
|
||||||
"type": "elio_schedule",
|
|
||||||
"url": "https://dbapi.banlv-ai.com/storage/v1/object/public/<PATH>"
|
|
||||||
},
|
|
||||||
"creditBalance": 60,
|
|
||||||
"creditsCharged": 40,
|
|
||||||
"requiredCredits": 40,
|
|
||||||
"lockDetail": {
|
|
||||||
"locked": false,
|
|
||||||
"showContent": true,
|
|
||||||
"showUpgrade": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
图片读取 `data.image.url`。后端已有图片锁消息可能在锁定时也返回真实 URL,因为 URL 本身不作为解锁凭证;前端仍必须根据 `lockDetail.locked` 遮挡或禁止查看。
|
|
||||||
|
|
||||||
前端临时创建、尚未确定图片内容的锁消息,在成功扣积分前可能没有图片 URL;成功后后端选择图片并返回 URL。
|
|
||||||
|
|
||||||
### 7.3 私密文本成功
|
|
||||||
|
|
||||||
前端临时私密消息按 10 积分生成并解锁:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"unlocked": true,
|
|
||||||
"reason": "ok",
|
|
||||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"clientLockId": "fb_private_001",
|
|
||||||
"lockType": "private_message",
|
|
||||||
"content": "<REAL_PRIVATE_REPLY>",
|
|
||||||
"creditsCharged": 10,
|
|
||||||
"requiredCredits": 10,
|
|
||||||
"lockDetail": {
|
|
||||||
"locked": false,
|
|
||||||
"showContent": true,
|
|
||||||
"showUpgrade": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
已有后端私密文本仍优先使用每日免费私密额度:登录用户默认每天 2 条,游客默认每天 1 条。成功消耗免费额度后也会持久解锁,但 `creditsCharged=0`。
|
|
||||||
|
|
||||||
## 8. History 接口
|
|
||||||
|
|
||||||
### 8.1 请求
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET <API_BASE_URL>/api/chat/history?limit=50&offset=0
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.banlv-ai.com/api/chat/history?limit=50&offset=0' \
|
|
||||||
-H 'Authorization: Bearer <TOKEN>'
|
|
||||||
```
|
|
||||||
|
|
||||||
`limit=1&offset=0` 返回最新的一条数据库聊天记录展开后的消息;接口最终按聊天展示顺序返回 `data.messages`。
|
|
||||||
|
|
||||||
### 8.2 锁定消息示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"type": "voice",
|
|
||||||
"content": "I left a voice message for you. Unlock it to listen.",
|
|
||||||
"id": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"created_at": "2026-07-13T08:00:00+00:00",
|
|
||||||
"audioUrl": null,
|
|
||||||
"image": {
|
|
||||||
"type": null,
|
|
||||||
"url": null
|
|
||||||
},
|
|
||||||
"lockDetail": {
|
|
||||||
"locked": true,
|
|
||||||
"showContent": true,
|
|
||||||
"showUpgrade": true,
|
|
||||||
"reason": "voice_message",
|
|
||||||
"detail": {
|
|
||||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
|
||||||
"clientLockId": "fb_voice_37370387172559600_001",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"requiredCredits": 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 前端展示规则
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const locked = message.lockDetail?.locked === true;
|
|
||||||
const showText = message.lockDetail?.showContent !== false;
|
|
||||||
|
|
||||||
if (locked) {
|
|
||||||
// 展示锁卡片、遮罩或解锁按钮
|
|
||||||
} else {
|
|
||||||
// 展示真实内容
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
各类型规则:
|
|
||||||
|
|
||||||
| 状态 | `lockDetail.locked` | URL/内容行为 |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| 锁定语音 | `true` | `audioUrl=null`,不能播放。 |
|
|
||||||
| 已解锁语音 | `false` | `audioUrl` 是完整可播放 URL。 |
|
|
||||||
| 锁定图片 | `true` | `image.url` 可能有值,也可能为空;必须遮挡。 |
|
|
||||||
| 已解锁图片 | `false` | 使用完整 `image.url` 展示。 |
|
|
||||||
| 锁定私密文本 | `true` | 根据 `showContent` 决定是否显示 `content`。 |
|
|
||||||
| 已解锁私密文本 | `false` | 展示完整 `content`。 |
|
|
||||||
|
|
||||||
## 9. 推荐 TypeScript 类型
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type LockType = "voice_message" | "image_paywall" | "private_message";
|
|
||||||
|
|
||||||
interface UnlockPrivateRequest {
|
|
||||||
messageId?: string;
|
|
||||||
lockType?: LockType;
|
|
||||||
clientLockId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LockDetail {
|
|
||||||
locked: boolean;
|
|
||||||
showContent: boolean;
|
|
||||||
showUpgrade: boolean;
|
|
||||||
reason?: string | null;
|
|
||||||
hint?: string | null;
|
|
||||||
actionLabel?: string | null;
|
|
||||||
detail?: {
|
|
||||||
messageId?: string;
|
|
||||||
clientLockId?: string;
|
|
||||||
lockType?: LockType;
|
|
||||||
requiredCredits?: number;
|
|
||||||
refundedCredits?: number;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UnlockPrivateData {
|
|
||||||
unlocked: boolean;
|
|
||||||
reason?: string | null;
|
|
||||||
messageId: string;
|
|
||||||
clientLockId?: string | null;
|
|
||||||
lockType?: LockType | null;
|
|
||||||
type: "text" | "voice";
|
|
||||||
content?: string | null;
|
|
||||||
audioUrl: string;
|
|
||||||
image: {
|
|
||||||
type?: string | null;
|
|
||||||
url?: string | null;
|
|
||||||
};
|
|
||||||
creditBalance?: number | null;
|
|
||||||
creditsCharged: number;
|
|
||||||
requiredCredits: number;
|
|
||||||
shortfallCredits: number;
|
|
||||||
lockDetail: LockDetail;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. 异常与前端处理
|
|
||||||
|
|
||||||
| `data.reason` | 含义 | 前端处理 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `ok` | 成功扣费并解锁 | 用响应替换锁卡片。 |
|
|
||||||
| `not_locked` | 消息已经不是锁定状态 | 直接按已解锁内容展示,不再扣费。 |
|
|
||||||
| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 |
|
|
||||||
| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 |
|
|
||||||
| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 |
|
|
||||||
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `lockDetail.hint`。 |
|
|
||||||
| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 |
|
|
||||||
| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 |
|
|
||||||
|
|
||||||
认证失败会返回 HTTP 401。请求字段类型不合法可能返回 HTTP 422。
|
|
||||||
|
|
||||||
## 11. 前端完整流程
|
|
||||||
|
|
||||||
```text
|
|
||||||
创建本地锁卡片并生成 clientLockId
|
|
||||||
-> 用户点击解锁
|
|
||||||
-> POST /api/chat/unlock-private
|
|
||||||
-> unlocked=true:替换卡片并展示真实内容
|
|
||||||
-> insufficient_credits:保存 messageId,打开充值
|
|
||||||
-> 充值成功
|
|
||||||
-> 使用相同 messageId + lockType + clientLockId 重试
|
|
||||||
-> unlocked=true:替换卡片
|
|
||||||
-> 页面刷新
|
|
||||||
-> GET /api/chat/history
|
|
||||||
-> 只按 lockDetail.locked 恢复锁定或解锁状态
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. 测试方法
|
|
||||||
|
|
||||||
必须使用测试账号,不要用真实付费用户验证扣费。
|
|
||||||
|
|
||||||
1. 准备余额低于 20 的测试账号。
|
|
||||||
2. 不传 `messageId`,使用唯一 `clientLockId` 请求 `voice_message`。
|
|
||||||
3. 确认 `unlocked=false`、`creditsCharged=0`,并取得非空 `messageId`。
|
|
||||||
4. 调用 history,确认同一 `messageId` 存在且 `locked=true`、`audioUrl=null`。
|
|
||||||
5. 给测试账号增加到至少 20 积分。
|
|
||||||
6. 使用相同 `messageId`、`lockType`、`clientLockId` 再次请求。
|
|
||||||
7. 确认 `unlocked=true`、`creditsCharged=20`、`audioUrl` 非空。
|
|
||||||
8. 再次调用 history,确认同一消息 `locked=false` 且 `audioUrl` 非空。
|
|
||||||
9. 对图片 40 积分和私密文本 10 积分重复验证。
|
|
||||||
10. 测试结束后记录测试账号积分变化;生成的聊天记录会保留在 history 中,没有自动清理接口。
|
|
||||||
|
|
||||||
后端自动化测试:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe -m pytest tests\test_private_unlock.py -q
|
|
||||||
```
|
|
||||||
|
|
||||||
当前结果:`20 passed`;私密解锁、图片 paywall、私密相册和私密空间相关回归共 `48 passed`。
|
|
||||||
|
|
||||||
## 13. 兼容性与回滚
|
|
||||||
|
|
||||||
- 已有前端继续只传 `messageId` 的调用方式保持兼容。
|
|
||||||
- 请求参数保持兼容:已有前端继续只传 `messageId` 仍可使用。
|
|
||||||
- 响应字段有收敛:旧别名不再返回,前端需要按第 5、9 节使用标准字段。
|
|
||||||
- 本次功能不需要数据库迁移,通过现有 `chat_messages` 和解锁记录保存状态。
|
|
||||||
- 如果上线后需要回滚,只回滚本次后端路由和 schema 代码即可;已生成的聊天记录可保留,不影响旧 history 读取。
|
|
||||||
- 上线前需将代码提交并推送到目标环境对应分支:`test`、`pro` 或 `main`,再部署对应环境。
|
|
||||||
|
|
||||||
## 14. Needs confirmation
|
|
||||||
|
|
||||||
- `https://api.cozsweet.com` 是否继续作为生产 API 别名,不在本次代码和环境配置中确认;本文统一使用正式生产地址 `https://api.banlv-ai.com`。
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
# 单角色应用迁移为多角色聊天架构
|
|
||||||
|
|
||||||
## 1. 文档目标
|
|
||||||
|
|
||||||
本文档定义 CozSweet 从单角色 Elio 迁移到多角色架构时,后端需要完成的接口、数据模型和兼容策略调整。
|
|
||||||
|
|
||||||
迁移目标:
|
|
||||||
|
|
||||||
1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。
|
|
||||||
2. 同一用户可以分别与多个角色聊天。
|
|
||||||
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
|
||||||
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
|
||||||
5. 现有客户端和历史数据继续默认归属于 Elio。
|
|
||||||
|
|
||||||
本文档中的标准角色标识:
|
|
||||||
|
|
||||||
| 类型 | Elio 示例 | 用途 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 |
|
|
||||||
| `slug` | `elio` | 前端 URL 和外部入口 |
|
|
||||||
|
|
||||||
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
|
|
||||||
|
|
||||||
环境地址:
|
|
||||||
|
|
||||||
| 环境 | API Base URL |
|
|
||||||
| --- | --- |
|
|
||||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
|
||||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
|
||||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
|
||||||
|
|
||||||
## 2. 前端 Character Catalog
|
|
||||||
|
|
||||||
后端角色目录协议尚未确定。过渡阶段由前端本地只读 Catalog 提供角色,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。业务页面只消费 Catalog 边界;后续切换为服务端目录时不改变角色路由和业务组件接口。
|
|
||||||
|
|
||||||
当前角色:
|
|
||||||
|
|
||||||
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
|
|
||||||
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
|
|
||||||
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
|
|
||||||
|
|
||||||
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
|
|
||||||
|
|
||||||
## 3. 聊天接口调整
|
|
||||||
|
|
||||||
### 3.1 通用规则
|
|
||||||
|
|
||||||
以下接口新增标准字段 `characterId`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/chat/send
|
|
||||||
GET /api/chat/history
|
|
||||||
POST /api/chat/unlock-private
|
|
||||||
POST /api/chat/unlock-history
|
|
||||||
```
|
|
||||||
|
|
||||||
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
|
||||||
|
|
||||||
后端必须使用以下维度定位聊天数据:
|
|
||||||
|
|
||||||
```text
|
|
||||||
正式用户:userId + characterId
|
|
||||||
游客:guest identity/device identity + characterId
|
|
||||||
```
|
|
||||||
|
|
||||||
任何历史查询、消息发送、解锁和配额判断都不能只按用户查找。
|
|
||||||
|
|
||||||
### 3.2 发送消息
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/chat/send
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
现有请求体增加 `characterId`,其他字段保持原协议:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"characterId": "character_elio",
|
|
||||||
"message": "Hello Elio",
|
|
||||||
"image": "",
|
|
||||||
"imageId": "",
|
|
||||||
"imageThumbUrl": "",
|
|
||||||
"imageMediumUrl": "",
|
|
||||||
"imageOriginalUrl": "",
|
|
||||||
"imageWidth": 0,
|
|
||||||
"imageHeight": 0,
|
|
||||||
"useWebSocket": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
后端处理要求:
|
|
||||||
|
|
||||||
1. 校验角色存在且已启用。
|
|
||||||
2. 使用 `characterId` 选择对应角色 Prompt、模型配置和聊天上下文。
|
|
||||||
3. 只加载该用户与该角色的历史。
|
|
||||||
4. 新消息和生成结果必须写入相同 `characterId`。
|
|
||||||
5. 响应继续沿用现有 `ChatSendResponse`,不额外返回前端不使用的角色字段。
|
|
||||||
|
|
||||||
如果后端启用 WebSocket,连接握手或每条发送帧也必须携带 `characterId`,不能依赖服务端进程内的“当前角色”状态。
|
|
||||||
|
|
||||||
### 3.3 获取聊天历史
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/chat/history?characterId=character_elio&limit=50&offset=0
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
| 参数 | 类型 | 是否必填 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `characterId` | string | 是 | 角色业务 ID。 |
|
|
||||||
| `limit` | integer | 否 | 保持现有分页规则。 |
|
|
||||||
| `offset` | integer | 否 | 保持现有分页规则。 |
|
|
||||||
|
|
||||||
响应结构和分页字段保持不变。`messages`、`total` 和 `offset` 必须只统计当前角色。
|
|
||||||
|
|
||||||
禁止在当前角色历史中返回其他角色的消息,即使消息属于同一用户。
|
|
||||||
|
|
||||||
### 3.4 解锁单条消息
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/chat/unlock-private
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体增加 `characterId`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"characterId": "character_elio",
|
|
||||||
"messageId": "message-id",
|
|
||||||
"lockType": "voice_message",
|
|
||||||
"clientLockId": "client-generated-id"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
现有 `messageId`、`lockType` 和 `clientLockId` 规则保持不变。
|
|
||||||
|
|
||||||
后端必须验证:
|
|
||||||
|
|
||||||
- 已存在消息的 `character_id` 与请求 `characterId` 一致。
|
|
||||||
- 临时锁消息不存在 `messageId` 时,新建消息必须写入请求角色。
|
|
||||||
- `clientLockId` 的幂等范围至少包含身份和角色,推荐唯一键:
|
|
||||||
|
|
||||||
```text
|
|
||||||
identity + character_id + client_lock_id
|
|
||||||
```
|
|
||||||
|
|
||||||
跨角色解锁返回 `CHARACTER_MISMATCH`,不能扣积分,也不能修改消息。
|
|
||||||
|
|
||||||
### 3.5 一键解锁历史
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/chat/unlock-history
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体由空请求调整为:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"characterId": "character_elio"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
后端只能统计和解锁当前角色的锁消息。`requiredCredits`、解锁数量和返回 messages 都必须按当前角色计算。
|
|
||||||
|
|
||||||
## 4. 角色最新消息接口
|
|
||||||
|
|
||||||
角色列表需要一次性获取各角色的最新消息,避免对每个角色分别请求 history。
|
|
||||||
|
|
||||||
### 4.1 请求
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/chat/previews
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 响应
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"characterId": "character_elio",
|
|
||||||
"message": {
|
|
||||||
"id": "message-id",
|
|
||||||
"role": "assistant",
|
|
||||||
"type": "text",
|
|
||||||
"content": "How was your day?",
|
|
||||||
"createdAt": "2026-07-17T10:00:00Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"characterId": "character_aria",
|
|
||||||
"message": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `items` 按前端本地角色目录顺序返回当前可用角色。
|
|
||||||
- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。
|
|
||||||
- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。
|
|
||||||
- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。
|
|
||||||
|
|
||||||
## 5. 私密空间接口调整
|
|
||||||
|
|
||||||
### 5.1 获取相册
|
|
||||||
|
|
||||||
标准请求改为:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/private-room/albums?characterId=character_elio&limit=20
|
|
||||||
Authorization: Bearer <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
兼容期继续接受旧参数:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/private-room/albums?character=elio&limit=20
|
|
||||||
```
|
|
||||||
|
|
||||||
参数优先级:
|
|
||||||
|
|
||||||
1. 存在 `characterId` 时使用标准 ID。
|
|
||||||
2. 兼容期仅存在 `character` 时,将旧 slug 解析为角色 ID。
|
|
||||||
3. 两者同时存在但指向不同角色时返回 `CHARACTER_MISMATCH`。
|
|
||||||
4. 两者都缺失时,兼容期默认 Elio。
|
|
||||||
|
|
||||||
响应结构保持不变,不增加前端未使用的角色字段。
|
|
||||||
|
|
||||||
### 5.2 解锁相册
|
|
||||||
|
|
||||||
接口路径保持不变:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/private-room/albums/{albumId}/unlock
|
|
||||||
```
|
|
||||||
|
|
||||||
`albumId` 已能唯一确定角色,因此请求体不增加 `characterId`。后端必须根据 album 关联的角色进行权限、积分和解锁记录校验。
|
|
||||||
|
|
||||||
## 6. Tip 支付接口调整
|
|
||||||
|
|
||||||
### 6.1 创建订单
|
|
||||||
|
|
||||||
现有接口:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/payment/create-order
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体增加可选字段 `recipientCharacterId`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"planId": "tip_coffee_medium",
|
|
||||||
"payChannel": "stripe",
|
|
||||||
"autoRenew": false,
|
|
||||||
"recipientCharacterId": "character_elio"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
后端必须先根据 `planId` 判断订单类型:
|
|
||||||
|
|
||||||
| 订单类型 | `recipientCharacterId` 规则 |
|
|
||||||
| --- | --- |
|
|
||||||
| Tip | 必填,并校验角色存在且启用 |
|
|
||||||
| VIP | 忽略,不保存 |
|
|
||||||
| Top-up | 忽略,不保存 |
|
|
||||||
|
|
||||||
Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归属、统计和后续展示。
|
|
||||||
|
|
||||||
支付套餐、创建订单响应和订单状态响应不增加角色字段。
|
|
||||||
|
|
||||||
## 7. 数据模型调整
|
|
||||||
|
|
||||||
### 7.1 Characters 表
|
|
||||||
|
|
||||||
后台建议至少保存:
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `id` | 主键,稳定角色 ID。 |
|
|
||||||
| `slug` | 唯一 URL 标识。 |
|
|
||||||
| `display_name` | 展示名称。 |
|
|
||||||
| `avatar_url` | 头像地址。 |
|
|
||||||
| `enabled` | 是否对用户启用,仅后台使用。 |
|
|
||||||
| `sort_order` | 运营排序,仅后台使用。 |
|
|
||||||
| `created_at` | 创建时间。 |
|
|
||||||
| `updated_at` | 更新时间。 |
|
|
||||||
|
|
||||||
`enabled` 和 `sort_order` 是后端管理字段,不返回前端。
|
|
||||||
|
|
||||||
### 7.2 业务表
|
|
||||||
|
|
||||||
以下数据增加 `character_id` 外键:
|
|
||||||
|
|
||||||
- conversations 或等价会话表;
|
|
||||||
- chat messages;
|
|
||||||
- 锁消息、解锁记录和 `clientLockId` 幂等记录;
|
|
||||||
- private albums 及相册解锁记录;
|
|
||||||
- Tip 订单或 Tip 业务记录。
|
|
||||||
|
|
||||||
推荐索引:
|
|
||||||
|
|
||||||
```text
|
|
||||||
(user_id, character_id, created_at)
|
|
||||||
(guest_id, character_id, created_at)
|
|
||||||
(character_id, enabled)
|
|
||||||
(identity, character_id, client_lock_id) UNIQUE
|
|
||||||
```
|
|
||||||
|
|
||||||
如果当前系统没有 conversations 表,可以继续使用 messages,但所有历史查询必须显式包含 `character_id`。
|
|
||||||
|
|
||||||
## 8. 历史数据迁移
|
|
||||||
|
|
||||||
上线新接口前执行:
|
|
||||||
|
|
||||||
1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。
|
|
||||||
2. 为相关业务表增加可空 `character_id`。
|
|
||||||
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。
|
|
||||||
4. 检查不存在空角色或无法关联的数据。
|
|
||||||
5. 创建联合索引和外键。
|
|
||||||
6. 将必要业务表的 `character_id` 调整为非空。
|
|
||||||
|
|
||||||
回填前后需要核对每张表的记录数量,迁移不能删除历史聊天或解锁状态。
|
|
||||||
|
|
||||||
## 9. 兼容发布策略
|
|
||||||
|
|
||||||
### 第一阶段:后端兼容
|
|
||||||
|
|
||||||
- 上线角色 ID 配置和新字段。
|
|
||||||
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
|
||||||
- Private Room 缺少角色时暂时默认 Elio。
|
|
||||||
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
|
||||||
- 记录缺少角色参数的调用次数和客户端版本。
|
|
||||||
|
|
||||||
### 第二阶段:前端迁移
|
|
||||||
|
|
||||||
- 新前端所有请求显式发送角色 ID。
|
|
||||||
- 动态路由和支付回跳携带角色上下文。
|
|
||||||
- 观察错误率、跨角色校验和旧请求占比。
|
|
||||||
|
|
||||||
### 第三阶段:收紧协议
|
|
||||||
|
|
||||||
- Chat 接口缺少 `characterId` 时返回 `CHARACTER_REQUIRED`。
|
|
||||||
- Tip 订单缺少 recipient 时返回 `CHARACTER_REQUIRED`。
|
|
||||||
- 删除 Private Room 旧 `character` 参数。
|
|
||||||
- 删除默认 Elio 的接口兼容逻辑。
|
|
||||||
|
|
||||||
由于 PWA 和浏览器可能长期缓存旧前端,第三阶段只能在旧请求流量降至可接受水平后执行。
|
|
||||||
|
|
||||||
只有“完全缺少角色字段”的旧请求可以在兼容期默认 Elio。显式传入不存在、禁用或不匹配角色时不得回退 Elio。
|
|
||||||
|
|
||||||
## 10. 错误响应
|
|
||||||
|
|
||||||
统一失败 envelope:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "Character is not available",
|
|
||||||
"error": "CHARACTER_DISABLED"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
新增错误:
|
|
||||||
|
|
||||||
| HTTP 状态 | error | 场景 |
|
|
||||||
| ---: | --- | --- |
|
|
||||||
| `400` | `CHARACTER_REQUIRED` | 收紧协议后缺少角色字段。 |
|
|
||||||
| `404` | `CHARACTER_NOT_FOUND` | `id` 或兼容 slug 不存在。 |
|
|
||||||
| `403` | `CHARACTER_DISABLED` | 角色存在但未启用。 |
|
|
||||||
| `409` | `CHARACTER_MISMATCH` | 消息、相册或重复参数属于不同角色。 |
|
|
||||||
|
|
||||||
角色错误不能扣积分、创建消息、创建订单或修改任何解锁状态。
|
|
||||||
|
|
||||||
## 11. 安全和一致性要求
|
|
||||||
|
|
||||||
- 角色 ID 必须由后端查询验证,不能信任客户端展示信息。
|
|
||||||
- 用户只能读取自己的某个角色会话,不能通过替换 characterId 读取其他用户数据。
|
|
||||||
- messageId、albumId 和 clientLockId 必须同时校验身份与角色归属。
|
|
||||||
- 角色禁用后不能继续发送、解锁、创建相册订单或 Tip 订单。
|
|
||||||
- 角色禁用不删除历史数据,恢复启用后历史仍可读取。
|
|
||||||
- 日志可以记录 `characterId`,但不能记录聊天正文、Token 或私密媒体 URL。
|
|
||||||
- Analytics 和业务统计统一使用角色 ID,不使用 displayName。
|
|
||||||
|
|
||||||
## 12. 联调验收
|
|
||||||
|
|
||||||
后端完成后至少验证:
|
|
||||||
|
|
||||||
1. 前端本地目录中的每个角色 ID 均可调用 Chat、Private Room 和 Tip 业务接口。
|
|
||||||
2. Login Token 和 Guest Token 均可按角色 ID 聊天。
|
|
||||||
3. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
|
||||||
4. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
|
||||||
5. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
|
||||||
6. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
|
||||||
7. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
|
||||||
8. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
|
||||||
9. 后端禁用角色后,直接访问业务接口返回 `CHARACTER_DISABLED`;前端发布时同步移除本地记录。
|
|
||||||
10. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
|
||||||
11. 历史数据回填前后记录数量、积分和解锁状态一致。
|
|
||||||
|
|
||||||
## 13. OpenAPI 参考
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
paths:
|
|
||||||
/api/chat/history:
|
|
||||||
get:
|
|
||||||
parameters:
|
|
||||||
- in: query
|
|
||||||
name: characterId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- in: query
|
|
||||||
name: limit
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
- in: query
|
|
||||||
name: offset
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
/api/chat/unlock-history:
|
|
||||||
post:
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [characterId]
|
|
||||||
properties:
|
|
||||||
characterId:
|
|
||||||
type: string
|
|
||||||
```
|
|
||||||
@@ -1,28 +1,95 @@
|
|||||||
# CozSweet 多角色聊天前端对接
|
# CozSweet 多角色聊天权威协议
|
||||||
|
|
||||||
## 3. Character IDs
|
## 1. 状态与范围
|
||||||
|
|
||||||
前端先调用:
|
本文是前端仓库中多角色目录、聊天、解锁、缓存隔离及关联业务的唯一人工维护协议。旧的单角色迁移方案和独立锁媒体协议已删除,不再作为实现或联调依据。
|
||||||
|
|
||||||
|
协议描述当前前端已经执行的行为,不描述尚未落地的后端迁移步骤。字段和路径由下列机器可验证入口约束:
|
||||||
|
|
||||||
|
| 边界 | 实现位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
|
| 请求与响应字段 | `src/data/schemas/character`、`src/data/schemas/chat` |
|
||||||
|
| 角色 ID、slug 与本地资源 | `src/data/constants/character.ts` |
|
||||||
|
| Chat UI 消息身份 | `src/stores/chat/ui-message.ts` |
|
||||||
|
| 本地缓存身份 | `src/data/repositories/chat_cache_identity.ts`、`src/lib/chat/chat_cache_keys.ts` |
|
||||||
|
|
||||||
|
如本文与上述实现发生差异,修改代码时必须在同一变更中更新本文,不能新增第二份并行协议。
|
||||||
|
|
||||||
|
## 2. 角色目录与路由
|
||||||
|
|
||||||
|
### 2.1 角色身份
|
||||||
|
|
||||||
|
`id` 是 API、状态机、缓存和数据归属使用的稳定业务身份;`slug` 只用于 URL。
|
||||||
|
|
||||||
|
| `id` | `slug` | 展示名称 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `elio` | `elio` | Elio Silvestri |
|
||||||
|
| `maya-tan` | `maya` | Maya Tan |
|
||||||
|
| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes |
|
||||||
|
|
||||||
|
不得向业务 API 发送 slug、展示名称、`@handle` 或旧 ID。旧 ID `character_elio`、`character_maya`、`character_nayeli` 仅用于本地持久化数据迁移。
|
||||||
|
|
||||||
|
### 2.2 角色目录接口
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET <API_BASE_URL>/api/characters?capability=chat
|
GET <API_BASE_URL>/api/characters?capability=chat
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
聊天业务统一使用响应里的 `items[].id`。当前规范 ID 是:
|
标准响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "maya-tan",
|
||||||
|
"displayName": "Maya Tan",
|
||||||
|
"isActive": true,
|
||||||
|
"capabilities": {
|
||||||
|
"chat": true,
|
||||||
|
"privateContent": true
|
||||||
|
},
|
||||||
|
"sortOrder": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultCharacterId": "elio"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端只接纳同时满足以下条件的角色:
|
||||||
|
|
||||||
|
1. `id` 存在于本地资源目录;
|
||||||
|
2. `isActive=true`;
|
||||||
|
3. `capabilities.chat=true`。
|
||||||
|
|
||||||
|
服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateZone` 只有在本地能力和服务端 `privateContent` 同时开启时可用。
|
||||||
|
|
||||||
|
远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。
|
||||||
|
|
||||||
|
### 2.3 页面路由
|
||||||
|
|
||||||
```text
|
```text
|
||||||
elio
|
/characters/{slug}/splash
|
||||||
maya-tan
|
/characters/{slug}/chat
|
||||||
nayeli-cervantes
|
/characters/{slug}/private-zone
|
||||||
|
/characters/{slug}/tip
|
||||||
```
|
```
|
||||||
|
|
||||||
只允许传递角色目录响应中的 `items[].id`,不要传展示名或 `@handle`。
|
当前 Splash 是角色详情入口,不是全角色列表页。以下旧地址保留为默认角色重定向,并原样保留查询参数:
|
||||||
|
|
||||||
角色响应中的 `capabilities` 是后端权威开关。`chat=false` 时不得开放输入框;直接调用聊天接口会得到 `CHARACTER_DISABLED`,不会返回 Elio 历史。
|
```text
|
||||||
|
/splash -> /characters/elio/splash
|
||||||
|
/chat -> /characters/elio/chat
|
||||||
|
/private-zone -> /characters/elio/private-zone
|
||||||
|
/tip -> /characters/elio/tip
|
||||||
|
```
|
||||||
|
|
||||||
## 4. Send Message
|
Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Zone 的角色边界由 [Private Zone 权威协议](./FRONTEND_PRIVATE_ZONE_API.md) 定义。
|
||||||
|
|
||||||
### Request URL
|
## 3. Chat HTTP 协议
|
||||||
|
|
||||||
|
### 3.1 发送消息
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST <API_BASE_URL>/api/chat/send
|
POST <API_BASE_URL>/api/chat/send
|
||||||
@@ -30,73 +97,56 @@ Content-Type: application/json
|
|||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parameters
|
```json
|
||||||
|
{
|
||||||
| Field | Type | Required | Example | Meaning |
|
"characterId": "maya-tan",
|
||||||
| --- | --- | --- | --- | --- |
|
"message": "Hello Maya",
|
||||||
| `characterId` | string | 新前端必传 | `maya-tan` | 当前聊天角色 ID |
|
"useWebSocket": false
|
||||||
| `message` | string | 与图片至少一项有值 | `Hello Maya` | 用户文本,最多 4000 字符 |
|
}
|
||||||
| `imageId` | string | 否 | `abc123` | 上传图片 ID |
|
|
||||||
| `imageThumbUrl` | string | 否 | `/images/...` | 缩略图 |
|
|
||||||
| `imageMediumUrl` | string | 否 | `/images/...` | 模型识图使用 |
|
|
||||||
| `imageOriginalUrl` | string | 否 | `/images/...` | 原图 |
|
|
||||||
| `imageWidth` | integer | 否 | `1080` | 图片宽 |
|
|
||||||
| `imageHeight` | integer | 否 | `1440` | 图片高 |
|
|
||||||
| `useWebSocket` | boolean | 否 | `false` | 是否同时通过已连接 WS 推送 |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST 'https://proapi.banlv-ai.com/api/chat/send' \
|
|
||||||
-H 'Authorization: Bearer <TOKEN>' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"characterId":"maya-tan","message":"Hello Maya","useWebSocket":false}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
响应沿用现有发送结构。前端仍读取 `data.reply`、`data.messageId`、`data.audioUrl`、`data.image` 和 `data.lockDetail`,无需从响应推断角色。
|
请求规则:
|
||||||
|
|
||||||
## 5. Chat History
|
- `characterId` 必填;
|
||||||
|
- `message` 最大 4000 字符;
|
||||||
|
- `message` 与 `imageId`、`imageThumbUrl`、`imageMediumUrl`、`imageOriginalUrl` 至少一项有效;
|
||||||
|
- 图片请求可附带 `imageWidth`、`imageHeight`;
|
||||||
|
- `useWebSocket` 缺失时按 `false` 处理。
|
||||||
|
|
||||||
|
前端使用的响应字段为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reply, audioUrl, messageId, isGuest, timestamp, image, lockDetail,
|
||||||
|
canSendMessage, creditBalance, creditsCharged, requiredCredits,
|
||||||
|
shortfallCredits
|
||||||
|
```
|
||||||
|
|
||||||
|
`messageId` 是后端消息身份,不是 React key。锁状态只读取 `lockDetail.locked` 和 `lockDetail.reason`。
|
||||||
|
|
||||||
|
### 3.2 获取历史
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET <API_BASE_URL>/api/chat/history?characterId=maya-tan&limit=50&offset=0
|
GET <API_BASE_URL>/api/chat/history?characterId=maya-tan&limit=50&offset=0
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Query | Type | Required | Rule |
|
| Query | 必填 | 规则 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `characterId` | string | 新前端必传 | 只返回该角色数据 |
|
| `characterId` | 是 | 角色业务 ID |
|
||||||
| `limit` | integer | 否 | `1-200`,默认 `50` |
|
| `limit` | 否 | 前端默认 50 |
|
||||||
| `offset` | integer | 否 | 最小 `0` |
|
| `offset` | 否 | 前端默认 0 |
|
||||||
|
|
||||||
`messages` 和 `total` 都只统计指定角色。切换角色时必须重新请求 history,不能复用上一个角色的本地数组。
|
响应数据包含 `messages`、`total`、`limit`、`offset`,并可包含当前私密额度快照。消息字段:
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|
role, type, content, id, createdAt/created_at, audioUrl, image, lockDetail
|
||||||
"code": 200,
|
|
||||||
"success": true,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"type": "text",
|
|
||||||
"content": "Hello Maya",
|
|
||||||
"id": "<MESSAGE_ID>",
|
|
||||||
"created_at": "2026-07-17T09:00:00Z",
|
|
||||||
"audioUrl": null,
|
|
||||||
"image": {"type": null, "url": null},
|
|
||||||
"lockDetail": {"locked": false, "showContent": true, "showUpgrade": false}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"limit": 50,
|
|
||||||
"offset": 0,
|
|
||||||
"isVip": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Latest Previews
|
前端兼容 `createdAt` 和 `created_at` 输入,解析后统一为 `createdAt`。缺失的可空载荷会由 Schema 归一化,业务组件不直接读取原始 envelope。
|
||||||
|
|
||||||
角色会话列表可一次请求所有可聊天角色的最后一条消息:
|
本地快照可先进入可渲染状态;网络历史随后覆盖缓存并更新 UI。网络同步期间新增的乐观消息按 `displayId` 保留。向上拉取更早页面时也按 `displayId` 去重。
|
||||||
|
|
||||||
|
### 3.3 最新消息预览
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET <API_BASE_URL>/api/chat/previews
|
GET <API_BASE_URL>/api/chat/previews
|
||||||
@@ -105,20 +155,29 @@ Authorization: Bearer <TOKEN>
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 200,
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"items": [
|
"items": [
|
||||||
{"characterId": "elio", "message": {"id": "...", "role": "assistant", "type": "text", "content": "..."}},
|
{
|
||||||
{"characterId": "maya-tan", "message": null}
|
"characterId": "elio",
|
||||||
]
|
"message": {
|
||||||
|
"id": "<MESSAGE_ID>",
|
||||||
|
"role": "assistant",
|
||||||
|
"type": "text",
|
||||||
|
"content": "How was your day?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": "maya-tan",
|
||||||
|
"message": null
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
锁定私密文本不会泄露内容,锁定语音不会返回 `audioUrl`(文字仍正常返回)。图片沿用现有协议,URL 可以存在;前端必须以 `lockDetail.locked=true` 覆盖展示,不能把“URL 非空”当作已解锁。
|
接口可用时,Splash 以批量结果更新各角色预览;接口失败时,当前角色可回退到 `history?limit=1&offset=0`。预览缓存仍使用角色会话身份隔离。
|
||||||
|
|
||||||
## 7. Unlock One Message
|
## 4. 解锁协议
|
||||||
|
|
||||||
|
### 4.1 解锁单条消息
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST <API_BASE_URL>/api/chat/unlock-private
|
POST <API_BASE_URL>/api/chat/unlock-private
|
||||||
@@ -126,84 +185,144 @@ Content-Type: application/json
|
|||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
已有后端消息:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"characterId": "maya-tan",
|
"characterId": "maya-tan",
|
||||||
"messageId": "<MESSAGE_ID>",
|
"messageId": "<MESSAGE_ID>"
|
||||||
"lockType": "voice_message",
|
|
||||||
"clientLockId": "maya-card-001"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`messageId` 存在时,后端会核对消息角色。拿 Elio 的 `messageId` 配 Maya 的 `characterId` 会返回 HTTP `409 / CHARACTER_MISMATCH`,且不会扣积分。
|
前端 Promotion 锁卡片:
|
||||||
|
|
||||||
前端临时伪造锁卡片没有 `messageId` 时,仍传 `characterId + lockType + clientLockId`。`clientLockId` 的幂等查找范围已包含账号和角色,同一个 ID 可以在不同角色下分别使用。
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "maya-tan",
|
||||||
|
"lockType": "voice_message",
|
||||||
|
"clientLockId": "<STABLE_CLIENT_LOCK_ID>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 8. Unlock Current Character History
|
请求必须包含 `characterId`,并至少包含 `messageId` 或 `lockType`。标准 `lockType` 为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
voice_message
|
||||||
|
image_paywall
|
||||||
|
private_message
|
||||||
|
```
|
||||||
|
|
||||||
|
前端解析的响应字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unlocked, content, messageId, clientLockId, lockType, audioUrl, image,
|
||||||
|
reason, creditBalance, creditsCharged, requiredCredits, shortfallCredits
|
||||||
|
```
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
|
||||||
|
1. `unlocked=true` 时更新内容、音频、图片和锁状态;
|
||||||
|
2. `unlocked=false` 时保持锁定,并根据 `reason` 决定是否打开支付流程;
|
||||||
|
3. `reason=not_found` 不打开支付流程;
|
||||||
|
4. 响应 `messageId` 只写入 `remoteId`,不得替换 `displayId`;
|
||||||
|
5. Promotion 重试继续使用同一个 `clientLockId`;
|
||||||
|
6. 跨支付、登录和图片 Overlay 恢复时同时保存显示身份与后端身份。
|
||||||
|
|
||||||
|
### 4.2 解锁当前角色历史
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST <API_BASE_URL>/api/chat/unlock-history
|
POST <API_BASE_URL>/api/chat/unlock-history
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"characterId":"maya-tan"}
|
{"characterId":"maya-tan"}
|
||||||
```
|
```
|
||||||
|
|
||||||
费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。
|
请求和返回消息都限定当前角色。前端完成解锁后重新读取该角色历史,不复用其他角色 Actor 的消息。
|
||||||
|
|
||||||
## 9. Guest History Sync
|
### 4.3 角色错误
|
||||||
|
|
||||||
```http
|
前端识别以下后端错误码:
|
||||||
POST <API_BASE_URL>/api/chat/sync
|
|
||||||
Content-Type: application/json
|
| 错误码 | 前端行为 |
|
||||||
Authorization: Bearer <LOGIN_TOKEN>
|
| --- | --- |
|
||||||
|
| `CHARACTER_DISABLED` | 禁止继续发送并刷新角色目录 |
|
||||||
|
| `CHARACTER_NOT_FOUND` | 清理待恢复导航、刷新目录并返回默认角色 |
|
||||||
|
| `CHARACTER_MISMATCH` | 不进入支付,刷新当前角色历史 |
|
||||||
|
| `CHARACTER_STATE_UNAVAILABLE` | 保留会话并提示稍后重试 |
|
||||||
|
|
||||||
|
任何角色错误都不得在前端回退为另一个角色的历史。
|
||||||
|
|
||||||
|
## 5. UI 消息身份协议
|
||||||
|
|
||||||
|
网络字段继续使用 `messageId`;前端 UI 严格区分三种身份:
|
||||||
|
|
||||||
|
| 字段 | 用途 | 是否稳定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `displayId` | React key、DOM 定位、Overlay URL、解锁目标 | 消息生命周期内必须稳定 |
|
||||||
|
| `remoteId` | 后端 `messageId`、媒体缓存、解锁 API | 后端返回后可补充 |
|
||||||
|
| `clientId` | 乐观发送消息或无后端 ID 的回复 | 创建时生成 |
|
||||||
|
|
||||||
|
标准 `displayId`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
历史消息 server:{encodedRemoteId}:{assistant|user}
|
||||||
|
同角色重复 ID server:{encodedRemoteId}:{role}:{occurrence}
|
||||||
|
无后端 ID 历史 legacy:{stableHash}
|
||||||
|
乐观文本 client:message:{uuid}
|
||||||
|
乐观图片 client:image:{uuid}
|
||||||
|
无 ID 回复 client:reply:{uuid}
|
||||||
|
错误提示 client:error:{uuid}
|
||||||
|
空会话问候语 greeting:{characterId}
|
||||||
|
Promotion promotion:{clientLockId}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
同一个后端 ID 可能同时出现在 user 和 assistant 记录中,因此不得直接使用 `remoteId` 作为 React key。解锁前后 `displayId` 不变;图片 Overlay 始终写入 `displayId`,但读取时兼容旧的 `remoteId` URL。
|
||||||
{
|
|
||||||
"characterId": "maya-tan",
|
## 6. 本地缓存与会话隔离
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "Hello", "timestamp": "2026-07-17T09:00:00Z"},
|
缓存 owner 规则:
|
||||||
{"role": "assistant", "content": "Hi", "timestamp": "2026-07-17T09:00:01Z"}
|
|
||||||
]
|
```text
|
||||||
}
|
正式用户或已有用户 ID 的会话 user:{userId}
|
||||||
|
没有用户 ID 的 Guest 会话 device:{deviceId}
|
||||||
```
|
```
|
||||||
|
|
||||||
每次同步只能包含一个角色。本地若保存了多个角色,前端按角色分组分别调用。
|
正式账号缺少持久化用户 ID 时跳过 Chat 缓存,不得回退到设备级或匿名缓存。
|
||||||
|
|
||||||
若页面使用用户聊天统计,也必须带角色:
|
消息和媒体共同使用会话键:
|
||||||
|
|
||||||
```http
|
```text
|
||||||
GET <API_BASE_URL>/api/user/stats?characterId=maya-tan
|
conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
|
||||||
```
|
```
|
||||||
|
|
||||||
其中 `totalMessages`、近期记忆、亲密度、情绪和关系状态都按角色返回。
|
媒体键在该会话下继续包含后端消息 ID 和媒体类型:
|
||||||
响应会明确带上 `characterId`、`relationshipStage` 和 `currentMood`,前端不要把这些状态写回其他角色的本地缓存。
|
|
||||||
|
|
||||||
## 12. Tip Attribution
|
```text
|
||||||
|
{conversationKey}:{remoteId}:{image|audio}
|
||||||
只有 Tip 订单使用角色归属:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"planId": "tip_coffee_usd_4_99",
|
|
||||||
"payChannel": "stripe",
|
|
||||||
"autoRenew": false,
|
|
||||||
"recipientCharacterId": "maya-tan"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段。
|
旧的无角色 owner key 迁移到 Elio;包含旧角色 ID 的 key 迁移到当前后端 ID。迁移是幂等的,不能覆盖已经存在的当前命名空间数据。
|
||||||
|
|
||||||
## 13. Failures
|
切换用户、Guest/正式账号或角色时,不得复用前一个 `conversationKey`。切换角色会销毁旧 Chat Actor;请求取消信号会阻止旧响应写入新角色状态。
|
||||||
|
|
||||||
| HTTP | `detail.errorCode` | Meaning | Frontend action |
|
## 7. 关联业务边界
|
||||||
| ---: | --- | --- | --- |
|
|
||||||
| 403 | `CHARACTER_DISABLED` | 角色或对应能力未开放 | 禁用输入并刷新角色目录 |
|
|
||||||
| 404 | `CHARACTER_NOT_FOUND` | 角色 ID 不存在 | 清理本地旧角色 ID,回角色列表 |
|
|
||||||
| 409 | `CHARACTER_MISMATCH` | 消息或双参数属于不同角色 | 不重试、不扣费,刷新当前角色历史 |
|
|
||||||
| 503 | `CHARACTER_STATE_UNAVAILABLE` | 角色会话状态暂不可用 | 保留输入并稍后重试 |
|
|
||||||
|
|
||||||
新前端的角色业务请求必须显式携带角色 ID。应用入口未指定角色时选择 Elio;显式错误、停用或不匹配的角色不会回退 Elio。
|
- Private Zone 的相册、解锁、Gallery 和支付回跳由 [Private Zone 权威协议](./FRONTEND_PRIVATE_ZONE_API.md) 定义。
|
||||||
|
- Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。
|
||||||
|
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
||||||
|
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
||||||
|
|
||||||
|
## 8. 变更验收
|
||||||
|
|
||||||
|
协议相关变更至少验证:
|
||||||
|
|
||||||
|
1. Character、Chat、Storage、Navigation 和 Payment 的 TypeScript 类型检查;
|
||||||
|
2. `src/data/services/api/__tests__/multi_character_api.test.ts`;
|
||||||
|
3. `src/stores/chat/__tests__`;
|
||||||
|
4. `src/data/storage/chat/__tests__` 与 `src/data/storage/navigation/__tests__`;
|
||||||
|
5. `src/app/chat/__tests__` 及 Chat 组件测试;
|
||||||
|
6. 同一用户的不同角色、Guest 与正式账号、账号切换后的历史和媒体均不串联;
|
||||||
|
7. 快速切换角色时,旧请求不能覆盖新 Actor;
|
||||||
|
8. 动态 URL、旧地址重定向、登录和支付回跳恢复正确角色;
|
||||||
|
9. 解锁后 `displayId` 不变,`remoteId` 可从响应补齐。
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# CozSweet Payment 权威协议
|
||||||
|
|
||||||
|
## 1. 状态与范围
|
||||||
|
|
||||||
|
本文是前端仓库中 VIP、Top-up、Tip、支付渠道、订单轮询和支付回跳的唯一人工维护协议。旧的 Tip 成功结果扩展文档已删除,不再单独定义 Payment 行为。
|
||||||
|
|
||||||
|
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
|
||||||
|
|
||||||
|
| 边界 | 实现位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
|
| 请求与响应字段 | `src/data/schemas/payment` |
|
||||||
|
| API 调用 | `src/data/services/api/payment_api.ts` |
|
||||||
|
| Payment 状态机 | `src/stores/payment` |
|
||||||
|
| 支付拉起与回跳 | `src/app/_hooks/use-payment-launch-flow.ts`、`src/lib/payment` |
|
||||||
|
| Subscription 页面 | `src/app/subscription` |
|
||||||
|
| Tip 页面 | `src/app/tip` |
|
||||||
|
|
||||||
|
修改上述实现时必须在同一变更中更新本文,不能再新增按支付页面或支付渠道拆分的并行协议。
|
||||||
|
|
||||||
|
## 2. API 总览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 |
|
||||||
|
| `GET` | `/api/payment/gift-products?characterId={id}` | 当前角色的完整礼物目录 |
|
||||||
|
| `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 |
|
||||||
|
| `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 |
|
||||||
|
| `POST` | `/api/payment/tip-message` | 获取已支付礼物订单的稳定感谢文案 |
|
||||||
|
|
||||||
|
所有响应先由通用 envelope 解包,再进入 Payment Schema。页面和状态机不直接读取原始 envelope。
|
||||||
|
|
||||||
|
## 3. 套餐目录
|
||||||
|
|
||||||
|
### 3.1 默认套餐
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/payment/plans
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isFirstRecharge": true,
|
||||||
|
"firstRechargeOffer": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "topup",
|
||||||
|
"discountPercent": 50
|
||||||
|
},
|
||||||
|
"plans": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plans[]` 的标准字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
planId, planName, orderType, vipDays, dolAmount, creditBalance,
|
||||||
|
amountCents, originalAmountCents, dailyPriceCents, currency,
|
||||||
|
isFirstRechargeOffer, mostPopular, firstRechargeDiscountPercent,
|
||||||
|
promotionType
|
||||||
|
```
|
||||||
|
|
||||||
|
默认目录写入本地套餐缓存。Payment Actor 启动时先读缓存;有缓存则先渲染并后台刷新,没有缓存则直接请求网络。
|
||||||
|
|
||||||
|
支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。
|
||||||
|
|
||||||
|
### 3.2 Gift Products 目录
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/payment/gift-products?characterId=elio
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "elio",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"category": "coffee",
|
||||||
|
"name": "Coffee",
|
||||||
|
"productCount": 1,
|
||||||
|
"imageUrl": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plans": [
|
||||||
|
{
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"planName": "Velvet Espresso",
|
||||||
|
"orderType": "tip",
|
||||||
|
"tipType": "coffee_small",
|
||||||
|
"category": "coffee",
|
||||||
|
"characterId": "elio",
|
||||||
|
"description": "Buy Elio a small coffee",
|
||||||
|
"imageUrl": null,
|
||||||
|
"amountCents": 499,
|
||||||
|
"currency": "USD",
|
||||||
|
"autoRenew": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
目录不需要登录,也不写入默认套餐缓存。前端必须传当前角色 ID,一次读取 `categories` 和全部 `plans`;当前 Tip 页面固定使用第一分类,并按商品原始顺序默认选择第一件商品,不展示分类切换栏。
|
||||||
|
|
||||||
|
名称、说明、图片、金额、币种和 `planId` 全部来自同一条商品数据。状态机把当前分类商品映射为 `PaymentPlan` 以复用 Checkout 和埋点,同时保留完整 Gift Product 供 UI 展示。目录为空或请求失败时禁止创建订单,不回退到本地固定商品或价格。
|
||||||
|
|
||||||
|
## 4. 创建订单
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/payment/create-order
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
标准请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"payChannel": "stripe",
|
||||||
|
"autoRenew": false,
|
||||||
|
"recipientCharacterId": "maya-tan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `planId` | 必须来自当前 Actor 已加载的目录 |
|
||||||
|
| `payChannel` | `stripe` 或 `ezpay` |
|
||||||
|
| `autoRenew` | Tip 和一次性 Top-up 为 `false`;VIP 根据套餐和用户选择决定 |
|
||||||
|
| `recipientCharacterId` | Schema 可选;当前 Tip 页面必传角色业务 ID,VIP 和 Top-up 不传 |
|
||||||
|
|
||||||
|
Payment Actor 只有在 `selectedPlanId` 非空且用户已同意协议时创建订单。套餐加载、创建和轮询期间页面会禁用重复提交;Tip 在 paid 状态展示成功页,Subscription 在 paid 状态展示成功 Dialog,随后通过 reset 明确开始下一笔订单。
|
||||||
|
|
||||||
|
创建订单响应统一归一化为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CreatePaymentOrderResponse {
|
||||||
|
orderId: string;
|
||||||
|
payParams: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端可以把支付 URL 放在 `payParams`,也可以使用以下顶层兼容字段;Schema 会把非空值合并进 `payParams`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cashierUrl/cashier_url
|
||||||
|
checkoutUrl/checkout_url
|
||||||
|
paymentUrl/payment_url
|
||||||
|
approvalUrl/approval_url
|
||||||
|
redirectUrl/redirect_url
|
||||||
|
url
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 支付渠道与拉起方式
|
||||||
|
|
||||||
|
### 5.1 渠道选择
|
||||||
|
|
||||||
|
| 环境与地区 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 生产环境、菲律宾 | 可选择 Stripe 或 Ezpay,默认 Ezpay |
|
||||||
|
| 生产环境、其他地区 | 强制 Stripe,不展示渠道选择器 |
|
||||||
|
| 非生产环境 | 允许选择两个渠道;菲律宾默认 Ezpay,其他地区默认 Stripe |
|
||||||
|
|
||||||
|
当生产环境不允许选择渠道时,URL 中请求的 `payChannel` 不生效。
|
||||||
|
|
||||||
|
### 5.2 Stripe
|
||||||
|
|
||||||
|
`payParams.clientSecret` 或 `payParams.client_secret` 存在,且 provider 未声明为非 Stripe 时,前端动态加载嵌入式 Stripe Dialog。
|
||||||
|
|
||||||
|
关闭尚未支付的 Stripe Dialog 会重置当前订单状态;确认支付后隐藏 Dialog,并继续由 Payment Actor 轮询订单状态。
|
||||||
|
|
||||||
|
### 5.3 Ezpay 与其他跳转支付
|
||||||
|
|
||||||
|
`payParams.provider="ezpay"` 且存在支付 URL 时:
|
||||||
|
|
||||||
|
- 生产环境先保存待恢复订单,然后直接跳转外部支付页;
|
||||||
|
- 非生产环境先显示确认 Dialog,确认后保存并跳转;
|
||||||
|
- 待恢复订单保存失败时不得离开当前页面。
|
||||||
|
|
||||||
|
存在支付 URL 但 provider 不是 Ezpay 时,前端直接设置 `window.location.href`。既没有 Stripe client secret 也没有支付 URL 时,订单进入失败状态并展示参数错误。
|
||||||
|
|
||||||
|
## 6. 订单状态轮询
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/payment/order-status?order_id=<ORDER_ID>
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderId": "tip_order_123",
|
||||||
|
"status": "paid",
|
||||||
|
"orderType": "tip",
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"creditsAdded": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 前端规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` | 只接受 `pending`、`paid`、`failed`、`expired` |
|
||||||
|
| `planId` | 可为 `null` |
|
||||||
|
| `creditsAdded` | 整数;Tip 固定为 `0` |
|
||||||
|
|
||||||
|
状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟:
|
||||||
|
|
||||||
|
- `paid`:进入最终成功状态并停止轮询;
|
||||||
|
- `failed`:进入失败状态并停止轮询;
|
||||||
|
- `expired`:进入订单过期状态、销毁旧支付参数并停止轮询;
|
||||||
|
- 超过 5 分钟:本地标记为失败并显示超时错误;
|
||||||
|
- 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。
|
||||||
|
|
||||||
|
## 7. 外部支付回跳
|
||||||
|
|
||||||
|
只有 Ezpay 跳转需要持久化 `PendingPaymentOrder`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
orderId
|
||||||
|
payChannel = ezpay
|
||||||
|
subscriptionType = vip | topup | tip
|
||||||
|
giftCategory(仅 Tip,可空)
|
||||||
|
giftPlanId(仅 Tip,可空)
|
||||||
|
returnTo = chat | private-zone | profile(可选)
|
||||||
|
characterSlug(可选)
|
||||||
|
createdAt
|
||||||
|
```
|
||||||
|
|
||||||
|
`/subscription/return` 读取该记录并恢复到对应入口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1
|
||||||
|
Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&paymentReturn=1
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`。
|
||||||
|
|
||||||
|
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-zone` 的最终页面行为由 [Private Zone 权威协议](./FRONTEND_PRIVATE_ZONE_API.md) 定义。
|
||||||
|
|
||||||
|
## 8. 成功后的跨域同步
|
||||||
|
|
||||||
|
`PaymentSuccessSync` 在每个订单首次进入 paid 时:
|
||||||
|
|
||||||
|
1. 消费当前 Actor 的首充展示状态;
|
||||||
|
2. 清除默认套餐缓存;
|
||||||
|
3. 派发 `UserFetch` 刷新积分、VIP 和权益。
|
||||||
|
|
||||||
|
Chat 路由额外挂载 `ChatPaymentSuccessSync`。如果当前角色没有待恢复的单消息解锁,它会派发 `ChatPaymentSucceeded`,由 Chat 决定是否展示历史解锁提示;存在待恢复单消息解锁时,由原解锁流程接管。
|
||||||
|
|
||||||
|
Subscription 显示成功 Dialog,关闭后根据 `returnTo` 和原角色 slug 返回。Tip 直接显示角色成功页,重置后可以再次创建订单。
|
||||||
|
|
||||||
|
## 9. Tip 成功结果
|
||||||
|
|
||||||
|
Tip 订单进入 paid 后调用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/payment/tip-message
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
|
||||||
|
{"orderId":"pay_xxx"}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应包含 `orderId`、`characterId`、`planId`、`productName`、`tipCount`、`poolIndex` 和可直接展示的完整 `message`。`tipCount` 表示当前付款身份对同一商品的累计成功次数。
|
||||||
|
|
||||||
|
Tip 成功页在文案加载期间立即确认支付成功。`tipCount=1` 时展示固定首次咖啡文案并忽略后端 `message`;大于 1 时按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单。
|
||||||
|
|
||||||
|
## 10. Provider 与状态边界
|
||||||
|
|
||||||
|
- `/subscription` 使用独立 Payment Actor;
|
||||||
|
- `/characters/{slug}/tip` 使用以 `characterId` 为 key 的 Payment Actor;
|
||||||
|
- Chat 路由使用角色级 Payment Actor,以便支付成功桥接当前 Chat;
|
||||||
|
- 切换角色或离开对应 Provider 后,不得把旧订单状态显示到另一个角色页面;
|
||||||
|
- Payment Actor 只根据订单状态接口判断最终结果,不根据支付 Dialog 或外部跳转本身推断已扣款。
|
||||||
|
|
||||||
|
## 11. 变更验收
|
||||||
|
|
||||||
|
Payment 协议相关变更至少验证:
|
||||||
|
|
||||||
|
1. `src/data/services/api/__tests__/payment_api.test.ts`;
|
||||||
|
2. `src/data/repositories/__tests__/payment_repository.test.ts`;
|
||||||
|
3. `src/stores/payment/__tests__`;
|
||||||
|
4. `src/lib/payment/__tests__`;
|
||||||
|
5. `src/app/_hooks/__tests__` 中的 Payment 流程测试;
|
||||||
|
6. `src/app/subscription/__tests__` 与 `src/app/tip/__tests__`;
|
||||||
|
7. Stripe、Ezpay、paid、failed、expired、timeout 和回跳恢复路径;
|
||||||
|
8. Tip 创建订单携带当前角色 ID,VIP/Top-up 不携带;
|
||||||
|
9. Tip Message 成功、失败和重试不重复创建订单;
|
||||||
|
10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# CozSweet Private Zone 权威协议
|
||||||
|
|
||||||
|
## 1. 状态与范围
|
||||||
|
|
||||||
|
本文是前端仓库中角色私密空间、相册列表、相册解锁、Gallery 和积分不足导航的唯一人工维护协议。
|
||||||
|
|
||||||
|
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
|
||||||
|
|
||||||
|
| 边界 | 实现位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
|
| 请求与响应字段 | `src/data/schemas/private-zone` |
|
||||||
|
| API 与 Repository | `src/data/services/api/private_zone_api.ts`、`src/data/repositories/private_zone_repository.ts` |
|
||||||
|
| Private Zone 状态机 | `src/stores/private-zone` |
|
||||||
|
| 页面、Gallery 与导航 | `src/app/private-zone` |
|
||||||
|
| 角色 Provider | `src/providers/private-zone-route-provider.tsx` |
|
||||||
|
|
||||||
|
修改上述实现时必须在同一变更中更新本文,不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。
|
||||||
|
|
||||||
|
## 2. 角色与路由边界
|
||||||
|
|
||||||
|
标准路由:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/characters/{characterSlug}/private-zone
|
||||||
|
```
|
||||||
|
|
||||||
|
旧地址保留为默认角色重定向,并保留查询参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/private-zone -> /characters/elio/private-zone
|
||||||
|
```
|
||||||
|
|
||||||
|
URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`:
|
||||||
|
|
||||||
|
| `id` | `slug` |
|
||||||
|
| --- | --- |
|
||||||
|
| `elio` | `elio` |
|
||||||
|
| `maya-tan` | `maya` |
|
||||||
|
| `nayeli-cervantes` | `nayeli` |
|
||||||
|
|
||||||
|
角色必须同时存在于本地目录且 `capabilities.privateZone=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。
|
||||||
|
|
||||||
|
`PrivateZoneProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。
|
||||||
|
|
||||||
|
## 3. 相册列表
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/private-zone/albums?characterId=maya-tan&limit=20
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Query | 必填 | 前端规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `characterId` | 是 | 当前角色业务 ID |
|
||||||
|
| `limit` | 否 | 当前固定为 20 |
|
||||||
|
|
||||||
|
前端当前只加载第一页,不实现 Private Zone 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。
|
||||||
|
|
||||||
|
标准响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"albumId": "album-1",
|
||||||
|
"title": "A quiet afternoon",
|
||||||
|
"content": "I saved these for you.",
|
||||||
|
"previewText": "Unlock to view",
|
||||||
|
"imageCount": 3,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/private/cover.jpg",
|
||||||
|
"locked": true,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locked": true,
|
||||||
|
"unlocked": false,
|
||||||
|
"unlockCost": 40,
|
||||||
|
"publishedAt": "2026-07-20T09:00:00Z",
|
||||||
|
"lockDetail": {
|
||||||
|
"locked": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"creditBalance": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端将缺失或非法的可空字段按 Schema 默认值归一化。页面不直接读取原始响应 envelope。
|
||||||
|
|
||||||
|
相册只要满足任意条件即视为锁定:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
album.locked || !album.unlocked || album.lockDetail.locked
|
||||||
|
```
|
||||||
|
|
||||||
|
因此不能只根据图片 URL 是否存在判断已解锁。锁定相册允许存在封面 URL,但只能显示锁定预览,不能打开 Gallery。
|
||||||
|
|
||||||
|
## 4. 解锁相册
|
||||||
|
|
||||||
|
### 4.1 请求
|
||||||
|
|
||||||
|
用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中,才会发起请求。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/private-zone/albums/{albumId}/unlock
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expectedCost": 40
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`albumId` 来自当前角色列表;请求 body 只发送用户确认时看到的 `expectedCost`,不重复发送 `characterId`。后端通过 `albumId` 确定相册与角色归属。
|
||||||
|
|
||||||
|
### 4.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"albumId": "album-1",
|
||||||
|
"locked": false,
|
||||||
|
"unlocked": true,
|
||||||
|
"reason": "ok",
|
||||||
|
"unlockCost": 40,
|
||||||
|
"requiredCredits": 40,
|
||||||
|
"creditBalance": 60,
|
||||||
|
"shortfallCredits": 0,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/private/photo-1.jpg",
|
||||||
|
"locked": false,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端识别的标准 reason:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ok
|
||||||
|
already_unlocked
|
||||||
|
insufficient_credits
|
||||||
|
cost_changed
|
||||||
|
unlock_in_progress
|
||||||
|
deduct_failed
|
||||||
|
persist_failed_refunded
|
||||||
|
not_found
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema 同时允许未知字符串,未知失败原因使用通用错误文案。
|
||||||
|
|
||||||
|
### 4.3 状态机处理
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `unlocked=true` 且 `locked=false` | 更新相册、余额和图片;增加成功 nonce;刷新用户权益 |
|
||||||
|
| `reason=insufficient_credits` | 更新相册与余额,生成 Paywall 请求 |
|
||||||
|
| `reason=cost_changed` | 清除确认状态、展示价格变化错误并重新加载列表 |
|
||||||
|
| `reason=not_found` | 从当前列表移除相册并展示错误 |
|
||||||
|
| 其他业务失败 | 使用响应补丁更新相册,并展示对应或通用错误 |
|
||||||
|
| 请求异常 | 保留列表,清除进行中状态并展示异常信息 |
|
||||||
|
|
||||||
|
响应补丁只替换匹配 `albumId` 的相册:
|
||||||
|
|
||||||
|
- `locked`、`unlocked` 使用响应值;
|
||||||
|
- 非零 `unlockCost` 更新当前价格,否则保留列表价格;
|
||||||
|
- 非空 `images` 更新图片,否则保留列表图片;
|
||||||
|
- `lockDetail.locked` 与响应 `locked` 对齐。
|
||||||
|
|
||||||
|
解锁请求进行期间确认按钮保持禁用,避免同一个 Actor 重复提交。
|
||||||
|
|
||||||
|
## 5. 身份与支付导航
|
||||||
|
|
||||||
|
Private Zone 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。
|
||||||
|
|
||||||
|
积分不足生成 Paywall 请求后:
|
||||||
|
|
||||||
|
| 当前身份 | 导航 |
|
||||||
|
| --- | --- |
|
||||||
|
| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Zone |
|
||||||
|
| 已认证用户 | 打开 Top-up,`returnTo=private-zone`,保留当前角色来源 |
|
||||||
|
|
||||||
|
Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。
|
||||||
|
|
||||||
|
解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Zone 不自行修改 User Store 余额。
|
||||||
|
|
||||||
|
## 6. Gallery URL 协议
|
||||||
|
|
||||||
|
解锁相册使用查询参数打开页内 Gallery:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/characters/{slug}/private-zone?album={albumId}&image={zeroBasedIndex}
|
||||||
|
```
|
||||||
|
|
||||||
|
解析规则:
|
||||||
|
|
||||||
|
- `album` 必须是非空字符串;
|
||||||
|
- `image` 必须是大于等于 0 的整数,缺失时使用 0;
|
||||||
|
- 相册必须仍在当前角色列表中;
|
||||||
|
- 相册必须已解锁;
|
||||||
|
- 对应图片必须存在非空 URL。
|
||||||
|
|
||||||
|
任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。
|
||||||
|
|
||||||
|
页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Zone。
|
||||||
|
|
||||||
|
Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针,达到视口宽度 18%(最低 56px),或达到 0.45px/ms 且至少移动 24px 时切换;首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画,键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭;`prefers-reduced-motion` 下取消吸附过渡。
|
||||||
|
|
||||||
|
## 7. UI 与数据边界
|
||||||
|
|
||||||
|
- React key 使用稳定 `albumId`;
|
||||||
|
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`;
|
||||||
|
- 锁定相册显示模糊封面、角色头像、锁标识、图片数量和 `View collection` 入口;卡片不展示视频数量或积分价格,解锁价格只在确认 Dialog 中展示;
|
||||||
|
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
|
||||||
|
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
|
||||||
|
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
|
||||||
|
- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
|
||||||
|
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
|
||||||
|
- Private Zone 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
|
||||||
|
- Private Zone 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
||||||
|
|
||||||
|
## 8. 变更验收
|
||||||
|
|
||||||
|
Private Zone 协议相关变更至少验证:
|
||||||
|
|
||||||
|
1. `src/data/schemas/private-zone/__tests__`;
|
||||||
|
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Zone 请求;
|
||||||
|
3. `src/stores/private-zone/__tests__`;
|
||||||
|
4. `src/app/private-zone/__tests__` 与组件测试;
|
||||||
|
5. Elio、Maya、Nayeli 列表互不串联;
|
||||||
|
6. 登录身份变化会刷新当前角色列表;
|
||||||
|
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
|
||||||
|
8. Auth、Top-up 和支付成功后返回原角色;
|
||||||
|
9. 锁定相册不能通过 Gallery URL 绕过;
|
||||||
|
10. 切换角色后旧 Actor 的相册与解锁状态不再可见。
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
| Workflow | 触发时机 | 职责 |
|
| Workflow | 触发时机 | 职责 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
| `.gitea/workflows/ci.yml` | `dev`、`main`、`pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||||
| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
| `.gitea/workflows/docker-image.yml` | `main`、`pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||||
|
|
||||||
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
||||||
|
|
||||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test`
|
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
|
||||||
分支为快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||||
`ci.yml` 会同时执行完整检查并提供测试结果。
|
`ci.yml` 会同时执行完整检查并提供测试结果。
|
||||||
|
|
||||||
## 必需 Secrets
|
## 必需 Secrets
|
||||||
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
|
|||||||
|
|
||||||
## 镜像 Tag 规则
|
## 镜像 Tag 规则
|
||||||
|
|
||||||
`test` 分支发布:
|
`pre` 分支发布时继续使用现有 `test-*` 运行镜像标签:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
REGISTRY_IMAGE:test-<short_sha>
|
REGISTRY_IMAGE:test-<short_sha>
|
||||||
@@ -81,7 +81,7 @@ volumes:
|
|||||||
|
|
||||||
## 验证方式
|
## 验证方式
|
||||||
|
|
||||||
推送到 `test` 分支后,检查 Actions 日志中是否出现:
|
推送到 `pre` 分支后,检查 Actions 日志中是否出现:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Build Docker image
|
Build Docker image
|
||||||
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
|
|||||||
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
||||||
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
||||||
|
|
||||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ https://<APP_HOST>/external-entry?<参数>
|
|||||||
|
|
||||||
| 参数 | 可选值或格式 | 用途 |
|
| 参数 | 可选值或格式 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `target` | `chat`、`tip`、`private-room` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
| `target` | `chat`、`tip`、`private-zone` | 指定进入的页面;不传或值无效时进入聊天页。历史值 `private-room` 兼容进入 `private-zone`,新链接不得继续使用。 |
|
||||||
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
| `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 |
|
||||||
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
| `psid` | string | 传入 Facebook Page-scoped User ID。 |
|
||||||
| `mode` | `promotion` | 开启聊天促销模式。 |
|
| `mode` | `promotion` | 开启聊天促销模式。 |
|
||||||
@@ -27,6 +27,8 @@ https://<APP_HOST>/external-entry?<参数>
|
|||||||
|
|
||||||
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
|
`mode=promotion` 只有在 `target=chat` 且同时提供有效的 `promotion_type` 时生效。
|
||||||
|
|
||||||
|
标准私密空间参数是 `target=private-zone`。前端仅为已经发布的旧链接保留 `target=private-room` 兼容;`private_zone` 等其他拼写仍按无效目标处理并回退聊天页。
|
||||||
|
|
||||||
## PSID 与登录状态流程
|
## PSID 与登录状态流程
|
||||||
|
|
||||||
PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
PSID 保存、登录状态判断和 Facebook Identity 绑定逻辑见 [PSID 与登录状态流程图](./psid-login-flow.md)。
|
||||||
@@ -78,15 +80,18 @@ https://<APP_HOST>/external-entry?target=tip
|
|||||||
私密空间:
|
私密空间:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<APP_HOST>/external-entry?target=private-room
|
https://<APP_HOST>/external-entry?target=private-zone
|
||||||
```
|
```
|
||||||
|
|
||||||
进入 Nayeli 私密空间:
|
进入 Nayeli 私密空间:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<APP_HOST>/external-entry?target=private-room&character=nayeli
|
https://<APP_HOST>/external-entry?target=private-zone&character=nayeli
|
||||||
```
|
```
|
||||||
|
|
||||||
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
|
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
|
||||||
|
|
||||||
测试环境和正式环境的完整可点击示例见 [外部入口链接清单](./links.md)。
|
完整可点击示例:
|
||||||
|
|
||||||
|
- [本地调试链接清单](./debug-links.md)
|
||||||
|
- [测试环境和正式环境链接清单](./links.md)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 外部入口本地调试链接清单
|
||||||
|
|
||||||
|
[返回外部入口接入说明](./README.md)
|
||||||
|
|
||||||
|
启动本地应用后,可以直接点击以下链接进行调试。根网址为 `http://localhost:3000`,PSID 示例统一使用 `27511427698460020`。
|
||||||
|
|
||||||
|
## 默认入口
|
||||||
|
|
||||||
|
| 入口 | 链接 |
|
||||||
|
| --- | --- |
|
||||||
|
| 默认入口(Elio 聊天) | [打开默认入口](http://localhost:3000/external-entry) |
|
||||||
|
| 普通聊天 | [打开普通聊天](http://localhost:3000/external-entry?target=chat) |
|
||||||
|
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](http://localhost:3000/external-entry?target=chat&psid=27511427698460020) |
|
||||||
|
|
||||||
|
## 角色聊天
|
||||||
|
|
||||||
|
| 角色 | 链接 |
|
||||||
|
| --- | --- |
|
||||||
|
| Elio | [打开 Elio 聊天](http://localhost:3000/external-entry?target=chat&character=elio) |
|
||||||
|
| Maya | [打开 Maya 聊天](http://localhost:3000/external-entry?target=chat&character=maya) |
|
||||||
|
| Nayeli | [打开 Nayeli 聊天](http://localhost:3000/external-entry?target=chat&character=nayeli) |
|
||||||
|
|
||||||
|
## 聊天促销
|
||||||
|
|
||||||
|
| 促销类型 | 链接 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语音促销 | [打开语音促销](http://localhost:3000/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
||||||
|
| 图片促销 | [打开图片促销](http://localhost:3000/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
||||||
|
| 私密文本促销 | [打开私密文本促销](http://localhost:3000/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
||||||
|
|
||||||
|
## 咖啡打赏
|
||||||
|
|
||||||
|
| 角色 | 链接 |
|
||||||
|
| --- | --- |
|
||||||
|
| Elio | [打开 Elio 咖啡打赏](http://localhost:3000/external-entry?target=tip&character=elio) |
|
||||||
|
| Maya | [打开 Maya 咖啡打赏](http://localhost:3000/external-entry?target=tip&character=maya) |
|
||||||
|
| Nayeli | [打开 Nayeli 咖啡打赏](http://localhost:3000/external-entry?target=tip&character=nayeli) |
|
||||||
|
|
||||||
|
## 私密空间
|
||||||
|
|
||||||
|
| 角色 | 链接 |
|
||||||
|
| --- | --- |
|
||||||
|
| Elio | [打开 Elio 私密空间](http://localhost:3000/external-entry?target=private-zone&character=elio) |
|
||||||
|
| Maya | [打开 Maya 私密空间](http://localhost:3000/external-entry?target=private-zone&character=maya) |
|
||||||
|
| Nayeli | [打开 Nayeli 私密空间](http://localhost:3000/external-entry?target=private-zone&character=nayeli) |
|
||||||
|
|
||||||
|
如需为其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。对于不带查询参数的默认入口,请改为追加 `?psid=27511427698460020`。
|
||||||
@@ -1,43 +1,66 @@
|
|||||||
# 外部入口链接清单
|
# 外部入口链接清单
|
||||||
|
|
||||||
[返回外部入口接入说明](./README.md)
|
这份清单可以独立发送和直接点击。所有新链接统一使用标准参数 `target=private-zone`;历史参数 `target=private-room` 仅用于兼容已经发出的旧链接。
|
||||||
|
|
||||||
以下链接可以直接点击打开。PSID 示例统一使用 `27511427698460020`。
|
## 使用规则
|
||||||
|
|
||||||
|
- `target=chat`:进入聊天页。
|
||||||
|
- `target=tip`:进入对应角色的打赏页。
|
||||||
|
- `target=private-zone`:进入对应角色的私密空间。
|
||||||
|
- `character` 可使用 `elio`、`maya`、`nayeli`;不传或值无效时使用 Elio。
|
||||||
|
- `mode=promotion` 只在 `target=chat` 且 `promotion_type` 为 `voice`、`image` 或 `private` 时生效。
|
||||||
|
- PSID 示例统一使用 `27511427698460020`;不要在入口中放登录 Token、Page Access Token 或 App Secret。
|
||||||
|
|
||||||
## 测试环境
|
## 测试环境
|
||||||
|
|
||||||
| 入口 | 链接 |
|
| 入口 | 可点击链接 | 预期落地页面 |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| 普通聊天 | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) |
|
| 普通聊天(Elio) | [打开普通聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat) | `/characters/elio/chat` |
|
||||||
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) |
|
| Maya 聊天 | [打开 Maya 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=maya) | `/characters/maya/chat` |
|
||||||
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) |
|
| Nayeli 聊天 | [打开 Nayeli 聊天](https://frontend-test.banlv-ai.com/external-entry?target=chat&character=nayeli) | `/characters/nayeli/chat` |
|
||||||
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) |
|
| 携带 PSID 的 Elio 聊天 | [打开 PSID 聊天示例](https://frontend-test.banlv-ai.com/external-entry?target=chat&psid=27511427698460020) | `/characters/elio/chat` |
|
||||||
| 语音促销 | [打开语音促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
| 语音促销 | [打开语音促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=voice) | `/characters/elio/chat`,保存语音促销意图 |
|
||||||
| 图片促销 | [打开图片促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
| 图片促销 | [打开图片促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=image) | `/characters/elio/chat`,保存图片促销意图 |
|
||||||
| 私密文本促销 | [打开私密文本促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
| 私密文本促销 | [打开私密文本促销](https://frontend-test.banlv-ai.com/external-entry?target=chat&mode=promotion&promotion_type=private) | `/characters/elio/chat`,保存私密文本促销意图 |
|
||||||
| 咖啡打赏 | [打开咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip) |
|
| Elio 咖啡打赏 | [打开 Elio 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=elio) | `/characters/elio/tip` |
|
||||||
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) |
|
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) | `/characters/maya/tip` |
|
||||||
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) |
|
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) | `/characters/nayeli/tip` |
|
||||||
| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room) |
|
| 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-room&character=maya) |
|
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=nayeli) |
|
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||||
|
|
||||||
|
## 旧链接兼容验证
|
||||||
|
|
||||||
|
以下链接仅用于验证已经发出的 `private-room` 旧链接仍能正确进入私密空间。新投放链接不要再使用这些地址。
|
||||||
|
|
||||||
|
| 旧入口 | 可点击链接 | 预期落地页面 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Elio 旧私密空间 | [验证 Elio 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=elio) | `/characters/elio/private-zone` |
|
||||||
|
| Maya 旧私密空间 | [验证 Maya 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=maya) | `/characters/maya/private-zone` |
|
||||||
|
| Nayeli 旧私密空间 | [验证 Nayeli 旧链接](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||||
|
|
||||||
## 正式环境
|
## 正式环境
|
||||||
|
|
||||||
| 入口 | 链接 |
|
正式环境仅列标准链接,不再传播 `private-room` 旧参数。
|
||||||
| --- | --- |
|
|
||||||
| 普通聊天 | [打开普通聊天](https://cozsweet.com/external-entry?target=chat) |
|
|
||||||
| Maya 聊天 | [打开 Maya 聊天](https://cozsweet.com/external-entry?target=chat&character=maya) |
|
|
||||||
| Nayeli 聊天 | [打开 Nayeli 聊天](https://cozsweet.com/external-entry?target=chat&character=nayeli) |
|
|
||||||
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](https://cozsweet.com/external-entry?target=chat&psid=27511427698460020) |
|
|
||||||
| 语音促销 | [打开语音促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=voice) |
|
|
||||||
| 图片促销 | [打开图片促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=image) |
|
|
||||||
| 私密文本促销 | [打开私密文本促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=private) |
|
|
||||||
| 咖啡打赏 | [打开咖啡打赏](https://cozsweet.com/external-entry?target=tip) |
|
|
||||||
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=maya) |
|
|
||||||
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=nayeli) |
|
|
||||||
| 私密空间 | [打开私密空间](https://cozsweet.com/external-entry?target=private-room) |
|
|
||||||
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-room&character=maya) |
|
|
||||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-room&character=nayeli) |
|
|
||||||
|
|
||||||
如需在其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。
|
| 入口 | 可点击链接 | 预期落地页面 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 普通聊天(Elio) | [打开普通聊天](https://cozsweet.com/external-entry?target=chat) | `/characters/elio/chat` |
|
||||||
|
| Maya 聊天 | [打开 Maya 聊天](https://cozsweet.com/external-entry?target=chat&character=maya) | `/characters/maya/chat` |
|
||||||
|
| Nayeli 聊天 | [打开 Nayeli 聊天](https://cozsweet.com/external-entry?target=chat&character=nayeli) | `/characters/nayeli/chat` |
|
||||||
|
| 携带 PSID 的 Elio 聊天 | [打开 PSID 聊天示例](https://cozsweet.com/external-entry?target=chat&psid=27511427698460020) | `/characters/elio/chat` |
|
||||||
|
| 语音促销 | [打开语音促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=voice) | `/characters/elio/chat`,保存语音促销意图 |
|
||||||
|
| 图片促销 | [打开图片促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=image) | `/characters/elio/chat`,保存图片促销意图 |
|
||||||
|
| 私密文本促销 | [打开私密文本促销](https://cozsweet.com/external-entry?target=chat&mode=promotion&promotion_type=private) | `/characters/elio/chat`,保存私密文本促销意图 |
|
||||||
|
| Elio 咖啡打赏 | [打开 Elio 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=elio) | `/characters/elio/tip` |
|
||||||
|
| Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=maya) | `/characters/maya/tip` |
|
||||||
|
| Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=nayeli) | `/characters/nayeli/tip` |
|
||||||
|
| Elio 私密空间 | [打开 Elio 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=elio) | `/characters/elio/private-zone` |
|
||||||
|
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||||
|
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=nayeli) | `/characters/nayeli/private-zone` |
|
||||||
|
|
||||||
|
## 验收判断
|
||||||
|
|
||||||
|
打开入口后,浏览器地址最终应与“预期落地页面”一致。普通聊天和私密空间必须分别落在 `/chat` 与 `/private-zone`,不能再进入同一个页面。
|
||||||
|
|
||||||
|
如需在其他入口携带 PSID,在已有查询参数的链接末尾追加 `&psid=27511427698460020`。
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Private Zone 全链路改名联调说明
|
||||||
|
|
||||||
|
- 日期:2026-07-22
|
||||||
|
- 目标环境:本地、`pre`、生产
|
||||||
|
- 当前状态:已验证
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
原功能名称存在拼写错误。本次将前端、unified 后端、Manager、数据库、埋点和文档中的正式命名统一为 `Private Zone`,并同步切换页面路由、API 路径和公开枚举值。
|
||||||
|
|
||||||
|
## 2. 环境地址
|
||||||
|
|
||||||
|
| 环境 | 前端 | API Base URL |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pre` | `https://frontend-test.banlv-ai.com` | `https://proapi.banlv-ai.com` |
|
||||||
|
| 生产 | `https://cozsweet.com` | `https://api.banlv-ai.com` |
|
||||||
|
|
||||||
|
## 3. 前端影响
|
||||||
|
|
||||||
|
前端已完成以下修改:
|
||||||
|
|
||||||
|
- 页面路由统一为 `/private-zone` 和 `/characters/{characterSlug}/private-zone`。
|
||||||
|
- 外部入口参数统一为 `target=private-zone`。
|
||||||
|
- 支付回跳参数统一为 `returnTo=private-zone`。
|
||||||
|
- 类型、Repository、API client、XState actor、Provider 和资源目录统一使用 `privateZone`、`PrivateZone`、`private-zone` 或 `private_zone`。
|
||||||
|
- 埋点键统一为 `navigation.private_zone`、`chat.open_private_zone_from_avatar` 等新名称。
|
||||||
|
- 角色能力字段统一为 `capabilities.privateZone`。
|
||||||
|
|
||||||
|
旧页面和旧查询参数不再作为正式入口保留。发布时必须让前后端属于同一个发布批次。
|
||||||
|
|
||||||
|
## 4. API 变更清单
|
||||||
|
|
||||||
|
所有接口都使用 `Authorization: Bearer <TOKEN>`,响应继续使用现有统一 envelope。请求与响应中未列出的业务字段保持原类型和含义。
|
||||||
|
|
||||||
|
| 方法 | 新路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/private-zone/albums` | 查询付费图片包 |
|
||||||
|
| `POST` | `/api/private-zone/albums/{albumId}/unlock` | 解锁图片包 |
|
||||||
|
| `GET` | `/api/private-zone/moments` | 查询朋友圈式内容 |
|
||||||
|
| `POST` | `/api/private-zone/moments/{momentId}/unlock` | 解锁单条内容 |
|
||||||
|
| `GET` | `/api/private-zone/config` | 查询当前角色和用户解锁配置 |
|
||||||
|
| `GET` | `/api/private-zone/diaries` | 查询关系日记 |
|
||||||
|
| `POST` | `/api/private-zone/diaries/{diaryId}/seen` | 标记关系日记已读 |
|
||||||
|
| `POST` | `/api/private-zone/diaries/{diaryId}/unlock` | 解锁关系日记 |
|
||||||
|
|
||||||
|
### 4.1 图片包列表
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/private-zone/albums?characterId=elio&limit=20
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 位置 | 类型 | 必填 | 可为 `null` | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `characterId` | query | string | 是 | 否 | 角色 ID |
|
||||||
|
| `limit` | query | integer | 否 | 否 | `1-50`,默认 `20` |
|
||||||
|
| `cursor` | query | string | 否 | 是 | 分页游标 |
|
||||||
|
|
||||||
|
调用示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://proapi.banlv-ai.com/api/private-zone/albums?characterId=elio&limit=20' \
|
||||||
|
-H 'Authorization: Bearer <TOKEN>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 解锁图片包
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/private-zone/albums/{albumId}/unlock
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 位置 | 类型 | 必填 | 可为 `null` | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `albumId` | path | string | 是 | 否 | 图片包 ID |
|
||||||
|
| `expectedCost` | body | integer | 否 | 是 | 前端确认价格;与后端价格不一致时拒绝解锁 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://proapi.banlv-ai.com/api/private-zone/albums/<ALBUM_ID>/unlock' \
|
||||||
|
-H 'Authorization: Bearer <TOKEN>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"expectedCost":320}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Moments 内容与解锁
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/private-zone/moments?characterId=elio&limit=20
|
||||||
|
POST /api/private-zone/moments/{momentId}/unlock
|
||||||
|
```
|
||||||
|
|
||||||
|
解锁请求体与图片包一致,`expectedCost` 为可选整数。响应中的正式分类值同步改为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lockDetail": {
|
||||||
|
"type": "private_zone_moment",
|
||||||
|
"reason": "private_zone_moment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 其他公开值
|
||||||
|
|
||||||
|
| 使用位置 | 新值 | 类型 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/chat/unlock-private` 的 `lockType` 别名 | `private_zone` | string enum |
|
||||||
|
| 日程导入 `contentType` 别名 | `private_zone` | string enum,归一化为 `paid_content` |
|
||||||
|
| 外部入口 `target` | `private-zone` | string enum |
|
||||||
|
| 支付回跳 `returnTo` | `private-zone` | string enum |
|
||||||
|
| 锁定详情分类 | `private_zone_moment` | string enum |
|
||||||
|
|
||||||
|
## 6. 数据库迁移
|
||||||
|
|
||||||
|
因为 `pre` 与生产当前共用 `https://dbapi.banlv-ai.com`,预发部署前先执行 unified 仓库中的临时桥接脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
database/private-zone-bridge.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
桥接脚本会创建新表并在切换窗口内保持新旧解锁写入双向同步。生产前后端全部切换完成后,再执行最终迁移:
|
||||||
|
|
||||||
|
```text
|
||||||
|
database/private-zone-migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移会在同一事务中完成:
|
||||||
|
|
||||||
|
- 将历史解锁表无损重命名;新旧表同时存在时先合并再删除旧表。
|
||||||
|
- 重命名关联约束、索引、策略和触发器。
|
||||||
|
- 合并 `users.preferences` 中历史解锁键,防止已解锁内容重新锁定。
|
||||||
|
- 把 `credit_ledger` 历史分类迁移到 `private_zone` 和 `private_zone_moment`。
|
||||||
|
- 定向迁移历史埋点键、页面 URL 和系统媒体路径,不改写用户聊天正文。
|
||||||
|
- 删除切换窗口使用的双写触发器和函数。
|
||||||
|
- 支持重复执行。
|
||||||
|
|
||||||
|
## 7. 兼容性与发布顺序
|
||||||
|
|
||||||
|
这是破坏性命名修正,不保留旧 API、旧页面路由或旧公开枚举作为正式兼容层。必须按以下顺序发布:
|
||||||
|
|
||||||
|
1. 备份数据库并记录当前 unified、前端和 Manager 版本。
|
||||||
|
2. 执行 `database/private-zone-bridge.sql`,确认新旧表双向写入一致。
|
||||||
|
3. 部署 unified `pre`,验证所有 `/api/private-zone/*` 接口。
|
||||||
|
4. 部署前端 `pre`,验证页面、外部入口、解锁和支付回跳。
|
||||||
|
5. Manager 更新后验证积分用途显示为新分类。
|
||||||
|
6. `pre` 验证通过后,先启动新生产后端并临时配置旧 API 到新 API 的 Nginx 转发。
|
||||||
|
7. 切换生产后端,再部署同批次生产前端;确认新前端只调用 `/api/private-zone/*`。
|
||||||
|
8. 删除临时 Nginx 转发,执行 `database/private-zone-migration.sql` 清理旧数据库对象。
|
||||||
|
|
||||||
|
## 8. 错误与界面状态
|
||||||
|
|
||||||
|
- `401`:Token 缺失或无效,前端进入现有登录恢复流程。
|
||||||
|
- `403`:游客访问仅注册用户可用的关系日记操作,展示现有注册引导。
|
||||||
|
- `404`:角色、图片包、内容或日记不存在,展示现有空状态或失效提示。
|
||||||
|
- `409`:`expectedCost` 与后端价格不同,刷新列表后重新确认。
|
||||||
|
- `402` 或业务余额不足错误:进入现有充值流程,并使用 `returnTo=private-zone` 返回原角色页面。
|
||||||
|
- 网络失败:不修改本地解锁状态,保留重试入口。
|
||||||
|
|
||||||
|
## 9. 验收用例
|
||||||
|
|
||||||
|
1. `/characters/elio/private-zone`、Maya 和 Nayeli 对应页面均可打开。
|
||||||
|
2. `GET /api/private-zone/albums` 返回当前角色内容,角色之间不混用。
|
||||||
|
3. 已解锁历史记录在迁移后保持解锁状态,不重复扣积分。
|
||||||
|
4. 新解锁成功后刷新页面仍为已解锁状态。
|
||||||
|
5. 积分不足进入充值页后,回跳到原角色 `/private-zone` 页面。
|
||||||
|
6. `target=private-zone&character=nayeli` 进入 Nayeli 对应页面。
|
||||||
|
7. Manager 的积分用途不再出现旧分类。
|
||||||
|
8. 三个仓库执行旧命名残留扫描,结果为 `0`。
|
||||||
|
|
||||||
|
## 10. 当前测试证据
|
||||||
|
|
||||||
|
- unified:`316 passed`。
|
||||||
|
- 前端:TypeScript 类型检查通过;Vitest `639 passed`;ESLint 通过;契约测试 `4 passed`;生产构建通过并生成三个角色的 `/private-zone` 页面。
|
||||||
|
- Manager:`75 passed`。
|
||||||
|
- PostgreSQL 16 临时库:升级、重复升级、回滚和再次升级全部通过;历史解锁、偏好键、积分账本、埋点、媒体路径和数据库对象名均通过断言,临时容器已删除。
|
||||||
|
- `pre`:unified `71da49b` 和前端 `35939e7` 已验证;新 API 已注册 `8` 条 `/api/private-zone/*` 路径,旧 API 路径数量为 `0`;新页面返回 `200`,旧页面返回 `404`。
|
||||||
|
- 生产:unified 镜像 `ai-boyfriend-unified:prod-20260722-71da49b`(镜像 ID `sha256:755ef5741856d83d7d808e7df8ee259b6fa5fb94fe333b523ee280ed1d5f674b`)和前端镜像 `prod-35939e7` 已健康运行;Manager 当前版本为 `6eea005`。
|
||||||
|
- 生产接口:测试账号调用 `GET https://api.banlv-ai.com/api/private-zone/config?characterId=elio` 返回 `200`,`success=true`;旧版 API 路径在所有公开 API 入口均返回 `404`。
|
||||||
|
- 生产数据库:最终迁移已执行,旧表、桥接函数、桥接触发器、旧偏好键、旧积分分类和旧埋点值的残留数量均为 `0`。
|
||||||
|
|
||||||
|
## 11. 回滚影响
|
||||||
|
|
||||||
|
最终迁移执行前可直接恢复旧应用,桥接表会保留双向一致的数据。最终迁移执行后,回滚必须同时执行 unified 仓库中的 `database/private-zone-rollback.sql`。回滚前应停止写入,先保存切换后的新增解锁记录,再执行逆向迁移并恢复旧应用版本。不得只恢复前端或只恢复 unified。
|
||||||
|
|
||||||
|
## 12. 待确认事项
|
||||||
|
|
||||||
|
- 无。
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
当前推荐部署链路:
|
当前推荐部署链路:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
push test/main
|
push pre/main
|
||||||
→ Gitea Actions 构建并推送 Docker 镜像
|
→ Gitea Actions 构建并推送 Docker 镜像
|
||||||
→ Actions 通过 SSH 登录目标服务器
|
→ Actions 通过 SSH 登录目标服务器
|
||||||
→ 目标服务器 docker pull 指定镜像 tag
|
→ 目标服务器 docker pull 指定镜像 tag
|
||||||
@@ -16,7 +16,7 @@ push test/main
|
|||||||
|
|
||||||
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
| `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||||
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
||||||
|
|
||||||
## Gitea Secrets
|
## Gitea Secrets
|
||||||
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
|
|||||||
|
|
||||||
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
||||||
|
|
||||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||||
|
|
||||||
## 本地发布入口
|
## 本地发布入口
|
||||||
|
|
||||||
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试环境
|
# 预发环境
|
||||||
./scripts/release/pre_release_web.sh
|
./scripts/release/pre_release_web.sh
|
||||||
|
|
||||||
# 生产环境
|
# 生产环境
|
||||||
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
|
|||||||
也可以直接推送:
|
也可以直接推送:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push gitea test
|
git push gitea pre
|
||||||
git push --force gitea main
|
git push --force gitea main
|
||||||
```
|
```
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
测试环境:
|
预发环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh <user>@<test-host>
|
ssh <user>@<pre-host>
|
||||||
cd /opt/cozsweet-web-test
|
cd /opt/cozsweet-web-test
|
||||||
docker compose ps
|
docker compose ps
|
||||||
curl -I http://127.0.0.1:9135/
|
curl -I http://127.0.0.1:9135/
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
|
|||||||
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
||||||
|
|
||||||
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
||||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier
|
refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
|
||||||
on every pull request and `dev` / `test` / `main` push.
|
request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
|
||||||
|
|
||||||
## Environment overrides
|
## Environment overrides
|
||||||
|
|
||||||
@@ -40,6 +40,6 @@ pnpm test:e2e:real
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
PLAYWRIGHT_BASE_URL=https://cozsweet.com \
|
PLAYWRIGHT_BASE_URL=https://cozsweet.com \
|
||||||
E2E_API_BASE_URL=https://proapi.banlv-ai.com \
|
E2E_API_BASE_URL=https://api.banlv-ai.com \
|
||||||
pnpm test:e2e:prod
|
pnpm test:e2e:prod
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
|
|||||||
|
|
||||||
import { registerAuthMocks } from "./api/auth";
|
import { registerAuthMocks } from "./api/auth";
|
||||||
import { registerChatMocks } from "./api/chat";
|
import { registerChatMocks } from "./api/chat";
|
||||||
|
import { registerCharacterMocks } from "./api/character";
|
||||||
import { registerPaymentMocks } from "./api/payment";
|
import { registerPaymentMocks } from "./api/payment";
|
||||||
import { registerUserMocks } from "./api/user";
|
import { registerUserMocks } from "./api/user";
|
||||||
|
|
||||||
@@ -11,7 +12,8 @@ export interface MockCoreApisOptions {
|
|||||||
paidImageFlow?: boolean;
|
paidImageFlow?: boolean;
|
||||||
paidImageInsufficientCreditsFlow?: boolean;
|
paidImageInsufficientCreditsFlow?: boolean;
|
||||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||||
psidLoginFlow?: boolean;
|
topUpHandoffFlow?: boolean;
|
||||||
|
checkoutHandoffFlow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||||
@@ -21,15 +23,18 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
paidImageFlow: options.paidImageFlow ?? false,
|
paidImageFlow: options.paidImageFlow ?? false,
|
||||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||||
psidLoginFlow: options.psidLoginFlow ?? false,
|
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||||
|
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||||
};
|
};
|
||||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||||
|
|
||||||
await registerAuthMocks(page, {
|
await registerAuthMocks(page, {
|
||||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||||
psidLoginFlow: chatOptions.psidLoginFlow,
|
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||||
|
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||||
});
|
});
|
||||||
|
await registerCharacterMocks(page);
|
||||||
await registerUserMocks(page);
|
await registerUserMocks(page);
|
||||||
await registerChatMocks(page, chatOptions, chatState);
|
await registerChatMocks(page, chatOptions, chatState);
|
||||||
await registerPaymentMocks(page);
|
await registerPaymentMocks(page);
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { apiEnvelope } from "../data/common";
|
import { apiEnvelope } from "../data/common";
|
||||||
import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
|
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||||
|
|
||||||
export interface AuthMockState {
|
export interface AuthMockState {
|
||||||
chatSendTokenRefreshFlow: boolean;
|
chatSendTokenRefreshFlow: boolean;
|
||||||
isChatSendTokenExpired: () => boolean;
|
isChatSendTokenExpired: () => boolean;
|
||||||
psidLoginFlow: boolean;
|
topUpHandoffFlow: boolean;
|
||||||
|
checkoutHandoffFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
||||||
await page.route("**/api/auth/login/facebook/psid", async (route) => {
|
await page.route("**/api/auth/handoff/checkout/consume", async (route) => {
|
||||||
if (!state.psidLoginFlow) {
|
if (!state.checkoutHandoffFlow) {
|
||||||
await route.continue();
|
await route.continue();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) });
|
||||||
|
});
|
||||||
|
|
||||||
await route.fulfill({ json: apiEnvelope(psidLoginResponse) });
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
|
if (!state.topUpHandoffFlow) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/auth/guest", async (route) => {
|
await page.route("**/api/auth/guest", async (route) => {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { apiEnvelope } from "../data/common";
|
||||||
|
import { chatCharactersResponse } from "../data/character";
|
||||||
|
|
||||||
|
export async function registerCharacterMocks(page: Page) {
|
||||||
|
await page.route(/\/api\/characters(?:\?.*)?$/, async (route) => {
|
||||||
|
await route.fulfill({ json: apiEnvelope(chatCharactersResponse) });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { apiEnvelope } from "../data/common";
|
import { apiEnvelope } from "../data/common";
|
||||||
import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat";
|
import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, emptyChatPreviewsResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat";
|
||||||
|
|
||||||
export interface ChatMockOptions {
|
export interface ChatMockOptions {
|
||||||
chatLimitTriggerAt?: number;
|
chatLimitTriggerAt?: number;
|
||||||
@@ -20,6 +20,23 @@ export interface ChatMockState {
|
|||||||
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
||||||
let sendCount = 0;
|
let sendCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/chat/opening-message", async (route) => {
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
openingMessage?: unknown;
|
||||||
|
} | undefined;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
messageId: "mock-opening-message",
|
||||||
|
created: true,
|
||||||
|
openingMessage:
|
||||||
|
typeof body?.openingMessage === "string"
|
||||||
|
? body.openingMessage
|
||||||
|
: "Hello",
|
||||||
|
createdAt: "2026-07-23T00:00:00Z",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/chat/history**", async (route) => {
|
await page.route("**/api/chat/history**", async (route) => {
|
||||||
const response = options.paidVoiceInsufficientCreditsFlow
|
const response = options.paidVoiceInsufficientCreditsFlow
|
||||||
? paidVoiceHistoryResponse
|
? paidVoiceHistoryResponse
|
||||||
@@ -31,6 +48,10 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st
|
|||||||
await route.fulfill({ json: apiEnvelope(response) });
|
await route.fulfill({ json: apiEnvelope(response) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/chat/previews", async (route) => {
|
||||||
|
await route.fulfill({ json: apiEnvelope(emptyChatPreviewsResponse) });
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/chat/send", async (route) => {
|
await page.route("**/api/chat/send", async (route) => {
|
||||||
sendCount += 1;
|
sendCount += 1;
|
||||||
const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined;
|
const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined;
|
||||||
@@ -40,7 +61,35 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st
|
|||||||
await route.fulfill({ status: 401, json: { message: "expired" } });
|
await route.fulfill({ status: 401, json: { message: "expired" } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = options.paidImageFlow && message.includes("给我发图片")
|
const response = message.includes("discount offer test")
|
||||||
|
? {
|
||||||
|
...chatSendResponse,
|
||||||
|
reply: "I know you're thinking carefully about the price.",
|
||||||
|
messageId: "discount-action-message",
|
||||||
|
commercialAction: {
|
||||||
|
actionId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
type: "discountOffer",
|
||||||
|
copy: "Want me to ask for my best private offer?",
|
||||||
|
ctaLabel: "Yes, ask for me",
|
||||||
|
target: "discountConsent",
|
||||||
|
ruleId: "discount_after_price_objection",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: message.includes("private album offer test")
|
||||||
|
? {
|
||||||
|
...chatSendResponse,
|
||||||
|
reply: "You always know how to make me smile.",
|
||||||
|
messageId: "commercial-action-message",
|
||||||
|
commercialAction: {
|
||||||
|
actionId: "commercial-action-1",
|
||||||
|
type: "privateAlbumOffer",
|
||||||
|
copy: "There are more private photos waiting for you.",
|
||||||
|
ctaLabel: "Open private zone",
|
||||||
|
target: "privateZone",
|
||||||
|
ruleId: "private_album_after_appearance_praise",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: options.paidImageFlow && message.includes("给我发图片")
|
||||||
? paidImageChatSendResponse
|
? paidImageChatSendResponse
|
||||||
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
||||||
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { apiEnvelope } from "../data/common";
|
|||||||
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
||||||
|
|
||||||
export async function registerPaymentMocks(page: Page) {
|
export async function registerPaymentMocks(page: Page) {
|
||||||
|
await page.route("**/api/payment/commercial-offers/*/accept", async (route) => route.fulfill({ json: apiEnvelope({
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
planId: "vip_annual",
|
||||||
|
subscriptionType: "vip",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-24T08:00:00+00:00",
|
||||||
|
message: "I got it for you.",
|
||||||
|
promotionType: "commercial_seven_discount",
|
||||||
|
}) }));
|
||||||
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
||||||
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
||||||
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
||||||
|
|||||||
@@ -50,14 +50,28 @@ export const refreshedEmailLoginResponse = {
|
|||||||
refreshToken: "e2e-refreshed-email-refresh-token",
|
refreshToken: "e2e-refreshed-email-refresh-token",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const psidLoginResponse = {
|
export const topUpHandoffResponse = {
|
||||||
token: "e2e-psid-guest-token",
|
token: "e2e-messenger-token",
|
||||||
refreshToken: "",
|
refreshToken: "e2e-messenger-refresh-token",
|
||||||
matchedBy: "psid",
|
loginStatus: "facebookMessenger",
|
||||||
fbAsid: "",
|
merged: false,
|
||||||
fbPsid: "e2e-facebook-psid",
|
user: {
|
||||||
hasCompleteFacebookIdentity: false,
|
...e2eEmailUser,
|
||||||
isGuest: true,
|
id: "user_e2e_messenger",
|
||||||
user: e2eUser,
|
username: "E2E Messenger User",
|
||||||
userId: e2eUser.id,
|
email: "messenger_hash@messenger.cozsweet.invalid",
|
||||||
|
loginProvider: "facebook_messenger",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkoutHandoffResponse = {
|
||||||
|
token: "e2e-checkout-token",
|
||||||
|
refreshToken: "e2e-checkout-refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
user: e2eEmailUser,
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const chatCharactersResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "elio",
|
||||||
|
displayName: "Elio Silvestri",
|
||||||
|
isActive: true,
|
||||||
|
capabilities: { chat: true, privateContent: true },
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maya-tan",
|
||||||
|
displayName: "Maya Tan",
|
||||||
|
isActive: true,
|
||||||
|
capabilities: { chat: true, privateContent: true },
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nayeli-cervantes",
|
||||||
|
displayName: "Nayeli Cervantes",
|
||||||
|
isActive: true,
|
||||||
|
capabilities: { chat: true, privateContent: true },
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultCharacterId: "elio",
|
||||||
|
};
|
||||||
@@ -9,6 +9,8 @@ export const emptyChatHistoryResponse = {
|
|||||||
privateCanViewFree: false,
|
privateCanViewFree: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const emptyChatPreviewsResponse = { items: [] };
|
||||||
|
|
||||||
export const chatSendResponse = {
|
export const chatSendResponse = {
|
||||||
reply: "Hello from the E2E boyfriend.",
|
reply: "Hello from the E2E boyfriend.",
|
||||||
audioUrl: "",
|
audioUrl: "",
|
||||||
@@ -64,6 +66,7 @@ export function createWeeklyLimitChatSendResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const paidImageMessageId = "msg_photo_paywall_001";
|
export const paidImageMessageId = "msg_photo_paywall_001";
|
||||||
|
export const paidImageDisplayMessageId = `server:${paidImageMessageId}:assistant`;
|
||||||
export const paidImageUrl =
|
export const paidImageUrl =
|
||||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
export const paidImageChatSendResponse = {
|
export const paidImageChatSendResponse = {
|
||||||
@@ -72,13 +75,13 @@ export const paidImageChatSendResponse = {
|
|||||||
messageId: paidImageMessageId,
|
messageId: paidImageMessageId,
|
||||||
isGuest: true,
|
isGuest: true,
|
||||||
timestamp: 1_782_180_725_000,
|
timestamp: 1_782_180_725_000,
|
||||||
image: { type: "elio_schedule", url: paidImageUrl },
|
image: { type: "elio_schedule", url: null },
|
||||||
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
||||||
return {
|
return {
|
||||||
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: paidImageUrl }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: unlocked ? paidImageUrl : null }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
||||||
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,11 @@ export const tipPaymentPlansResponse = { plans: [
|
|||||||
] };
|
] };
|
||||||
export const paymentOrderId = "order_e2e_vip_monthly";
|
export const paymentOrderId = "order_e2e_vip_monthly";
|
||||||
export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } };
|
export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } };
|
||||||
export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" };
|
export const paidPaymentOrderStatusResponse = {
|
||||||
|
orderId: paymentOrderId,
|
||||||
|
status: "paid",
|
||||||
|
orderType: "vip_monthly",
|
||||||
|
planId: "vip_monthly",
|
||||||
|
creditsAdded: 0,
|
||||||
|
};
|
||||||
export const vipStatusResponse = { isVip: false, expiresAt: null };
|
export const vipStatusResponse = { isVip: false, expiresAt: null };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const splashStartChatButtonName = "Start Chatting";
|
|||||||
export const defaultCharacterSlug = "elio";
|
export const defaultCharacterSlug = "elio";
|
||||||
export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`;
|
export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`;
|
||||||
export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`;
|
export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`;
|
||||||
export const defaultCharacterSidebarPath = `/characters/${defaultCharacterSlug}/sidebar`;
|
export const defaultCharacterProfilePath = `/profile?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`;
|
||||||
export const defaultCharacterChatUrl = new RegExp(
|
export const defaultCharacterChatUrl = new RegExp(
|
||||||
`/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`,
|
`/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`,
|
||||||
);
|
);
|
||||||
@@ -34,6 +34,8 @@ export async function seedEmailSession(page: Page) {
|
|||||||
await page.goto(defaultCharacterSplashPath);
|
await page.goto(defaultCharacterSplashPath);
|
||||||
await setEmailSessionStorage(page);
|
await setEmailSessionStorage(page);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
// Let the root auth provider hydrate the seeded session before navigation.
|
||||||
|
await page.waitForTimeout(750);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchToEmailSignIn(page: Page) {
|
export async function switchToEmailSignIn(page: Page) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function enterChatFromSplash(page: Page, options: { expectedUrl?: R
|
|||||||
await expect(startChatButton).toBeVisible({ timeout });
|
await expect(startChatButton).toBeVisible({ timeout });
|
||||||
await expect(startChatButton).toBeEnabled();
|
await expect(startChatButton).toBeEnabled();
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
await startChatButton.click();
|
await startChatButton.click({ timeout: 3_000 }).catch(() => {});
|
||||||
try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); }
|
try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); }
|
||||||
}
|
}
|
||||||
await expect(page).toHaveURL(expectedUrl, { timeout });
|
await expect(page).toHaveURL(expectedUrl, { timeout });
|
||||||
@@ -42,9 +42,7 @@ export async function dismissChatInterruptions(page: Page) {
|
|||||||
|
|
||||||
export async function signInWithEmailAndOpenChat(page: Page) {
|
export async function signInWithEmailAndOpenChat(page: Page) {
|
||||||
await seedEmailSession(page);
|
await seedEmailSession(page);
|
||||||
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
|
|
||||||
await page.goto(defaultCharacterChatPath);
|
await page.goto(defaultCharacterChatPath);
|
||||||
await historyResponsePromise;
|
|
||||||
await dismissChatInterruptions(page);
|
await dismissChatInterruptions(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
export async function completeVipPayment(page: Page) {
|
export async function completeVipPayment(page: Page) {
|
||||||
|
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(checkoutButton).toBeEnabled();
|
||||||
|
|
||||||
|
await checkoutButton.click();
|
||||||
|
const renewalDialog = page.getByRole("dialog", {
|
||||||
|
name: "Automatic Renewal Confirmation",
|
||||||
|
});
|
||||||
|
await expect(renewalDialog).toBeVisible();
|
||||||
|
|
||||||
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||||
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
||||||
await page.getByRole("button", { name: /Pay and Activ/i }).click();
|
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
|
||||||
const createOrderRequest = await createOrderRequestPromise;
|
const createOrderRequest = await createOrderRequestPromise;
|
||||||
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
||||||
await orderStatusRequestPromise;
|
await orderStatusRequestPromise;
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ test("user can email login from other sign-in options and return to chat", async
|
|||||||
expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
|
expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
|
||||||
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
defaultCharacterSidebarPath,
|
defaultCharacterProfilePath,
|
||||||
defaultCharacterSplashPath,
|
defaultCharacterSplashPath,
|
||||||
|
defaultCharacterChatUrl,
|
||||||
|
enterChatFromSplash,
|
||||||
getSplashStartChatButton,
|
getSplashStartChatButton,
|
||||||
signInWithEmailAndOpenChat,
|
submitEmailLogin,
|
||||||
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
@@ -14,14 +17,25 @@ test.beforeEach(async ({ baseURL, context, page }) => {
|
|||||||
await mockCoreApis(page);
|
await mockCoreApis(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user can log out from the sidebar after email login", async ({
|
test("user can log out from the profile after email login", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await signInWithEmailAndOpenChat(page);
|
await enterChatFromSplash(page);
|
||||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
await page
|
||||||
|
.getByRole("button", { name: "Sign up to unlock more features" })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/auth(?:\?.*)?$/);
|
||||||
|
await switchToEmailSignIn(page);
|
||||||
|
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
||||||
|
await page.getByRole("link", { name: "Back to home" }).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(defaultCharacterSplashPath.replace("?", "\\?") + "$"),
|
||||||
|
);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Menu" }).click();
|
await page.getByRole("button", { name: "Menu" }).click();
|
||||||
await expect(page).toHaveURL(new RegExp(`${defaultCharacterSidebarPath}$`));
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"),
|
||||||
|
);
|
||||||
|
|
||||||
const logoutRequestPromise = page.waitForRequest("**/api/auth/logout");
|
const logoutRequestPromise = page.waitForRequest("**/api/auth/logout");
|
||||||
await page.getByRole("button", { name: /Log out/i }).click();
|
await page.getByRole("button", { name: /Log out/i }).click();
|
||||||
@@ -1,49 +1,74 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import {
|
import { clearBrowserState } from "@e2e/fixtures/test-helpers";
|
||||||
clearBrowserState,
|
|
||||||
defaultCharacterChatUrl,
|
|
||||||
} from "@e2e/fixtures/test-helpers";
|
|
||||||
|
|
||||||
const psid = "e2e-facebook-psid";
|
const handoffToken = "e2e-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, { psidLoginFlow: true });
|
await mockCoreApis(page, { topUpHandoffFlow: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user enters from an external PSID link and becomes a guest", async ({
|
test("user consumes a top-up handoff and opens credit packages as a formal Messenger user", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const psidLoginRequestPromise = page.waitForRequest(
|
const handoffRequestPromise = page.waitForRequest(
|
||||||
"**/api/auth/login/facebook/psid",
|
"**/api/auth/handoff/topup/consume",
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(`/external-entry?target=chat&psid=${psid}`);
|
await page.goto(
|
||||||
|
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
const psidLoginRequest = await psidLoginRequestPromise;
|
const handoffRequest = await handoffRequestPromise;
|
||||||
expect(psidLoginRequest.method()).toBe("POST");
|
expect(handoffRequest.method()).toBe("POST");
|
||||||
expect(psidLoginRequest.postDataJSON()).toMatchObject({
|
expect(handoffRequest.postDataJSON()).toEqual({ handoffToken });
|
||||||
psid,
|
|
||||||
bindToGuest: true,
|
|
||||||
});
|
|
||||||
expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String));
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(defaultCharacterChatUrl);
|
await expect(page).toHaveURL("/subscription?type=topup");
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("button", { name: /Pay and Top Up/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page.evaluate(() => ({
|
page.evaluate(() => ({
|
||||||
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
guestToken: localStorage.getItem("cozsweet:guest_token"),
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
psid: localStorage.getItem("cozsweet:psid"),
|
refreshToken: localStorage.getItem("cozsweet:refresh_token"),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
loginProvider: "guest",
|
loginProvider: "facebookMessenger",
|
||||||
guestToken: "e2e-psid-guest-token",
|
loginToken: "e2e-messenger-token",
|
||||||
psid,
|
refreshToken: "e2e-messenger-refresh-token",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("invalid top-up handoff does not log in and is removed from the URL", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 410,
|
||||||
|
json: {
|
||||||
|
detail: {
|
||||||
|
code: "HANDOFF_EXPIRED",
|
||||||
|
message: "充值链接已过期",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("/external-entry?target=topup");
|
||||||
|
await expect(
|
||||||
|
page.getByText(/invalid or has expired/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => localStorage.getItem("cozsweet:login_provider")))
|
||||||
|
.not.toBe("facebookMessenger");
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
defaultCharacterProfilePath,
|
||||||
|
dismissChatInterruptions,
|
||||||
|
enterChatFromSplash,
|
||||||
|
submitEmailLogin,
|
||||||
|
switchToEmailSignIn,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const characters = [
|
||||||
|
{ slug: "elio", displayName: "Elio Silvestri" },
|
||||||
|
{ slug: "maya", displayName: "Maya Tan" },
|
||||||
|
{ slug: "nayeli", displayName: "Nayeli Cervantes" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("guest user avatar returns to Profile after sign-in", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enterChatFromSplash(page, { timeout: 30_000 });
|
||||||
|
await sendMessage(page, "Open my profile");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Open profile" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
|
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||||
|
defaultCharacterProfilePath,
|
||||||
|
);
|
||||||
|
await switchToEmailSignIn(page);
|
||||||
|
await submitEmailLogin(page, {
|
||||||
|
expectedUrl: new RegExp(
|
||||||
|
`${defaultCharacterProfilePath.replace("?", "\\?")}$`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const character of characters) {
|
||||||
|
test(`${character.displayName} avatar opens the matching Private Zone`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enterCharacterChat(page, character.slug);
|
||||||
|
await sendMessage(page, `Hello ${character.displayName}`);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: `Open ${character.displayName}'s private zone`,
|
||||||
|
})
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/characters/${character.slug}/private-zone(?:\\?.*)?$`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterCharacterChat(page: Page, characterSlug: string) {
|
||||||
|
const chatPath = `/characters/${characterSlug}/chat`;
|
||||||
|
|
||||||
|
await page.goto(`/characters/${characterSlug}/splash`);
|
||||||
|
await page.getByRole("button", { name: "Start Chatting" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${chatPath}(?:\\?.*)?$`));
|
||||||
|
await dismissChatInterruptions(page);
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(page: Page, message: string) {
|
||||||
|
await dismissChatInterruptions(page);
|
||||||
|
const messageInput = page.getByRole("textbox", { name: "Message" });
|
||||||
|
|
||||||
|
await expect(messageInput).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await messageInput.fill(message);
|
||||||
|
await messageInput.press("Enter");
|
||||||
|
await expect(page.getByRole("button", { name: "Open profile" })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { expect, test, type Request } from "@playwright/test";
|
|||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
signInWithEmailAndOpenChat,
|
enterChatFromSplash,
|
||||||
|
setEmailSessionStorage,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
@@ -23,7 +24,8 @@ test("chat send refreshes an expired token, retries the request, and renders the
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await signInWithEmailAndOpenChat(page);
|
await enterChatFromSplash(page);
|
||||||
|
await setEmailSessionStorage(page);
|
||||||
|
|
||||||
const message = "token refresh retry test";
|
const message = "token refresh retry test";
|
||||||
const messageInput = page.getByRole("textbox", { name: "Message" });
|
const messageInput = page.getByRole("textbox", { name: "Message" });
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
enterChatFromSplash,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a backend commercial action and opens the private zone", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
|
||||||
|
const input = page.getByRole("textbox", { name: "Message" });
|
||||||
|
await expect(input).toBeEnabled();
|
||||||
|
await input.fill("private album offer test");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
const offer = page.getByLabel("Open private zone");
|
||||||
|
await expect(offer).toContainText(
|
||||||
|
"There are more private photos waiting for you.",
|
||||||
|
);
|
||||||
|
await offer.getByRole("button", { name: "Open private zone" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/characters\/elio\/private-zone$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks for consent before opening a user-bound discount plan", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
|
||||||
|
const input = page.getByRole("textbox", { name: "Message" });
|
||||||
|
await input.fill("discount offer test");
|
||||||
|
await input.press("Enter");
|
||||||
|
|
||||||
|
const offer = page.getByLabel("Yes, ask for me");
|
||||||
|
await expect(offer).toContainText("Want me to ask for my best private offer?");
|
||||||
|
await offer.getByRole("button", { name: "Yes, ask for me" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?type=vip.*planId=vip_annual.*commercialOfferId=13ec8a10/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
defaultCharacterChatUrl,
|
defaultCharacterChatUrl,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
setEmailSessionStorage,
|
|
||||||
submitEmailLogin,
|
submitEmailLogin,
|
||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
@@ -27,7 +26,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
|||||||
);
|
);
|
||||||
await expect(page).toHaveURL(defaultCharacterChatUrl);
|
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",
|
name: "Unlock private image",
|
||||||
});
|
});
|
||||||
await expect(unlockButton).toBeVisible({ timeout: 10_000 });
|
await expect(unlockButton).toBeVisible({ timeout: 10_000 });
|
||||||
@@ -36,13 +38,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
|||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
|
||||||
|
|
||||||
await setEmailSessionStorage(page);
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await page.reload();
|
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
expect(unlockRequest.method()).toBe("POST");
|
expect(unlockRequest.method()).toBe("POST");
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { e2eEmailUser } from "@e2e/fixtures/data/auth";
|
||||||
|
import { chatCharactersResponse } from "@e2e/fixtures/data/character";
|
||||||
|
import {
|
||||||
|
emptyChatHistoryResponse,
|
||||||
|
emptyChatPreviewsResponse,
|
||||||
|
} from "@e2e/fixtures/data/chat";
|
||||||
|
import { paymentPlansResponse } from "@e2e/fixtures/data/payment";
|
||||||
|
import { userEntitlementsResponse } from "@e2e/fixtures/data/user";
|
||||||
|
import {
|
||||||
|
defaultCharacterChatPath,
|
||||||
|
defaultCharacterSplashPath,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/helpers/auth";
|
||||||
|
|
||||||
|
const previewDirectory = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
".codex",
|
||||||
|
"artifacts",
|
||||||
|
"frontend-preview",
|
||||||
|
"2026-07-22",
|
||||||
|
"favorite-menu",
|
||||||
|
);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await registerFavoriteMenuMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("favorite entry and three-tab Menu navigation render on the real pages", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const mobile = testInfo.project.name.includes("mobile");
|
||||||
|
await page.setViewportSize(
|
||||||
|
mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||||
|
);
|
||||||
|
const browserErrors = collectBrowserErrors(page);
|
||||||
|
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /First recharge offer, 50% off/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Profile" })).toHaveCount(0);
|
||||||
|
await savePreview(page, `${mobile ? "mobile" : "desktop"}-chat.png`);
|
||||||
|
|
||||||
|
await page.goto(defaultCharacterSplashPath);
|
||||||
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||||
|
const navigation = page.getByRole("navigation", {
|
||||||
|
name: "Primary navigation",
|
||||||
|
});
|
||||||
|
await expect(navigation).toContainText("Chat");
|
||||||
|
await expect(navigation).toContainText("Private Zone");
|
||||||
|
await expect(navigation).toContainText("Menu");
|
||||||
|
await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`);
|
||||||
|
|
||||||
|
await page.goto("/characters/elio/private-zone");
|
||||||
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||||
|
await savePreview(
|
||||||
|
page,
|
||||||
|
`${mobile ? "mobile" : "desktop"}-private-zone.png`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Menu" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Menu" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Menu" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
await savePreview(page, `${mobile ? "mobile" : "desktop"}-menu.png`);
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem("cozsweet:favorite_entry", "1");
|
||||||
|
});
|
||||||
|
await page.goto(defaultCharacterChatPath);
|
||||||
|
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
||||||
|
await savePreview(page, "mobile-android-external-chat-download.png");
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, "userAgent", {
|
||||||
|
configurable: true,
|
||||||
|
value:
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto(defaultCharacterChatPath);
|
||||||
|
const iosDownload = page.getByRole("button", { name: "Download" });
|
||||||
|
await expect(iosDownload).toBeVisible();
|
||||||
|
await iosDownload.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Add CozSweet to Home Screen" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("dialog")).toContainText(
|
||||||
|
"In Safari, tap the Share button, then choose “Add to Home Screen”.",
|
||||||
|
);
|
||||||
|
await savePreview(page, "mobile-ios-external-chat-download.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerFavoriteMenuMocks(page: Page): Promise<void> {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
await page.route("**/api/payment/plans**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
...paymentPlansResponse,
|
||||||
|
isFirstRecharge: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ items: [], creditBalance: 80 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.context().route("**/api/**", async (route) => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
if (pathname === "/api/auth/session") {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
expires: "2099-12-31T23:59:59.000Z",
|
||||||
|
user: { email: null, image: null, name: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/characters") {
|
||||||
|
await route.fulfill({ json: apiEnvelope(chatCharactersResponse) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/user/profile") {
|
||||||
|
await route.fulfill({ json: apiEnvelope(e2eEmailUser) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/user/entitlements") {
|
||||||
|
await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/chat/history") {
|
||||||
|
await route.fulfill({ json: apiEnvelope(emptyChatHistoryResponse) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/chat/previews") {
|
||||||
|
await route.fulfill({ json: apiEnvelope(emptyChatPreviewsResponse) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/payment/plans") {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
...paymentPlansResponse,
|
||||||
|
isFirstRecharge: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/payment/vip-status") {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ isVip: false, expiresAt: null }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/api/private-zone/albums") {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ items: [], creditBalance: 80 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBrowserErrors(page: Page): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFeatureRuntimeError(message: string): boolean {
|
||||||
|
const isKnownLocalPreviewDependencyError =
|
||||||
|
message.includes("[ApiLoggingInterceptor]") ||
|
||||||
|
message.includes("[next-auth][error][CLIENT_FETCH_ERROR]") ||
|
||||||
|
message.includes("[Result] Result.wrap caught exception");
|
||||||
|
return !isKnownLocalPreviewDependencyError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePreview(page: Page, fileName: string): Promise<void> {
|
||||||
|
if (process.env.FRONTEND_PREVIEW_SCREENSHOTS !== "1") return;
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(previewDirectory, fileName),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { clearBrowserState } from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const characters = [
|
||||||
|
{
|
||||||
|
id: "elio",
|
||||||
|
slug: "elio",
|
||||||
|
displayName: "Elio Silvestri",
|
||||||
|
shortName: "Elio",
|
||||||
|
cover: "elio.png",
|
||||||
|
splashCover: "elio.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maya-tan",
|
||||||
|
slug: "maya",
|
||||||
|
displayName: "Maya Tan",
|
||||||
|
shortName: "Maya",
|
||||||
|
cover: "maya.webp",
|
||||||
|
splashCover: "maya-home.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nayeli-cervantes",
|
||||||
|
slug: "nayeli",
|
||||||
|
displayName: "Nayeli Cervantes",
|
||||||
|
shortName: "Nayeli",
|
||||||
|
cover: "nayeli.webp",
|
||||||
|
splashCover: "nayeli.webp",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const previewDirectory = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
".codex",
|
||||||
|
"artifacts",
|
||||||
|
"frontend-preview",
|
||||||
|
"2026-07-22",
|
||||||
|
"multi-role-commercial",
|
||||||
|
);
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
await registerMultiRoleCommercialMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const character of characters) {
|
||||||
|
test(`${character.displayName} homepage renders its assigned cover`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const mobile = testInfo.project.name.includes("mobile");
|
||||||
|
await page.setViewportSize(
|
||||||
|
mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/characters/${character.slug}/splash`);
|
||||||
|
|
||||||
|
const cover = page.locator(`img[src*="${character.splashCover}"]`);
|
||||||
|
await expect(cover).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("navigation", { name: "Primary navigation" }),
|
||||||
|
).toContainText("Private Zone");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
cover.evaluate((image: HTMLImageElement) =>
|
||||||
|
image.complete ? image.naturalWidth : 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.FRONTEND_PREVIEW_SCREENSHOTS === "1" &&
|
||||||
|
character.id !== "elio"
|
||||||
|
) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(
|
||||||
|
previewDirectory,
|
||||||
|
`${character.slug}-${mobile ? "mobile-390x844" : "desktop-1440x900"}.png`,
|
||||||
|
),
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${character.displayName} can open a character-scoped Private Zone`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const momentsRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/posts" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const albumRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/albums" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/characters/${character.slug}/private-zone`);
|
||||||
|
|
||||||
|
await Promise.all([momentsRequest, albumRequest]);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab")).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
||||||
|
const albumButton = page.getByRole("button", {
|
||||||
|
name: `View locked collection with 8 images from ${character.displayName}`,
|
||||||
|
});
|
||||||
|
if (character.id === "maya-tan") {
|
||||||
|
await expect(albumButton).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("No private albums yet.")).toHaveCount(0);
|
||||||
|
} else {
|
||||||
|
await expect(albumButton).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${character.displayName} receives a character-scoped Tip catalog`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const catalogRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/payment/gift-products" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/characters/${character.slug}/tip`);
|
||||||
|
|
||||||
|
await catalogRequest;
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: `Buy ${character.shortName} a coffee` }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("radio", { name: /Small Coffee/ })).toBeChecked();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Order and Buy" }),
|
||||||
|
).toBeEnabled();
|
||||||
|
await expect(
|
||||||
|
page.getByText("This character does not have any gifts available yet."),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a real Private Zone request failure still offers Retry", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
await route.fulfill({ status: 503, json: { message: "Albums unavailable" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> {
|
||||||
|
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId,
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
creditBalance: 1000,
|
||||||
|
currency: "credits",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
const character =
|
||||||
|
characters.find((candidate) => candidate.id === characterId) ?? characters[0];
|
||||||
|
|
||||||
|
const hasCompleteAlbum = character.id !== "maya-tan";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
creditBalance: 1000,
|
||||||
|
pendingImageCount: hasCompleteAlbum ? 0 : 7,
|
||||||
|
hasIncompleteContent: !hasCompleteAlbum,
|
||||||
|
items: hasCompleteAlbum ? [
|
||||||
|
{
|
||||||
|
albumId: `album-${character.id}`,
|
||||||
|
title: `${character.shortName} private photos`,
|
||||||
|
content: null,
|
||||||
|
previewText: "Unlock this private album",
|
||||||
|
imageCount: 8,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: `/images/private-zone/banner/${character.slug}.png`,
|
||||||
|
locked: true,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locked: true,
|
||||||
|
unlocked: false,
|
||||||
|
unlockCost: 320,
|
||||||
|
publishedAt: "2026-07-22T10:00:00+08:00",
|
||||||
|
lockDetail: { locked: true },
|
||||||
|
},
|
||||||
|
] : [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/payment/gift-products**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
const planPrefix =
|
||||||
|
characterId === "elio"
|
||||||
|
? "tip"
|
||||||
|
: `tip_${characterId.replaceAll("-", "_")}`;
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
category: "coffee",
|
||||||
|
name: "Coffee",
|
||||||
|
productCount: 1,
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planId: `${planPrefix}_coffee_usd_4_99`,
|
||||||
|
planName: "Small Coffee",
|
||||||
|
orderType: "tip",
|
||||||
|
tipType: "coffee_small",
|
||||||
|
category: "coffee",
|
||||||
|
characterId,
|
||||||
|
description: "A character-scoped coffee gift",
|
||||||
|
imageUrl: null,
|
||||||
|
amountCents: 499,
|
||||||
|
currency: "USD",
|
||||||
|
autoRenew: false,
|
||||||
|
isFirstRechargeOffer: false,
|
||||||
|
firstRechargeDiscountPercent: 0,
|
||||||
|
promotionType: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import { paidImageMessageId } from "@e2e/fixtures/test-data";
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { createPaidImageHistoryResponse } from "@e2e/fixtures/data/chat";
|
||||||
|
import {
|
||||||
|
paidImageDisplayMessageId,
|
||||||
|
paidImageMessageId,
|
||||||
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
completeVipPayment,
|
completeVipPayment,
|
||||||
@@ -10,15 +15,10 @@ import {
|
|||||||
enterChatFromSplash,
|
enterChatFromSplash,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
setEmailSessionStorage,
|
|
||||||
submitEmailLogin,
|
submitEmailLogin,
|
||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
|
||||||
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, {
|
await mockCoreApis(page, {
|
||||||
@@ -44,38 +44,27 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
message: "给我发图片",
|
message: "给我发图片",
|
||||||
});
|
});
|
||||||
|
|
||||||
const paidImageButton = page.getByRole("button", {
|
const lockedImageCard = page.getByRole("group", {
|
||||||
name: "Open image in fullscreen",
|
name: "Locked private image",
|
||||||
}).last();
|
}).last();
|
||||||
await expect(paidImageButton).toBeVisible();
|
await expect(lockedImageCard).toBeVisible();
|
||||||
|
await expect(lockedImageCard.locator("img")).toHaveCount(0);
|
||||||
await paidImageButton.click();
|
await lockedImageCard
|
||||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
.getByRole("button", { name: "Unlock private image" })
|
||||||
|
|
||||||
const lockedImageDialog = page.getByRole("dialog", {
|
|
||||||
name: "Locked fullscreen image",
|
|
||||||
});
|
|
||||||
await expect(lockedImageDialog).toBeVisible();
|
|
||||||
|
|
||||||
await lockedImageDialog
|
|
||||||
.getByRole("button", { name: "Unlock high-definition large image" })
|
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||||
`${defaultCharacterChatPath}?image=${paidImageMessageId}`,
|
defaultCharacterChatPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, {
|
|
||||||
expectedUrl: paidImageOverlayUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
await setEmailSessionStorage(page);
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await page.goto(`${defaultCharacterChatPath}?image=${paidImageMessageId}`);
|
await submitEmailLogin(page, {
|
||||||
|
expectedUrl: new RegExp(`${defaultCharacterChatPath}$`),
|
||||||
|
});
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
expect(unlockRequest.postDataJSON()).toMatchObject({
|
expect(unlockRequest.postDataJSON()).toMatchObject({
|
||||||
@@ -92,9 +81,44 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`${defaultCharacterChatPath}(?:\\?image=msg_photo_paywall_001)?$`,
|
`${defaultCharacterChatPath}(?:\\?image=${encodeURIComponent(paidImageDisplayMessageId)})?$`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fullscreen image closes from its back button, the image, and browser history Back", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/chat/history**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope(createPaidImageHistoryResponse(true)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
await dismissChatInterruptions(page);
|
||||||
|
|
||||||
|
const imageButton = page.getByRole("button", {
|
||||||
|
name: "Open image in fullscreen",
|
||||||
|
});
|
||||||
|
const imageDialog = page.getByRole("dialog", { name: "Fullscreen image" });
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await imageDialog.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await imageDialog.locator("img").click();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles";
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 405, height: 797 },
|
||||||
|
{ width: 540, height: 900 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
test(`payment portal stays viewport-bound at ${viewport.width}x${viewport.height}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/subscription?type=topup", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
|
||||||
|
await mountPaymentPanel(page, 180);
|
||||||
|
await expectViewportScrim(page, viewport);
|
||||||
|
await expectCenteredShortPanel(page, viewport);
|
||||||
|
|
||||||
|
await mountPaymentPanel(page, 1_200);
|
||||||
|
await expectViewportScrim(page, viewport);
|
||||||
|
await expectScrollableTallPanel(page, viewport);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPaymentPanel(page: Page, contentHeight: number) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ dialogClassName, overlayClassName, requestedContentHeight }) => {
|
||||||
|
document.querySelector('[data-testid="payment-layout-overlay"]')?.remove();
|
||||||
|
document.querySelector('[data-testid="transformed-checkout-host"]')?.remove();
|
||||||
|
|
||||||
|
const transformedCheckoutHost = document.createElement("div");
|
||||||
|
transformedCheckoutHost.dataset.testid = "transformed-checkout-host";
|
||||||
|
transformedCheckoutHost.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(transformedCheckoutHost);
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.dataset.testid = "payment-layout-overlay";
|
||||||
|
overlay.className = overlayClassName;
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.dataset.testid = "payment-layout-panel";
|
||||||
|
panel.className = dialogClassName;
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.style.height = `${requestedContentHeight}px`;
|
||||||
|
content.textContent = "Complete payment";
|
||||||
|
panel.appendChild(content);
|
||||||
|
overlay.appendChild(panel);
|
||||||
|
|
||||||
|
// Mirrors ModalPortal: the overlay must be a body child, never a child of
|
||||||
|
// the transformed checkout host that triggered it.
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dialogClassName: stripePaymentDialogStyles.dialog,
|
||||||
|
overlayClassName: stripePaymentDialogStyles.overlay,
|
||||||
|
requestedContentHeight: contentHeight,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectViewportScrim(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const overlay = page.getByTestId("payment-layout-overlay");
|
||||||
|
await expect(overlay).toBeVisible();
|
||||||
|
const box = await overlay.boundingBox();
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.x).toBeCloseTo(0, 0);
|
||||||
|
expect(box?.y).toBeCloseTo(0, 0);
|
||||||
|
expect(box?.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(box?.height).toBeCloseTo(viewport.height, 0);
|
||||||
|
await expect(overlay.evaluate((node) => node.parentElement === document.body))
|
||||||
|
.resolves.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCenteredShortPanel(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const panel = page.getByTestId("payment-layout-panel");
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.y ?? 0).toBeGreaterThan(100);
|
||||||
|
expect(Math.abs((box?.y ?? 0) * 2 + (box?.height ?? 0) - viewport.height))
|
||||||
|
.toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectScrollableTallPanel(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const panel = page.getByTestId("payment-layout-panel");
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
const scrollMetrics = await panel.evaluate((node) => ({
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.y ?? 0).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(box?.y ?? 100).toBeLessThanOrEqual(25);
|
||||||
|
expect((box?.y ?? 0) + (box?.height ?? 0))
|
||||||
|
.toBeLessThanOrEqual(viewport.height - 15);
|
||||||
|
expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const facebookAndroidUserAgent =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
|
||||||
|
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||||
|
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
userAgent: facebookAndroidUserAgent,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumes the handoff, removes its token, and does not create an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page, { checkoutHandoffFlow: true });
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const consumeRequest = page.waitForRequest(
|
||||||
|
"**/api/auth/handoff/checkout/consume",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/,
|
||||||
|
);
|
||||||
|
expect(page.url()).not.toContain("handoffToken");
|
||||||
|
await expect.poll(() => createOrderRequests).toBe(0);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers the Facebook external-browser path before creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/handoff/checkout", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
|
||||||
|
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||||
|
const externalButton = page.getByRole("button", {
|
||||||
|
name: "Open in browser for more payment methods",
|
||||||
|
});
|
||||||
|
const paymentButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(externalButton).toBeVisible();
|
||||||
|
await expect(externalButton).toBeEnabled();
|
||||||
|
await expect(paymentButton).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
const externalBox = await externalButton.boundingBox();
|
||||||
|
const paymentBox = await paymentButton.boundingBox();
|
||||||
|
expect(externalBox).not.toBeNull();
|
||||||
|
expect(paymentBox).not.toBeNull();
|
||||||
|
expect(externalBox!.y).toBeLessThan(paymentBox!.y);
|
||||||
|
expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844);
|
||||||
|
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
|
||||||
|
await externalButton.click();
|
||||||
|
expect((await handoffRequest).postDataJSON()).toMatchObject({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
});
|
||||||
|
expect(createOrderRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { e2eEmailUser } from "@e2e/fixtures/data/user";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const idrPlans = {
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planId: "vip_monthly",
|
||||||
|
planName: "Monthly",
|
||||||
|
orderType: "vip_monthly",
|
||||||
|
amountCents: 19_590_000,
|
||||||
|
originalAmountCents: 19_590_000,
|
||||||
|
dailyPriceCents: 653_000,
|
||||||
|
currency: "IDR",
|
||||||
|
vipDays: 30,
|
||||||
|
dolAmount: null,
|
||||||
|
creditBalance: 3_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
planId: "dol_1000",
|
||||||
|
planName: "1,000 Credits",
|
||||||
|
orderType: "dol",
|
||||||
|
amountCents: 8_890_000,
|
||||||
|
originalAmountCents: null,
|
||||||
|
dailyPriceCents: null,
|
||||||
|
currency: "IDR",
|
||||||
|
vipDays: null,
|
||||||
|
dolAmount: 1_000,
|
||||||
|
creditBalance: 1_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const idrGiftCatalog = {
|
||||||
|
characterId: "elio",
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
category: "coffee",
|
||||||
|
name: "Coffee",
|
||||||
|
productCount: 1,
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planId: "tip_coffee_usd_4_99",
|
||||||
|
planName: "Velvet Espresso",
|
||||||
|
orderType: "tip",
|
||||||
|
tipType: "coffee_small",
|
||||||
|
category: "coffee",
|
||||||
|
characterId: "elio",
|
||||||
|
description: "Buy Elio a Velvet Espresso",
|
||||||
|
imageUrl: null,
|
||||||
|
amountCents: 8_929_900,
|
||||||
|
currency: "IDR",
|
||||||
|
autoRenew: false,
|
||||||
|
isFirstRechargeOffer: false,
|
||||||
|
firstRechargeDiscountPercent: 0,
|
||||||
|
promotionType: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function registerIndonesiaPaymentMocks(page: Page) {
|
||||||
|
const orderStatuses = new Map<string, "pending" | "paid">();
|
||||||
|
const orderPlans = new Map<string, { planId: string; orderType: string }>();
|
||||||
|
let createOrderCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/user/profile", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/plans**", async (route) => {
|
||||||
|
await route.fulfill({ json: apiEnvelope(idrPlans) });
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/gift-products**", async (route) => {
|
||||||
|
await route.fulfill({ json: apiEnvelope(idrGiftCatalog) });
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/create-order", async (route) => {
|
||||||
|
createOrderCount += 1;
|
||||||
|
const body = route.request().postDataJSON() as { planId: string };
|
||||||
|
const plan =
|
||||||
|
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
||||||
|
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
||||||
|
const orderId = `order_qris_${body.planId}`;
|
||||||
|
orderStatuses.set(orderId, "pending");
|
||||||
|
orderPlans.set(orderId, {
|
||||||
|
planId: body.planId,
|
||||||
|
orderType: plan?.orderType ?? "dol",
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId,
|
||||||
|
payParams: {
|
||||||
|
provider: "ezpay",
|
||||||
|
countryCode: "ID",
|
||||||
|
channelCode: "ID_QRIS_DYNAMIC_QR",
|
||||||
|
channelType: "QR",
|
||||||
|
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||||
|
firstChargeAmountCents: plan?.amountCents ?? 0,
|
||||||
|
currency: "IDR",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/order-status**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const orderId = url.searchParams.get("order_id") ?? "";
|
||||||
|
const plan = orderPlans.get(orderId);
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId,
|
||||||
|
status: orderStatuses.get(orderId) ?? "pending",
|
||||||
|
orderType: plan?.orderType ?? "dol",
|
||||||
|
planId: plan?.planId ?? null,
|
||||||
|
creditsAdded: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/tip-message", async (route) => {
|
||||||
|
const body = route.request().postDataJSON() as { orderId: string };
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId: body.orderId,
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "tip_coffee_usd_4_99",
|
||||||
|
productName: "Velvet Espresso",
|
||||||
|
tipCount: 1,
|
||||||
|
poolIndex: 0,
|
||||||
|
message: "Thank you. Your gift made me smile.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCreateOrderCount() {
|
||||||
|
return createOrderCount;
|
||||||
|
},
|
||||||
|
markPaid(orderId: string) {
|
||||||
|
orderStatuses.set(orderId, "paid");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareIndonesiaUser(page: Page) {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const rawUser = localStorage.getItem("cozsweet:user");
|
||||||
|
const user = rawUser ? JSON.parse(rawUser) : {};
|
||||||
|
localStorage.setItem(
|
||||||
|
"cozsweet:user",
|
||||||
|
JSON.stringify({ ...user, countryCode: "ID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectQrisOrder(
|
||||||
|
page: Page,
|
||||||
|
buttonName: RegExp,
|
||||||
|
planId: string,
|
||||||
|
formattedAmount: RegExp,
|
||||||
|
) {
|
||||||
|
const requestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||||
|
await page.getByRole("button", { name: buttonName }).click();
|
||||||
|
const request = await requestPromise;
|
||||||
|
expect(request.postDataJSON()).toMatchObject({
|
||||||
|
planId,
|
||||||
|
payChannel: "ezpay",
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(formattedAmount);
|
||||||
|
await expect(dialog).toContainText("Waiting for payment");
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
||||||
|
).toBeVisible();
|
||||||
|
return `order_qris_${planId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCheckoutButtonLayout(page: Page) {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
const checkoutBox = await page
|
||||||
|
.getByRole("button", { name: "Pay and Top Up" })
|
||||||
|
.boundingBox();
|
||||||
|
const stripeBox = await page.getByRole("button", { name: "Stripe" }).boundingBox();
|
||||||
|
expect(viewport).not.toBeNull();
|
||||||
|
expect(checkoutBox).not.toBeNull();
|
||||||
|
expect(stripeBox).not.toBeNull();
|
||||||
|
if (!viewport || !checkoutBox || !stripeBox) return;
|
||||||
|
expect(checkoutBox.y + checkoutBox.height).toBeLessThanOrEqual(viewport.height);
|
||||||
|
expect(stripeBox.y + stripeBox.height).toBeLessThanOrEqual(checkoutBox.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Indonesia VIP defaults to QRIS and completes after status polling", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=vip&character=elio");
|
||||||
|
|
||||||
|
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Elio", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(checkoutButton).toBeEnabled();
|
||||||
|
await expectCheckoutButtonLayout(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(0);
|
||||||
|
|
||||||
|
const orderId = await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"vip_monthly",
|
||||||
|
/Rp\s*195[.,]900/,
|
||||||
|
);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
payment.markPaid(orderId);
|
||||||
|
|
||||||
|
const success = page.getByRole("alertdialog", { name: "Payment successful" });
|
||||||
|
await expect(success).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||||
|
).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
|
await expectCheckoutButtonLayout(page);
|
||||||
|
|
||||||
|
const orderId = await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"dol_1000",
|
||||||
|
/Rp\s*88[.,]900/,
|
||||||
|
);
|
||||||
|
payment.markPaid(orderId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alertdialog", { name: "Payment successful" }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"dol_1000",
|
||||||
|
/Rp\s*88[.,]900/,
|
||||||
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
|
await page.getByRole("button", { name: "Stripe" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("payment issue submits Other to the feedback API without creating an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
let feedbackBody = "";
|
||||||
|
let idempotencyKey = "";
|
||||||
|
await page.route("**/api/feedback", async (route) => {
|
||||||
|
feedbackBody = route.request().postData() ?? "";
|
||||||
|
idempotencyKey = route.request().headers()["idempotency-key"] ?? "";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ feedbackId: "feedback-payment-1", duplicate: false }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
await page.getByRole("button", { name: "Payment issue?" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", {
|
||||||
|
name: "What problem did you encounter?",
|
||||||
|
});
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole("radio", { name: "Other" }).check();
|
||||||
|
await dialog
|
||||||
|
.getByLabel("Please describe the issue")
|
||||||
|
.fill("QRIS did not open correctly.");
|
||||||
|
await dialog.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("status")).toHaveText(
|
||||||
|
"Thanks. Your payment issue has been submitted.",
|
||||||
|
);
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(0);
|
||||||
|
expect(idempotencyKey).toMatch(/^payment_feedback_[A-Za-z0-9_-]+$/);
|
||||||
|
expect(feedbackBody).toContain('name="category"');
|
||||||
|
expect(feedbackBody).toContain("payment");
|
||||||
|
expect(feedbackBody).toContain(
|
||||||
|
"Payment issue: Other\r\nDetails: QRIS did not open correctly.",
|
||||||
|
);
|
||||||
|
expect(feedbackBody).toContain('name="context"');
|
||||||
|
expect(feedbackBody).toContain('"paymentIssueReason":"other"');
|
||||||
|
expect(feedbackBody).toContain('"characterId":"elio"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/characters/elio/tip");
|
||||||
|
|
||||||
|
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
|
||||||
|
await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Order and Buy/i,
|
||||||
|
"tip_coffee_usd_4_99",
|
||||||
|
/Rp\s*89[.,]299/,
|
||||||
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
|
await page.getByRole("button", { name: "Stripe" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("serves the VIP membership benefits agreement from the same site", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto("/legal/vip-membership-benefits.html");
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "VIP Membership Benefits Agreement" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves the automatic renewal agreement from the same site", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto("/legal/automatic-renewal.html");
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Automatic Renewal Agreement" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { e2eEmailUser } from "@e2e/fixtures/data/user";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const phpPlans = {
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planId: "dol_1000",
|
||||||
|
planName: "1,000 Credits",
|
||||||
|
orderType: "dol",
|
||||||
|
amountCents: 49_990,
|
||||||
|
originalAmountCents: 74_990,
|
||||||
|
dailyPriceCents: null,
|
||||||
|
currency: "PHP",
|
||||||
|
vipDays: null,
|
||||||
|
dolAmount: 1_000,
|
||||||
|
creditBalance: 1_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function registerPhilippinesPaymentMocks(
|
||||||
|
page: Page,
|
||||||
|
response: "hosted" | "qr",
|
||||||
|
) {
|
||||||
|
let createOrderCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/user/profile", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/plans**", async (route) => {
|
||||||
|
await route.fulfill({ json: apiEnvelope(phpPlans) });
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/create-order", async (route) => {
|
||||||
|
createOrderCount += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId: `order_gcash_${response}`,
|
||||||
|
payParams: {
|
||||||
|
provider: "ezpay",
|
||||||
|
countryCode: "PH",
|
||||||
|
channelCode: "PH_QRPH_DYNAMIC",
|
||||||
|
channelType: "QR",
|
||||||
|
payData: "000201010212ph-qr-payload",
|
||||||
|
...(response === "hosted"
|
||||||
|
? { cashierUrl: "https://pay.example/gcash-hosted" }
|
||||||
|
: {}),
|
||||||
|
firstChargeAmountCents: 49_990,
|
||||||
|
currency: "PHP",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/order-status**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId: `order_gcash_${response}`,
|
||||||
|
status: "pending",
|
||||||
|
orderType: "dol",
|
||||||
|
planId: "dol_1000",
|
||||||
|
creditsAdded: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCreateOrderCount: () => createOrderCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preparePhilippinesUser(page: Page) {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const rawUser = localStorage.getItem("cozsweet:user");
|
||||||
|
const user = rawUser ? JSON.parse(rawUser) : {};
|
||||||
|
localStorage.setItem(
|
||||||
|
"cozsweet:user",
|
||||||
|
JSON.stringify({ ...user, countryCode: "PH" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
|
||||||
|
await preparePhilippinesUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alertdialog", { name: "Continue to payment?" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const payment = await registerPhilippinesPaymentMocks(page, "qr");
|
||||||
|
await preparePhilippinesUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", {
|
||||||
|
name: "Pay with GCash / QR Ph",
|
||||||
|
});
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(/₱\s*499\.90/);
|
||||||
|
await expect(dialog).not.toContainText("QRIS");
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
|
await page.getByRole("button", { name: "Stripe" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const postId = "11111111-1111-1111-1111-111111111111";
|
||||||
|
let unlocked = false;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
unlocked = false;
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
await registerPrivateZoneVideoMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Private Zone defaults to Albums and reveals video after switching to Moments", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
|
||||||
|
const listRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/posts" &&
|
||||||
|
url.searchParams.get("characterId") === "maya-tan"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
await listRequest;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Maya archive")).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "Moments" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private moments" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Only for you.")).toBeVisible();
|
||||||
|
await expect(page.locator("video")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Unlock for 100 credits" }).click();
|
||||||
|
const dialog = page.getByRole("alertdialog");
|
||||||
|
await expect(dialog).toContainText("100 credits");
|
||||||
|
const unlockRequest = page.waitForRequest(
|
||||||
|
`**/api/private-zone/posts/${postId}/unlock`,
|
||||||
|
);
|
||||||
|
await dialog.getByRole("button", { name: "Unlock", exact: true }).click();
|
||||||
|
expect((await unlockRequest).postDataJSON()).toEqual({ expectedCost: 100 });
|
||||||
|
|
||||||
|
const video = page.locator("video");
|
||||||
|
await expect(video).toHaveCount(1);
|
||||||
|
await expect(video.locator("source")).toHaveAttribute("src", /video\.mp4/);
|
||||||
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Albums render first and Moments remain available behind the second tab", async ({ page }) => {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
|
||||||
|
const tabs = page.getByRole("tab");
|
||||||
|
await expect(tabs).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Maya archive")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Moments" }).click();
|
||||||
|
await expect(page.getByRole("tab", { name: "Moments" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private moments" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerPrivateZoneVideoMocks(page: Page): Promise<void> {
|
||||||
|
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId: url.searchParams.get("characterId") ?? "maya-tan",
|
||||||
|
items: [videoPost(unlocked)],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
creditBalance: unlocked ? 150 : 250,
|
||||||
|
currency: "credits",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playwright evaluates page routes in reverse registration order. Register
|
||||||
|
// the specific mutation last so the broader list route cannot intercept it.
|
||||||
|
await page.route("**/api/private-zone/posts/*/unlock", async (route) => {
|
||||||
|
unlocked = true;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
postId,
|
||||||
|
reason: "ok",
|
||||||
|
unlocked: true,
|
||||||
|
creditsCharged: 100,
|
||||||
|
requiredCredits: 100,
|
||||||
|
currentCredits: 150,
|
||||||
|
shortfallCredits: 0,
|
||||||
|
creditBalance: 150,
|
||||||
|
post: videoPost(true),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
albumId: "album-maya",
|
||||||
|
title: "Maya archive",
|
||||||
|
content: null,
|
||||||
|
previewText: "Private photos",
|
||||||
|
imageCount: 1,
|
||||||
|
images: [{ url: "/images/private-zone/banner/maya.png", locked: true, index: 0 }],
|
||||||
|
locked: true,
|
||||||
|
unlocked: false,
|
||||||
|
unlockCost: 80,
|
||||||
|
publishedAt: "2026-07-24T10:00:00+00:00",
|
||||||
|
lockDetail: { locked: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
creditBalance: 250,
|
||||||
|
pendingImageCount: 0,
|
||||||
|
hasIncompleteContent: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoPost(isUnlocked: boolean) {
|
||||||
|
return {
|
||||||
|
postId,
|
||||||
|
characterId: "maya-tan",
|
||||||
|
caption: "Only for you.",
|
||||||
|
posterUrl: "/images/private-zone/banner/maya.png",
|
||||||
|
mediaType: "video",
|
||||||
|
videoUrl: isUnlocked ? "/test-video.mp4?token=short" : null,
|
||||||
|
videoMimeType: "video/mp4",
|
||||||
|
videoSizeBytes: 1024,
|
||||||
|
durationSeconds: 18,
|
||||||
|
unlockCostCredits: 100,
|
||||||
|
currency: "credits",
|
||||||
|
locked: !isUnlocked,
|
||||||
|
unlocked: isUnlocked,
|
||||||
|
availableForNewUnlock: true,
|
||||||
|
publishedAt: "2026-07-24T10:00:00+00:00",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import { paidImageMessageId } from "@e2e/fixtures/test-data";
|
import {
|
||||||
|
paidImageMessageId,
|
||||||
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
defaultCharacterChatPath,
|
|
||||||
dismissChatInterruptions,
|
dismissChatInterruptions,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
signInWithEmailAndOpenChat,
|
signInWithEmailAndOpenChat,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
|
||||||
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, {
|
await mockCoreApis(page, {
|
||||||
@@ -22,30 +19,23 @@ test.beforeEach(async ({ baseURL, context, page }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user sees insufficient credits when unlocking a paid image from fullscreen and can navigate to subscription", async ({
|
test("user sees insufficient credits when unlocking from the locked image card and can navigate to subscription", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await signInWithEmailAndOpenChat(page);
|
await signInWithEmailAndOpenChat(page);
|
||||||
await dismissChatInterruptions(page);
|
await dismissChatInterruptions(page);
|
||||||
|
|
||||||
const paidImageButton = page.getByRole("button", {
|
const lockedImageCard = page.getByRole("group", {
|
||||||
name: "Open image in fullscreen",
|
name: "Locked private image",
|
||||||
}).last();
|
}).last();
|
||||||
await expect(paidImageButton).toBeVisible({ timeout: 10_000 });
|
await expect(lockedImageCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(lockedImageCard.locator("img")).toHaveCount(0);
|
||||||
await paidImageButton.click();
|
|
||||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
|
||||||
|
|
||||||
const lockedImageDialog = page.getByRole("dialog", {
|
|
||||||
name: "Locked fullscreen image",
|
|
||||||
});
|
|
||||||
await expect(lockedImageDialog).toBeVisible();
|
|
||||||
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await lockedImageDialog
|
await lockedImageCard
|
||||||
.getByRole("button", { name: "Unlock high-definition large image" })
|
.getByRole("button", { name: "Unlock private image" })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test.describe("pre-release email login smoke", () => {
|
|||||||
urlTimeout: 20_000,
|
urlTimeout: 20_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# Server-only deploy configuration.
|
|
||||||
# Copy this file to .deploy.env on the deployment server when you want the
|
|
||||||
# post-receive hook to pull CI-built images instead of building locally.
|
|
||||||
|
|
||||||
# build: build Docker image on the deployment server.
|
|
||||||
# pull: pull Docker image from registry and restart the container.
|
|
||||||
COZSWEET_DEPLOY_IMAGE_SOURCE=pull
|
|
||||||
|
|
||||||
# Full image repository without tag.
|
|
||||||
# Example:
|
|
||||||
# COZSWEET_REGISTRY_IMAGE=gitea.banlv-ai.com/admin/cozsweet-web
|
|
||||||
COZSWEET_REGISTRY_IMAGE=gitea.banlv-ai.com/admin/cozsweet-web
|
|
||||||
|
|
||||||
# Optional. Defaults to <deploy_env>-<short_sha>, for example:
|
|
||||||
# - test-abc1234 on test branch
|
|
||||||
# - prod-abc1234 on main branch
|
|
||||||
#
|
|
||||||
# You may set this to test-latest or prod-latest for latest-tag deploys.
|
|
||||||
# COZSWEET_REMOTE_IMAGE_TAG=
|
|
||||||
@@ -32,8 +32,8 @@ AUTH_SECRET=PinzhujxR/qMPrY7bAor8qQlwmmy1tJJCex9Tomhk2c=
|
|||||||
# 第三方 OAuth
|
# 第三方 OAuth
|
||||||
AUTH_GOOGLE_ID=351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com
|
AUTH_GOOGLE_ID=351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com
|
||||||
AUTH_GOOGLE_SECRET=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
|
AUTH_GOOGLE_SECRET=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
|
||||||
AUTH_FACEBOOK_ID=26934819589512827
|
AUTH_FACEBOOK_ID=3346658785491798
|
||||||
AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad
|
AUTH_FACEBOOK_SECRET=11afd29e30d53d55f069ec2e63f5b61c
|
||||||
|
|
||||||
# Cloudflare CDN(test / production 部署后刷缓存用)
|
# Cloudflare CDN(test / production 部署后刷缓存用)
|
||||||
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
|||||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
|
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
|
||||||
|
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
@@ -25,8 +26,8 @@ AUTH_SECRET=rKGeHQ/8tKPoh12Kiedil5zHuj9ZfPA0NwY9bgwRqkE=
|
|||||||
# 第三方 OAuth
|
# 第三方 OAuth
|
||||||
AUTH_GOOGLE_ID=351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com
|
AUTH_GOOGLE_ID=351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com
|
||||||
AUTH_GOOGLE_SECRET=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
|
AUTH_GOOGLE_SECRET=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
|
||||||
AUTH_FACEBOOK_ID=26934819589512827
|
AUTH_FACEBOOK_ID=3346658785491798
|
||||||
AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad
|
AUTH_FACEBOOK_SECRET=11afd29e30d53d55f069ec2e63f5b61c
|
||||||
|
|
||||||
# Cloudflare CDN(test / production 部署后刷缓存用)
|
# Cloudflare CDN(test / production 部署后刷缓存用)
|
||||||
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ NEXT_PUBLIC_API_BASE_URL=https://api.banlv-ai.com
|
|||||||
|
|
||||||
# WebSocket URL(生产环境)
|
# WebSocket URL(生产环境)
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
|
||||||
|
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
|
||||||
|
|
||||||
# Stripe 支付(publishable key 可暴露在浏览器端)
|
# Stripe 支付(publishable key 可暴露在浏览器端)
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
|
||||||
@@ -34,8 +35,8 @@ AUTH_SECRET=XypsCWoLCNmwOaq4taIMJHe4+cU9YZBaYN4VMTtmSik=
|
|||||||
# 第三方 OAuth
|
# 第三方 OAuth
|
||||||
AUTH_GOOGLE_ID=351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com
|
AUTH_GOOGLE_ID=351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com
|
||||||
AUTH_GOOGLE_SECRET=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
|
AUTH_GOOGLE_SECRET=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
|
||||||
AUTH_FACEBOOK_ID=26934819589512827
|
AUTH_FACEBOOK_ID=3346658785491798
|
||||||
AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad
|
AUTH_FACEBOOK_SECRET=11afd29e30d53d55f069ec2e63f5b61c
|
||||||
|
|
||||||
# Cloudflare CDN(test / production 部署后刷缓存用)
|
# Cloudflare CDN(test / production 部署后刷缓存用)
|
||||||
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 19 KiB |
@@ -2,9 +2,14 @@ import { withSentryConfig } from "@sentry/nextjs";
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { withSerwist } from "@serwist/turbopack";
|
import { withSerwist } from "@serwist/turbopack";
|
||||||
|
|
||||||
|
import { LEGACY_GLOBAL_ROUTE_REDIRECTS } from "./src/router/legacy-global-route-redirects";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Docker runs the traced runtime emitted to `.next/standalone`.
|
// Docker runs the traced runtime emitted to `.next/standalone`.
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
async redirects() {
|
||||||
|
return [...LEGACY_GLOBAL_ROUTE_REDIRECTS];
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
"test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium",
|
"test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium",
|
||||||
"test:e2e:headed": "playwright test --project=mock-chromium --headed",
|
"test:e2e:headed": "playwright test --project=mock-chromium --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-sidebar.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts",
|
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-profile.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts",
|
||||||
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
|
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
|
||||||
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke",
|
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://api.banlv-ai.com} playwright test --project=prod-smoke",
|
||||||
"contract:check": "node scripts/contracts/check-openapi.mjs",
|
"contract:check": "node scripts/contracts/check-openapi.mjs",
|
||||||
"test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs",
|
"test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs",
|
||||||
"perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
|
"perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"next-auth": "^4.24.14",
|
"next-auth": "^4.24.14",
|
||||||
"ofetch": "^1.5.1",
|
"ofetch": "^1.5.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
// Next.js dev + Turbopack can return incomplete module payloads when the
|
||||||
|
// local mock suite compiles many routes concurrently. Keep this tier
|
||||||
|
// deterministic locally as well as in CI.
|
||||||
|
workers: 1,
|
||||||
reporter: process.env.CI
|
reporter: process.env.CI
|
||||||
? [["list"], ["html", { open: "never" }]]
|
? [["list"], ["html", { open: "never" }]]
|
||||||
: [["list"], ["html", { open: "never" }]],
|
: [["list"], ["html", { open: "never" }]],
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ importers:
|
|||||||
pino:
|
pino:
|
||||||
specifier: ^10.3.1
|
specifier: ^10.3.1
|
||||||
version: 10.3.1
|
version: 10.3.1
|
||||||
|
qrcode.react:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(react@19.2.4)
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -3290,6 +3293,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0:
|
||||||
|
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -7085,6 +7093,10 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
quick-format-unescaped@4.0.4: {}
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 814 KiB After Width: | Height: | Size: 814 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 907 KiB After Width: | Height: | Size: 907 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 490 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>Automatic Renewal Agreement | Cozsweet</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||||
|
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||||
|
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||||
|
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||||
|
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||||
|
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Automatic Renewal Agreement</h1>
|
||||||
|
<p class="meta">Effective date: July 29, 2026</p>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
By selecting <strong>Confirm</strong> before payment, you authorize Cozsweet and its payment provider to renew the selected membership automatically under the terms shown below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. When renewal applies</h2>
|
||||||
|
<p>
|
||||||
|
Automatic renewal applies only when the selected membership is identified as auto-renewing in the purchase flow. One-time credit purchases, tips, gifts, lifetime plans, and payment methods explicitly shown as one-time purchases do not automatically renew.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Billing frequency</h2>
|
||||||
|
<p>
|
||||||
|
The plan renews at the end of the billing period shown at checkout, such as monthly, quarterly, or annually. Renewal continues until cancelled. Your payment method may be charged shortly before or on the start of the next period as permitted by the payment provider.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Renewal amount</h2>
|
||||||
|
<p>
|
||||||
|
The initial charge and renewal amount are displayed before confirmation. A promotion may reduce the first charge without reducing later renewals. Unless another renewal amount is shown, renewal uses the applicable regular price for the selected plan, together with any taxes or fees required by law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Confirmation</h2>
|
||||||
|
<p>
|
||||||
|
Cozsweet asks for renewal confirmation when an account first attempts to pay for an automatically renewing membership in the current agreement version. Cancelling the confirmation does not create an order. Confirming records the acknowledgement in the current browser and continues to payment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Cancellation</h2>
|
||||||
|
<p>
|
||||||
|
You may cancel future renewal using a cancellation option made available in the product or by using the support/payment-issue path before the next renewal is processed. Cancellation takes effect for future billing; membership already paid for normally remains available until the end of the current term.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Failed or disputed payments</h2>
|
||||||
|
<p>
|
||||||
|
A failed renewal can interrupt membership benefits. If you see a duplicate charge, a long-pending payment, or missing benefits, do not repeatedly submit payment. Use <strong>Payment issue?</strong> on the purchase page and keep the returned Feedback ID. Never provide passwords, full card numbers, security codes, verification codes, or access tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Material changes</h2>
|
||||||
|
<p>
|
||||||
|
If the renewal terms materially change, Cozsweet will update this agreement and request a new acknowledgement when required. The version effective for a payment is the version linked from its confirmation dialog.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>VIP Membership Benefits Agreement | Cozsweet</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||||
|
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||||
|
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||||
|
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||||
|
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||||
|
a { color: #d8327c; }
|
||||||
|
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>VIP Membership Benefits Agreement</h1>
|
||||||
|
<p class="meta">Effective date: July 29, 2026</p>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
This agreement explains the VIP membership displayed on the Cozsweet purchase page. The plan name, price, currency, duration, credits, and benefits shown at checkout are the terms that apply to your purchase.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Membership benefits</h2>
|
||||||
|
<p>
|
||||||
|
VIP may include the chat access, credits, private content access, or other benefits displayed for the selected plan. Benefits are available only for the membership period and account shown at checkout. A membership does not guarantee that every feature or item is free; some content can still require credits or a separate purchase when clearly shown in the product.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Plan term and account</h2>
|
||||||
|
<p>
|
||||||
|
Membership is attached to the Cozsweet account used for payment. The selected monthly, quarterly, annual, lifetime, or other plan term is shown before payment. Do not share payment credentials or account access with another person.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Automatic renewal</h2>
|
||||||
|
<p>
|
||||||
|
A plan marked as automatically renewing continues until cancelled. Before the first automatically renewing purchase, Cozsweet displays a separate confirmation. Please also read the <a href="/legal/automatic-renewal.html">Automatic Renewal Agreement</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Prices and promotions</h2>
|
||||||
|
<p>
|
||||||
|
The checkout page is the source of truth for the current amount and currency. A first-payment or limited promotion can apply only to the initial charge; the renewal amount may be the regular price displayed in the renewal notice. Cozsweet does not ask you to pay a price that is not shown in the checkout flow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Payment and access</h2>
|
||||||
|
<p>
|
||||||
|
Benefits are activated only after payment is confirmed and the order is fulfilled. A pending or failed payment does not activate membership. If payment is duplicated, remains pending, or benefits are missing, do not submit repeated payments; use the <strong>Payment issue?</strong> entry on the purchase page and retain the returned Feedback ID.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Cancellation, refunds, and applicable rights</h2>
|
||||||
|
<p>
|
||||||
|
Cancellation stops future renewal but does not normally remove benefits already paid for during the current term. Refund rights depend on applicable law, the payment provider, and the circumstances of the order. Use the product support or payment-issue path for review; never send a password, card number, security code, verification code, or access token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Changes</h2>
|
||||||
|
<p>
|
||||||
|
If a material membership term changes, the updated version and effective date will be made available before it applies to a new purchase or renewal where required.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
"lang": "en",
|
"lang": "en",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/icons/Icon-192.png",
|
"src": "/images/icons/Icon-192.png?v=20260724",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/icons/Icon-512.png",
|
"src": "/images/icons/Icon-512.png?v=20260724",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ describe("OpenAPI contract comparison", () => {
|
|||||||
chatHistory: { method: "get", path: "/api/chat/history" },
|
chatHistory: { method: "get", path: "/api/chat/history" },
|
||||||
unlockAlbum: {
|
unlockAlbum: {
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/api/private-room/albums/{albumId}/unlock",
|
path: "/api/private-zone/albums/{albumId}/unlock",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
paths: {
|
paths: {
|
||||||
"/api/chat/history": { get: {} },
|
"/api/chat/history": { get: {} },
|
||||||
"/api/private-room/albums/{album_id}/unlock/": { post: {} },
|
"/api/private-zone/albums/{album_id}/unlock/": { post: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 推送指定分支到指定远端。
|
# 推送指定分支到指定远端。
|
||||||
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
||||||
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
||||||
# deploy_web_test.sh(test)→ push_to_remote "gitea" "test"
|
# deploy_web_test.sh(pre)→ push_to_remote "gitea" "pre"
|
||||||
push_to_remote() {
|
push_to_remote() {
|
||||||
local remote="$1"
|
local remote="$1"
|
||||||
local branch="$2"
|
local branch="$2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
# 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||||
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
|
|||||||
|
|
||||||
# 主函数
|
# 主函数
|
||||||
main() {
|
main() {
|
||||||
push_to_remote "gitea" "test"
|
push_to_remote "gitea" "pre"
|
||||||
}
|
}
|
||||||
|
|
||||||
main
|
main
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ unset_git_hook_env() {
|
|||||||
|
|
||||||
unset_git_hook_env
|
unset_git_hook_env
|
||||||
|
|
||||||
# Test 分支 worktree 路径
|
# pre 分支 worktree 路径
|
||||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test"
|
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
|
||||||
echo "=== 进入 test 分支 worktree ==="
|
echo "=== 进入 pre 分支 worktree ==="
|
||||||
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
||||||
|
|
||||||
echo "=== 检查当前分支 ==="
|
echo "=== 检查当前分支 ==="
|
||||||
@@ -22,17 +22,17 @@ git branch --show-current
|
|||||||
echo "=== 变基到 dev 分支 ==="
|
echo "=== 变基到 dev 分支 ==="
|
||||||
git rebase dev
|
git rebase dev
|
||||||
|
|
||||||
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/)
|
# 复制预发环境图标(运行资源目录仍为 icons/test)
|
||||||
echo "=== 复制测试环境图标(icons/test → public/) ==="
|
echo "=== 复制预发环境图标(icons/test → public/) ==="
|
||||||
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
||||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
||||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
||||||
|
|
||||||
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local)
|
# 准备预发环境变量(运行配置仍使用 .env.local)
|
||||||
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local) ==="
|
echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local) ==="
|
||||||
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
||||||
|
|
||||||
echo "=== 执行构建部署脚本(测试环境)==="
|
echo "=== 执行构建部署脚本(预发环境)==="
|
||||||
./scripts/deploy/deploy_web_test.sh
|
./scripts/deploy/deploy_web_test.sh
|
||||||
|
|
||||||
echo "=== 预发布完成 ==="
|
echo "=== 预发布完成 ==="
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ echo "=== compose project: $COMPOSE_PROJECT_NAME ==="
|
|||||||
"${COMPOSE[@]}" up -d --remove-orphans --no-build web
|
"${COMPOSE[@]}" up -d --remove-orphans --no-build web
|
||||||
"${COMPOSE[@]}" ps
|
"${COMPOSE[@]}" ps
|
||||||
|
|
||||||
docker image prune -f
|
|
||||||
|
|
||||||
prune_project_images() {
|
prune_project_images() {
|
||||||
local retain_count="$1"
|
local retain_count="$1"
|
||||||
if ! [[ "$retain_count" =~ ^[0-9]+$ ]] || [ "$retain_count" -le 0 ]; then
|
if ! [[ "$retain_count" =~ ^[0-9]+$ ]] || [ "$retain_count" -le 0 ]; then
|
||||||
|
|||||||
@@ -79,4 +79,34 @@ describe("shared Tailwind components", () => {
|
|||||||
expect(guestHtml).toContain('aria-label="Guest avatar"');
|
expect(guestHtml).toContain('aria-label="Guest avatar"');
|
||||||
expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png");
|
expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders interactive avatars as accessible buttons", () => {
|
||||||
|
const characterHtml = renderToStaticMarkup(
|
||||||
|
<CharacterAvatar
|
||||||
|
src="/images/avatar/elio.png"
|
||||||
|
alt="Elio Silvestri"
|
||||||
|
actionLabel="Open Elio's private zone"
|
||||||
|
analyticsKey="chat.open_private_zone_from_avatar"
|
||||||
|
onClick={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const userHtml = renderToStaticMarkup(
|
||||||
|
<UserMessageAvatar
|
||||||
|
actionLabel="Open profile"
|
||||||
|
analyticsKey="chat.open_profile_from_avatar"
|
||||||
|
onClick={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(characterHtml).toContain('<button type="button"');
|
||||||
|
expect(characterHtml).toContain(
|
||||||
|
'aria-label="Open Elio's private zone"',
|
||||||
|
);
|
||||||
|
expect(characterHtml).toContain(
|
||||||
|
'data-analytics-key="chat.open_private_zone_from_avatar"',
|
||||||
|
);
|
||||||
|
expect(userHtml).toContain('<button type="button"');
|
||||||
|
expect(userHtml).toContain('aria-label="Open profile"');
|
||||||
|
expect(userHtml).toContain("focus-visible:outline-3");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type AvatarInteractionProps =
|
||||||
|
| {
|
||||||
|
onClick?: undefined;
|
||||||
|
actionLabel?: never;
|
||||||
|
analyticsKey?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
onClick: () => void;
|
||||||
|
actionLabel: string;
|
||||||
|
analyticsKey?: string;
|
||||||
|
};
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
export interface CharacterAvatarProps {
|
import type { AvatarInteractionProps } from "./avatar-interaction";
|
||||||
|
|
||||||
|
interface CharacterAvatarVisualProps {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -12,6 +14,9 @@ export interface CharacterAvatarProps {
|
|||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CharacterAvatarProps = CharacterAvatarVisualProps &
|
||||||
|
AvatarInteractionProps;
|
||||||
|
|
||||||
export function CharacterAvatar({
|
export function CharacterAvatar({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
@@ -19,6 +24,9 @@ export function CharacterAvatar({
|
|||||||
size = 43,
|
size = 43,
|
||||||
imageSize,
|
imageSize,
|
||||||
priority = false,
|
priority = false,
|
||||||
|
onClick,
|
||||||
|
actionLabel,
|
||||||
|
analyticsKey,
|
||||||
}: CharacterAvatarProps) {
|
}: CharacterAvatarProps) {
|
||||||
const resolvedImageSize =
|
const resolvedImageSize =
|
||||||
imageSize ?? (typeof size === "number" ? size : 96);
|
imageSize ?? (typeof size === "number" ? size : 96);
|
||||||
@@ -27,16 +35,16 @@ export function CharacterAvatar({
|
|||||||
height: size,
|
height: size,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const avatarClassName = [
|
||||||
<span
|
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border-0 bg-(--color-avatar-border,#fbf3f5)",
|
||||||
className={[
|
onClick
|
||||||
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-avatar-border,#fbf3f5)",
|
? "cursor-pointer p-0 transition-transform duration-150 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-accent active:scale-96"
|
||||||
|
: undefined,
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ");
|
||||||
style={style}
|
const image = (
|
||||||
>
|
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
@@ -45,6 +53,27 @@ export function CharacterAvatar({
|
|||||||
priority={priority}
|
priority={priority}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={avatarClassName}
|
||||||
|
style={style}
|
||||||
|
aria-label={actionLabel}
|
||||||
|
data-analytics-key={analyticsKey}
|
||||||
|
data-analytics-label={actionLabel}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{image}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={avatarClassName} style={style}>
|
||||||
|
{image}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { pwaUtil } from "@/utils/pwa";
|
||||||
|
|
||||||
|
import { FavoriteEntryButton } from "../favorite-entry-button";
|
||||||
|
|
||||||
|
describe("FavoriteEntryButton install flow", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
window.localStorage.setItem("cozsweet:favorite_entry", "1");
|
||||||
|
vi.spyOn(BrowserDetector, "isInAppBrowser").mockReturnValue(false);
|
||||||
|
vi.spyOn(pwaUtil, "isInstalled").mockReturnValue(false);
|
||||||
|
vi.spyOn(pwaUtil, "prepareInstallPrompt").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(pwaUtil, "subscribe").mockReturnValue(() => undefined);
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Download on iOS and opens Safari add-to-home instructions", () => {
|
||||||
|
vi.spyOn(PlatformDetector, "isIOS").mockReturnValue(true);
|
||||||
|
vi.spyOn(PlatformDetector, "isAndroid").mockReturnValue(false);
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
const downloadButton = getButton("Download");
|
||||||
|
act(() => downloadButton.click());
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Add CozSweet to Home Screen");
|
||||||
|
expect(container.textContent).toContain(
|
||||||
|
"In Safari, tap the Share button, then choose “Add to Home Screen”.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the native Android install prompt and marks an accepted install as Saved", async () => {
|
||||||
|
vi.spyOn(PlatformDetector, "isIOS").mockReturnValue(false);
|
||||||
|
vi.spyOn(PlatformDetector, "isAndroid").mockReturnValue(true);
|
||||||
|
const install = vi.spyOn(pwaUtil, "install").mockResolvedValue("accepted");
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
await act(async () => {
|
||||||
|
getButton("Download").click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(install).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getButton("Saved")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderButton(): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(<FavoriteEntryButton characterSlug="elio" />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButton(label: string): HTMLButtonElement {
|
||||||
|
const button = container.querySelector<HTMLButtonElement>(
|
||||||
|
`button[aria-label="${label}"]`,
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Missing button: ${label}`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ModalPortal } from "../modal-portal";
|
||||||
|
|
||||||
|
describe("ModalPortal", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("portals outside transformed ancestors and exposes dialog semantics", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName="fixed inset-0"
|
||||||
|
panelClassName="payment-panel"
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
role="alertdialog"
|
||||||
|
ariaLabelledBy="payment-title"
|
||||||
|
ariaDescribedBy="payment-description"
|
||||||
|
>
|
||||||
|
<h2 id="payment-title">Complete payment</h2>
|
||||||
|
<p id="payment-description">Choose a payment method.</p>
|
||||||
|
</ModalPortal>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="alertdialog"]');
|
||||||
|
expect(dialog).not.toBeNull();
|
||||||
|
expect(container.contains(dialog)).toBe(false);
|
||||||
|
expect(dialog?.parentElement?.parentElement).toBe(document.body);
|
||||||
|
expect(dialog?.getAttribute("aria-labelledby")).toBe("payment-title");
|
||||||
|
expect(dialog?.getAttribute("aria-describedby")).toBe(
|
||||||
|
"payment-description",
|
||||||
|
);
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the previous body overflow after the final modal closes", () => {
|
||||||
|
const renderModals = (showFirst: boolean, showSecond: boolean) => (
|
||||||
|
<>
|
||||||
|
<ModalPortal
|
||||||
|
open={showFirst}
|
||||||
|
onClose={() => undefined}
|
||||||
|
scrimClassName="first-scrim"
|
||||||
|
panelClassName="first-panel"
|
||||||
|
scrimOpacity={0.4}
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</ModalPortal>
|
||||||
|
<ModalPortal
|
||||||
|
open={showSecond}
|
||||||
|
onClose={() => undefined}
|
||||||
|
scrimClassName="second-scrim"
|
||||||
|
panelClassName="second-panel"
|
||||||
|
scrimOpacity={0.4}
|
||||||
|
>
|
||||||
|
Second
|
||||||
|
</ModalPortal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => root.render(renderModals(true, true)));
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
|
||||||
|
act(() => root.render(renderModals(false, true)));
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
|
||||||
|
act(() => root.render(renderModals(false, false)));
|
||||||
|
expect(document.body.style.overflow).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persistent payment modals open on scrim click and Escape", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName="fixed inset-0"
|
||||||
|
panelClassName="payment-panel"
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
>
|
||||||
|
Payment
|
||||||
|
</ModalPortal>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrim = document.body.querySelector(".fixed.inset-0");
|
||||||
|
act(() => scrim?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
act(() =>
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,18 +79,20 @@ describe("core Tailwind components", () => {
|
|||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="chat"
|
activeItem="chat"
|
||||||
variant="dark"
|
variant="dark"
|
||||||
privateRoomLabel="Maya Private room"
|
privateZoneLabel="Maya Private Zone"
|
||||||
onChatClick={() => undefined}
|
onChatClick={() => undefined}
|
||||||
onPrivateRoomClick={() => undefined}
|
onPrivateZoneClick={() => undefined}
|
||||||
|
onMenuClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const privateRoomHtml = renderToStaticMarkup(
|
const privateZoneHtml = renderToStaticMarkup(
|
||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="privateRoom"
|
activeItem="privateZone"
|
||||||
variant="warm"
|
variant="warm"
|
||||||
privateRoomLabel="Maya Private room"
|
privateZoneLabel="Maya Private Zone"
|
||||||
onChatClick={() => undefined}
|
onChatClick={() => undefined}
|
||||||
onPrivateRoomClick={() => undefined}
|
onPrivateZoneClick={() => undefined}
|
||||||
|
onMenuClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,13 +101,15 @@ describe("core Tailwind components", () => {
|
|||||||
expect(chatHtml).toMatch(
|
expect(chatHtml).toMatch(
|
||||||
/<button[^>]*aria-current="page"[^>]*>.*?<span>Chat<\/span>/,
|
/<button[^>]*aria-current="page"[^>]*>.*?<span>Chat<\/span>/,
|
||||||
);
|
);
|
||||||
expect(privateRoomHtml).toMatch(/class="[^"]*warm[^"]*"/);
|
expect(privateZoneHtml).toMatch(/class="[^"]*warm[^"]*"/);
|
||||||
expect(privateRoomHtml).toMatch(
|
expect(privateZoneHtml).toMatch(
|
||||||
/<button[^>]*aria-current="page"[^>]*>.*?<span>Maya Private room<\/span>/,
|
/<button[^>]*aria-current="page"[^>]*>.*?<span>Maya Private Zone<\/span>/,
|
||||||
);
|
);
|
||||||
expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
|
expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
|
||||||
expect(privateRoomHtml).toContain(
|
expect(privateZoneHtml).toContain(
|
||||||
'data-analytics-key="navigation.private_room"',
|
'data-analytics-key="navigation.private_zone"',
|
||||||
);
|
);
|
||||||
|
expect(chatHtml).toContain('data-analytics-key="navigation.menu"');
|
||||||
|
expect(chatHtml).toContain("<span>Menu</span>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: min(100vw, var(--app-max-width, 540px));
|
width: min(100vw, var(--app-max-width, 540px));
|
||||||
min-height: calc(
|
min-height: calc(
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Camera, MessageCircle } from "lucide-react";
|
import { Camera, Menu as MenuIcon, MessageCircle } from "lucide-react";
|
||||||
|
|
||||||
import styles from "./app-bottom-nav.module.css";
|
import styles from "./app-bottom-nav.module.css";
|
||||||
|
|
||||||
export type AppBottomNavItem = "chat" | "privateRoom";
|
export type AppBottomNavItem = "chat" | "privateZone" | "menu";
|
||||||
export type AppBottomNavVariant = "warm" | "dark";
|
export type AppBottomNavVariant = "warm" | "dark";
|
||||||
|
|
||||||
export interface AppBottomNavProps {
|
export interface AppBottomNavProps {
|
||||||
activeItem?: AppBottomNavItem | null;
|
activeItem?: AppBottomNavItem | null;
|
||||||
variant?: AppBottomNavVariant;
|
variant?: AppBottomNavVariant;
|
||||||
privateRoomLabel: string;
|
privateZoneLabel: string;
|
||||||
onChatClick: () => void;
|
onChatClick: () => void;
|
||||||
onPrivateRoomClick: () => void;
|
onPrivateZoneClick: () => void;
|
||||||
|
onMenuClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBottomNav({
|
export function AppBottomNav({
|
||||||
activeItem = null,
|
activeItem = null,
|
||||||
variant = "warm",
|
variant = "warm",
|
||||||
privateRoomLabel,
|
privateZoneLabel,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
onPrivateRoomClick,
|
onPrivateZoneClick,
|
||||||
|
onMenuClick,
|
||||||
}: AppBottomNavProps) {
|
}: AppBottomNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav className={getRootClass(variant)} aria-label="Primary navigation">
|
<nav className={getRootClass(variant)} aria-label="Primary navigation">
|
||||||
@@ -37,14 +39,25 @@ export function AppBottomNav({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="navigation.private_room"
|
data-analytics-key="navigation.private_zone"
|
||||||
data-analytics-label="Private room navigation"
|
data-analytics-label="Private Zone navigation"
|
||||||
className={getButtonClass(activeItem === "privateRoom")}
|
className={getButtonClass(activeItem === "privateZone")}
|
||||||
aria-current={activeItem === "privateRoom" ? "page" : undefined}
|
aria-current={activeItem === "privateZone" ? "page" : undefined}
|
||||||
onClick={onPrivateRoomClick}
|
onClick={onPrivateZoneClick}
|
||||||
>
|
>
|
||||||
<Camera size={20} aria-hidden="true" />
|
<Camera size={20} aria-hidden="true" />
|
||||||
<span>{privateRoomLabel}</span>
|
<span>{privateZoneLabel}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key="navigation.menu"
|
||||||
|
data-analytics-label="Menu navigation"
|
||||||
|
className={getButtonClass(activeItem === "menu")}
|
||||||
|
aria-current={activeItem === "menu" ? "page" : undefined}
|
||||||
|
onClick={onMenuClick}
|
||||||
|
>
|
||||||
|
<MenuIcon size={20} aria-hidden="true" />
|
||||||
|
<span>Menu</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: var(--responsive-icon-button-size, 42px);
|
||||||
|
height: var(--responsive-icon-button-size, 42px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid var(--favorite-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--favorite-background);
|
||||||
|
color: var(--favorite-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: var(--favorite-shadow);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
transition: background 160ms ease, transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--favorite-background: rgba(18, 15, 24, 0.72);
|
||||||
|
--favorite-border: rgba(255, 255, 255, 0.22);
|
||||||
|
--favorite-color: #ffffff;
|
||||||
|
--favorite-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
--favorite-background: rgba(255, 255, 255, 0.88);
|
||||||
|
--favorite-border: rgba(43, 27, 34, 0.1);
|
||||||
|
--favorite-color: #34272c;
|
||||||
|
--favorite-shadow: 0 10px 24px rgba(74, 48, 58, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite {
|
||||||
|
width: var(--responsive-icon-button-size, 42px);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #ff76ab, #f84d96);
|
||||||
|
border-color: rgba(255, 255, 255, 0.42);
|
||||||
|
box-shadow: 0 10px 25px rgba(248, 77, 150, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved {
|
||||||
|
color: #f84d96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 13px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(1px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light:focus-visible {
|
||||||
|
outline-color: #f84d96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 240;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding:
|
||||||
|
calc(18px + var(--app-safe-top, 0px))
|
||||||
|
calc(18px + var(--app-safe-right, 0px))
|
||||||
|
calc(18px + var(--app-safe-bottom, 0px))
|
||||||
|
calc(18px + var(--app-safe-left, 0px));
|
||||||
|
background: rgba(15, 11, 18, 0.58);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideDialog {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: min(100%, 340px);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 28px 22px 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 250, 252, 0.98);
|
||||||
|
box-shadow: 0 24px 70px rgba(33, 17, 26, 0.3);
|
||||||
|
color: #2d2026;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideClose {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(45, 32, 38, 0.07);
|
||||||
|
color: #5b4a52;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: 19px;
|
||||||
|
background: linear-gradient(135deg, #ff82b2, #f84d96);
|
||||||
|
box-shadow: 0 12px 26px rgba(248, 77, 150, 0.26);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideDialog h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #281a21;
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideDialog p {
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
color: #685761;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideConfirm {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #ff76ab, #f84d96);
|
||||||
|
box-shadow: 0 10px 24px rgba(248, 77, 150, 0.24);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideClose:focus-visible,
|
||||||
|
.guideConfirm:focus-visible {
|
||||||
|
outline: 2px solid #f84d96;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { Download, Share2, Star, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
|
||||||
|
import {
|
||||||
|
hasPersistedFavoriteEntryIntent,
|
||||||
|
resolveFavoriteEntryMode,
|
||||||
|
type FavoriteEntryMode,
|
||||||
|
} from "@/lib/navigation/favorite_entry";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { pwaUtil } from "@/utils/pwa";
|
||||||
|
|
||||||
|
import styles from "./favorite-entry-button.module.css";
|
||||||
|
|
||||||
|
export interface FavoriteEntryButtonProps {
|
||||||
|
characterSlug: string;
|
||||||
|
tone?: "dark" | "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoriteEntryButton({
|
||||||
|
characterSlug,
|
||||||
|
tone = "dark",
|
||||||
|
}: FavoriteEntryButtonProps) {
|
||||||
|
const [mode, setMode] = useState<FavoriteEntryMode>("favorite");
|
||||||
|
const [installGuide, setInstallGuide] = useState<
|
||||||
|
"ios" | "browser" | null
|
||||||
|
>(null);
|
||||||
|
const installGuideTitleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateMode = () => {
|
||||||
|
setMode(
|
||||||
|
resolveFavoriteEntryMode({
|
||||||
|
hasFavoriteIntent: hasPersistedFavoriteEntryIntent(),
|
||||||
|
isAndroid: PlatformDetector.isAndroid(),
|
||||||
|
isIOS: PlatformDetector.isIOS(),
|
||||||
|
isInAppBrowser: BrowserDetector.isInAppBrowser(),
|
||||||
|
isInstalled: pwaUtil.isInstalled(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
pwaUtil.prepareInstallPrompt();
|
||||||
|
updateMode();
|
||||||
|
const unsubscribe = pwaUtil.subscribe(updateMode);
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!installGuide) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setInstallGuide(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [installGuide]);
|
||||||
|
|
||||||
|
const handleClick = async (): Promise<void> => {
|
||||||
|
if (mode === "favorite") {
|
||||||
|
await openChatInExternalBrowser({ characterSlug });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "download") {
|
||||||
|
if (PlatformDetector.isIOS()) {
|
||||||
|
setInstallGuide("ios");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pwaUtil.prepareInstallPrompt();
|
||||||
|
const result = await pwaUtil.install();
|
||||||
|
if (result === "accepted" || pwaUtil.isInstalled()) {
|
||||||
|
setMode("saved");
|
||||||
|
setInstallGuide(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === "unavailable") setInstallGuide("browser");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = getFavoriteEntryLabel(mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key={`favorite.${mode}`}
|
||||||
|
data-analytics-label={label}
|
||||||
|
className={[styles.button, styles[tone], styles[mode]]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={mode === "saved"}
|
||||||
|
onClick={() => void handleClick()}
|
||||||
|
>
|
||||||
|
{mode === "download" ? (
|
||||||
|
<Download size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Star
|
||||||
|
size={21}
|
||||||
|
strokeWidth={2.3}
|
||||||
|
fill={mode === "saved" ? "currentColor" : "none"}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode === "favorite" ? null : <span>{label}</span>}
|
||||||
|
</button>
|
||||||
|
{installGuide ? (
|
||||||
|
<div
|
||||||
|
className={styles.guideBackdrop}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
className={styles.guideDialog}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={installGuideTitleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.guideClose}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
aria-label="Close install instructions"
|
||||||
|
>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span className={styles.guideIcon} aria-hidden="true">
|
||||||
|
{installGuide === "ios" ? (
|
||||||
|
<Share2 size={28} strokeWidth={2.2} />
|
||||||
|
) : (
|
||||||
|
<Download size={28} strokeWidth={2.2} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<h2 id={installGuideTitleId}>Add CozSweet to Home Screen</h2>
|
||||||
|
<p>
|
||||||
|
{installGuide === "ios"
|
||||||
|
? "In Safari, tap the Share button, then choose “Add to Home Screen”."
|
||||||
|
: "Open your browser menu, then choose “Add to Home screen”."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.guideConfirm}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFavoriteEntryLabel(mode: FavoriteEntryMode): string {
|
||||||
|
if (mode === "download") return "Download";
|
||||||
|
if (mode === "saved") return "Saved";
|
||||||
|
return "Save CozSweet";
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
export * from "./app-bottom-nav";
|
export * from "./app-bottom-nav";
|
||||||
export * from "./bottom-sheet";
|
export * from "./bottom-sheet";
|
||||||
export * from "./checkbox";
|
export * from "./checkbox";
|
||||||
|
export * from "./favorite-entry-button";
|
||||||
export * from "./loading-indicator";
|
export * from "./loading-indicator";
|
||||||
export * from "./mobile-shell";
|
export * from "./mobile-shell";
|
||||||
export * from "./page-loading-fallback";
|
export * from "./page-loading-fallback";
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export interface ModalPortalProps {
|
|||||||
scrimOpacity: number;
|
scrimOpacity: number;
|
||||||
panelStyle?: CSSProperties;
|
panelStyle?: CSSProperties;
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
|
role?: "dialog" | "alertdialog";
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
ariaLabelledBy?: string;
|
||||||
|
ariaDescribedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalPortal({
|
export function ModalPortal({
|
||||||
@@ -32,7 +35,10 @@ export function ModalPortal({
|
|||||||
scrimOpacity,
|
scrimOpacity,
|
||||||
panelStyle,
|
panelStyle,
|
||||||
persistent = false,
|
persistent = false,
|
||||||
|
role = "dialog",
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
ariaLabelledBy,
|
||||||
|
ariaDescribedBy,
|
||||||
}: ModalPortalProps) {
|
}: ModalPortalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof document === "undefined") return;
|
if (!open || typeof document === "undefined") return;
|
||||||
@@ -63,9 +69,11 @@ export function ModalPortal({
|
|||||||
onClick={handleScrimClick}
|
onClick={handleScrimClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role={role}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
className={panelClassName}
|
className={panelClassName}
|
||||||
style={panelStyle}
|
style={panelStyle}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./back-button";
|
export * from "./back-button";
|
||||||
|
export * from "./avatar-interaction";
|
||||||
export * from "./character-avatar";
|
export * from "./character-avatar";
|
||||||
export * from "./user-message-avatar";
|
export * from "./user-message-avatar";
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
createCheckoutHandoff: vi.fn(),
|
||||||
|
openUrlWithExternalBrowser: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
|
||||||
|
vi.mock("@/utils/url-launcher-util", () => ({
|
||||||
|
UrlLauncherUtil: { openUrlWithExternalBrowser },
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/browser-detect", () => ({
|
||||||
|
BrowserDetector: { isFacebookInAppBrowser: () => true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
|
||||||
|
|
||||||
|
describe("ExternalBrowserCheckoutButton", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
createCheckoutHandoff.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only a handoff before opening the external browser", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
characterSlug="maya"
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector("button");
|
||||||
|
expect(button?.textContent).toContain(
|
||||||
|
"Open in browser for more payment methods",
|
||||||
|
);
|
||||||
|
await act(async () => button?.click());
|
||||||
|
|
||||||
|
expect(createCheckoutHandoff).toHaveBeenCalledWith({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
});
|
||||||
|
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque&character=maya",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,49 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { act } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||||
|
|
||||||
const hiddenLaunch = {
|
const hiddenLaunch = {
|
||||||
handleEzpayCancel: vi.fn(),
|
handleEzpayCancel: vi.fn(),
|
||||||
handleEzpayConfirm: vi.fn(),
|
handleEzpayConfirm: vi.fn(),
|
||||||
|
handleRegionalQrClose: vi.fn(),
|
||||||
handleStripeClose: vi.fn(),
|
handleStripeClose: vi.fn(),
|
||||||
handleStripeConfirmed: vi.fn(),
|
handleStripeConfirmed: vi.fn(),
|
||||||
isConfirmingEzpay: false,
|
isConfirmingEzpay: false,
|
||||||
|
regionalQrErrorMessage: null,
|
||||||
|
regionalQrPayment: null,
|
||||||
|
regionalQrStatus: null,
|
||||||
shouldShowEzpayConfirmDialog: false,
|
shouldShowEzpayConfirmDialog: false,
|
||||||
|
shouldShowRegionalQrDialog: false,
|
||||||
shouldShowStripeDialog: false,
|
shouldShowStripeDialog: false,
|
||||||
stripeClientSecret: null,
|
stripeClientSecret: null,
|
||||||
|
stripeCustomerSessionClientSecret: null,
|
||||||
|
savedPaymentMethodsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("PaymentLaunchDialogs", () => {
|
describe("PaymentLaunchDialogs", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
|
||||||
it("renders nothing when neither payment dialog is active", () => {
|
it("renders nothing when neither payment dialog is active", () => {
|
||||||
expect(
|
act(() =>
|
||||||
renderToStaticMarkup(
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId={null}
|
currentOrderId={null}
|
||||||
externalCheckoutAnalyticsKey="payment.external_checkout"
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
@@ -25,11 +51,15 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
launch={hiddenLaunch}
|
launch={hiddenLaunch}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
).toBe("");
|
);
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the shared Ezpay confirmation content", () => {
|
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
|
||||||
const html = renderToStaticMarkup(
|
act(() =>
|
||||||
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId="order-123"
|
currentOrderId="order-123"
|
||||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||||
@@ -40,13 +70,126 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
shouldShowEzpayConfirmDialog: true,
|
shouldShowEzpayConfirmDialog: true,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('role="alertdialog"');
|
const dialog = document.body.querySelector('[role="alertdialog"]');
|
||||||
expect(html).toContain("Continue to GCash?");
|
expect(dialog).not.toBeNull();
|
||||||
expect(html).toContain("Your coffee order is ready.");
|
expect(container.contains(dialog)).toBe(false);
|
||||||
expect(html).toContain("Order No. order-123");
|
expect(dialog?.parentElement?.parentElement).toBe(document.body);
|
||||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
expect(dialog?.textContent).toContain("Continue to payment?");
|
||||||
expect(html).toContain("Opening...");
|
expect(dialog?.textContent).toContain("Your coffee order is ready.");
|
||||||
|
expect(dialog?.textContent).toContain("Order No. order-123");
|
||||||
|
expect(
|
||||||
|
dialog?.querySelector("button[data-analytics-key]")?.getAttribute(
|
||||||
|
"data-analytics-key",
|
||||||
|
),
|
||||||
|
).toBe("tip.external_checkout");
|
||||||
|
expect(dialog?.textContent).toContain("Opening...");
|
||||||
|
expect(document.body.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("portals an accessible QRIS dialog with display cents and order status", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<PaymentLaunchDialogs
|
||||||
|
currentOrderId="order-id-qris"
|
||||||
|
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||||
|
ezpayDescription="Scan QRIS to finish the payment."
|
||||||
|
launch={{
|
||||||
|
...hiddenLaunch,
|
||||||
|
regionalQrPayment: {
|
||||||
|
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||||
|
orderId: "order-id-qris",
|
||||||
|
amountCents: 5_000_000,
|
||||||
|
currency: "IDR",
|
||||||
|
experience: "qris",
|
||||||
|
},
|
||||||
|
regionalQrStatus: "pending",
|
||||||
|
shouldShowRegionalQrDialog: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
|
expect(dialog).not.toBeNull();
|
||||||
|
expect(container.contains(dialog)).toBe(false);
|
||||||
|
expect(dialog?.textContent).toContain("Scan to pay with QRIS");
|
||||||
|
expect(dialog?.querySelector("svg title")?.textContent).toBe(
|
||||||
|
"QRIS payment QR code",
|
||||||
|
);
|
||||||
|
expect(dialog?.textContent).toContain("Order No. order-id-qris");
|
||||||
|
expect(dialog?.textContent).toContain("Rp");
|
||||||
|
expect(dialog?.textContent).toContain("50.000");
|
||||||
|
expect(dialog?.textContent).toContain("Waiting for payment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a Philippine QR fallback as GCash / QR Ph", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<PaymentLaunchDialogs
|
||||||
|
currentOrderId="order-ph-qr"
|
||||||
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
|
ezpayDescription="Pay with GCash."
|
||||||
|
launch={{
|
||||||
|
...hiddenLaunch,
|
||||||
|
regionalQrPayment: {
|
||||||
|
qrData: "000201010212ph-qr-payload",
|
||||||
|
orderId: "order-ph-qr",
|
||||||
|
amountCents: 49_990,
|
||||||
|
currency: "PHP",
|
||||||
|
experience: "gcashQrPh",
|
||||||
|
},
|
||||||
|
regionalQrStatus: "pending",
|
||||||
|
shouldShowRegionalQrDialog: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
|
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
|
||||||
|
expect(dialog?.textContent).not.toContain("QRIS");
|
||||||
|
expect(dialog?.querySelector("svg title")?.textContent).toBe(
|
||||||
|
"GCash / QR Ph payment QR code",
|
||||||
|
);
|
||||||
|
expect(dialog?.textContent).toContain("₱");
|
||||||
|
|
||||||
|
const closeButton = Array.from(
|
||||||
|
dialog?.querySelectorAll("button") ?? [],
|
||||||
|
).find((button) => button.textContent?.trim() === "Close");
|
||||||
|
act(() => closeButton?.click());
|
||||||
|
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows QRIS failure state and handles empty QR data safely", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<PaymentLaunchDialogs
|
||||||
|
currentOrderId="order-id-qris"
|
||||||
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
|
ezpayDescription="Scan QRIS to finish the payment."
|
||||||
|
launch={{
|
||||||
|
...hiddenLaunch,
|
||||||
|
regionalQrErrorMessage: "Payment failed or was cancelled.",
|
||||||
|
regionalQrPayment: {
|
||||||
|
qrData: "",
|
||||||
|
orderId: "order-id-qris",
|
||||||
|
amountCents: 5_000_000,
|
||||||
|
currency: "IDR",
|
||||||
|
experience: "qris",
|
||||||
|
},
|
||||||
|
regionalQrStatus: "failed",
|
||||||
|
shouldShowRegionalQrDialog: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
|
expect(dialog?.textContent).toContain("QRIS data is unavailable");
|
||||||
|
expect(dialog?.textContent).toContain("Payment failed or was cancelled.");
|
||||||
|
expect(dialog?.textContent).toContain("Close");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||