Compare commits
67 Commits
d6f104ee3f
...
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 | |||
| 770ce6b8fd | |||
| 0357fbcaff | |||
| 30122a44db | |||
| 2bb3da829e | |||
| 163ba78f06 | |||
| fd2291fa62 | |||
| 4e71dbd5f8 | |||
| 36e5dd3c96 | |||
| 40184c7631 | |||
| 7103652829 | |||
| 4fceab537d | |||
| dff112eadc | |||
| e1454cd002 | |||
| 437ac3460b | |||
| 1e25279e8f |
@@ -152,12 +152,9 @@ jobs:
|
|||||||
- name: Deploy image over SSH
|
- name: Deploy image over SSH
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
TEST_DEPLOY_DIR: ${{ secrets.TEST_DEPLOY_DIR }}
|
|
||||||
PROD_DEPLOY_DIR: ${{ secrets.PROD_DEPLOY_DIR }}
|
|
||||||
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
|
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
@@ -166,16 +163,18 @@ jobs:
|
|||||||
|
|
||||||
case "$DEPLOY_ENV" in
|
case "$DEPLOY_ENV" in
|
||||||
test)
|
test)
|
||||||
DEPLOY_DIR="${TEST_DEPLOY_DIR:-/opt/cozsweet-web-test}"
|
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-test"
|
||||||
HOST_PORT="9135"
|
HOST_PORT="9135"
|
||||||
CONTAINER_NAME="cozsweet-web-test"
|
CONTAINER_NAME="cozsweet-web-test"
|
||||||
PROJECT_NAME="cozsweet-web-test"
|
PROJECT_NAME="cozsweet-web-test"
|
||||||
|
SERVER_RETAIN_COUNT="1"
|
||||||
;;
|
;;
|
||||||
prod)
|
prod)
|
||||||
DEPLOY_DIR="${PROD_DEPLOY_DIR:-/opt/cozsweet-web-prod}"
|
DEPLOY_ROOT="/home/lzf/apps/cozsweet-web-prod"
|
||||||
HOST_PORT="9185"
|
HOST_PORT="9185"
|
||||||
CONTAINER_NAME="cozsweet-web-prod"
|
CONTAINER_NAME="cozsweet-web-prod"
|
||||||
PROJECT_NAME="cozsweet-web-prod"
|
PROJECT_NAME="cozsweet-web-prod"
|
||||||
|
SERVER_RETAIN_COUNT="2"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unsupported deploy env: $DEPLOY_ENV" >&2
|
echo "Unsupported deploy env: $DEPLOY_ENV" >&2
|
||||||
@@ -183,8 +182,11 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
DEPLOY_DIR="$DEPLOY_ROOT/current"
|
||||||
|
DEPLOY_SHARED_DIR="$DEPLOY_ROOT/shared"
|
||||||
|
REMOTE_ENV_FILE="$DEPLOY_SHARED_DIR/$NEXT_ENV_FILE"
|
||||||
DEPLOY_HOST="$SSH_HOST"
|
DEPLOY_HOST="$SSH_HOST"
|
||||||
DEPLOY_USER="$SSH_USER"
|
DEPLOY_USER="lzf"
|
||||||
DEPLOY_PORT="${SSH_PORT:-22}"
|
DEPLOY_PORT="${SSH_PORT:-22}"
|
||||||
DEPLOY_KEY="$SSH_PRIVATE_KEY"
|
DEPLOY_KEY="$SSH_PRIVATE_KEY"
|
||||||
DEPLOY_KNOWN_HOSTS="$SSH_KNOWN_HOSTS"
|
DEPLOY_KNOWN_HOSTS="$SSH_KNOWN_HOSTS"
|
||||||
@@ -210,19 +212,19 @@ jobs:
|
|||||||
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes"
|
SSH_OPTS="-i $KEY_FILE -p $DEPLOY_PORT -o IdentitiesOnly=yes"
|
||||||
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes"
|
SCP_OPTS="-i $KEY_FILE -P $DEPLOY_PORT -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
ssh $SSH_OPTS "$SSH_TARGET" "mkdir -p '$DEPLOY_DIR'"
|
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
|
"mkdir -p '$DEPLOY_DIR' '$DEPLOY_SHARED_DIR' '$DEPLOY_ROOT/releases' '$DEPLOY_ROOT/backups' '$DEPLOY_ROOT/tmp' && chmod 700 '$DEPLOY_SHARED_DIR'"
|
||||||
scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml"
|
scp $SCP_OPTS docker-compose.yml "$SSH_TARGET:$DEPLOY_DIR/docker-compose.yml"
|
||||||
scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh"
|
scp $SCP_OPTS scripts/server/deploy-docker-image.sh "$SSH_TARGET:$DEPLOY_DIR/deploy-docker-image.sh"
|
||||||
scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$DEPLOY_DIR/$NEXT_ENV_FILE"
|
scp $SCP_OPTS "$NEXT_ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_FILE"
|
||||||
|
ssh $SSH_OPTS "$SSH_TARGET" "chmod 600 '$REMOTE_ENV_FILE'"
|
||||||
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" \
|
printf '%s' "$REGISTRY_PASSWORD" \
|
||||||
| ssh $SSH_OPTS "$SSH_TARGET" \
|
| ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
"docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin"
|
"docker login '$REGISTRY_HOST' -u '$REGISTRY_USERNAME' --password-stdin"
|
||||||
|
|
||||||
SERVER_RETAIN_COUNT="1"
|
|
||||||
|
|
||||||
ssh $SSH_OPTS "$SSH_TARGET" \
|
ssh $SSH_OPTS "$SSH_TARGET" \
|
||||||
"cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$NEXT_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
|
"cd '$DEPLOY_DIR' && chmod +x ./deploy-docker-image.sh && ./deploy-docker-image.sh '$IMAGE_VERSION_TAG' '$REMOTE_ENV_FILE' '$HOST_PORT' '$CONTAINER_NAME' '$PROJECT_NAME' '$SERVER_RETAIN_COUNT'"
|
||||||
|
|
||||||
- name: Purge Cloudflare cache
|
- name: Purge Cloudflare cache
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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 ./
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ https://<APP_HOST>/external-entry?<参数>
|
|||||||
|
|
||||||
| 参数 | 可选值或格式 | 用途 |
|
| 参数 | 可选值或格式 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `target` | `chat`、`tip`、`private-zone` | 指定进入的页面;不传或值无效时进入聊天页。 |
|
| `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)。
|
||||||
|
|||||||
@@ -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-zone) |
|
| Elio 私密空间 | [打开 Elio 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=elio) | `/characters/elio/private-zone` |
|
||||||
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=maya) |
|
| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=maya) | `/characters/maya/private-zone` |
|
||||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zone&character=nayeli) |
|
| 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-zone) |
|
|
||||||
| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=maya) |
|
|
||||||
| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zone&character=nayeli) |
|
|
||||||
|
|
||||||
如需在其他入口携带 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`。
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export interface MockCoreApisOptions {
|
|||||||
paidImageFlow?: boolean;
|
paidImageFlow?: boolean;
|
||||||
paidImageInsufficientCreditsFlow?: boolean;
|
paidImageInsufficientCreditsFlow?: boolean;
|
||||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||||
psidLoginFlow?: boolean;
|
topUpHandoffFlow?: boolean;
|
||||||
|
checkoutHandoffFlow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||||
@@ -22,14 +23,16 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
paidImageFlow: options.paidImageFlow ?? false,
|
paidImageFlow: options.paidImageFlow ?? false,
|
||||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||||
psidLoginFlow: options.psidLoginFlow ?? false,
|
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||||
|
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||||
};
|
};
|
||||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||||
|
|
||||||
await registerAuthMocks(page, {
|
await registerAuthMocks(page, {
|
||||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||||
psidLoginFlow: chatOptions.psidLoginFlow,
|
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||||
|
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||||
});
|
});
|
||||||
await registerCharacterMocks(page);
|
await registerCharacterMocks(page);
|
||||||
await registerUserMocks(page);
|
await registerUserMocks(page);
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { apiEnvelope } from "../data/common";
|
import { apiEnvelope } from "../data/common";
|
||||||
import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
|
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||||
|
|
||||||
export interface AuthMockState {
|
export interface AuthMockState {
|
||||||
chatSendTokenRefreshFlow: boolean;
|
chatSendTokenRefreshFlow: boolean;
|
||||||
isChatSendTokenExpired: () => boolean;
|
isChatSendTokenExpired: () => boolean;
|
||||||
psidLoginFlow: boolean;
|
topUpHandoffFlow: boolean;
|
||||||
|
checkoutHandoffFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
||||||
await page.route("**/api/auth/login/facebook/psid", async (route) => {
|
await page.route("**/api/auth/handoff/checkout/consume", async (route) => {
|
||||||
if (!state.psidLoginFlow) {
|
if (!state.checkoutHandoffFlow) {
|
||||||
await route.continue();
|
await route.continue();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) });
|
||||||
|
});
|
||||||
|
|
||||||
await route.fulfill({ json: apiEnvelope(psidLoginResponse) });
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
|
if (!state.topUpHandoffFlow) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/auth/guest", async (route) => {
|
await page.route("**/api/auth/guest", async (route) => {
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export interface ChatMockState {
|
|||||||
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
|
||||||
let sendCount = 0;
|
let sendCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/chat/opening-message", async (route) => {
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
openingMessage?: unknown;
|
||||||
|
} | undefined;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
messageId: "mock-opening-message",
|
||||||
|
created: true,
|
||||||
|
openingMessage:
|
||||||
|
typeof body?.openingMessage === "string"
|
||||||
|
? body.openingMessage
|
||||||
|
: "Hello",
|
||||||
|
createdAt: "2026-07-23T00:00:00Z",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/chat/history**", async (route) => {
|
await page.route("**/api/chat/history**", async (route) => {
|
||||||
const response = options.paidVoiceInsufficientCreditsFlow
|
const response = options.paidVoiceInsufficientCreditsFlow
|
||||||
? paidVoiceHistoryResponse
|
? paidVoiceHistoryResponse
|
||||||
@@ -44,7 +61,35 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st
|
|||||||
await route.fulfill({ status: 401, json: { message: "expired" } });
|
await route.fulfill({ status: 401, json: { message: "expired" } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = options.paidImageFlow && message.includes("给我发图片")
|
const response = message.includes("discount offer test")
|
||||||
|
? {
|
||||||
|
...chatSendResponse,
|
||||||
|
reply: "I know you're thinking carefully about the price.",
|
||||||
|
messageId: "discount-action-message",
|
||||||
|
commercialAction: {
|
||||||
|
actionId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
type: "discountOffer",
|
||||||
|
copy: "Want me to ask for my best private offer?",
|
||||||
|
ctaLabel: "Yes, ask for me",
|
||||||
|
target: "discountConsent",
|
||||||
|
ruleId: "discount_after_price_objection",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: message.includes("private album offer test")
|
||||||
|
? {
|
||||||
|
...chatSendResponse,
|
||||||
|
reply: "You always know how to make me smile.",
|
||||||
|
messageId: "commercial-action-message",
|
||||||
|
commercialAction: {
|
||||||
|
actionId: "commercial-action-1",
|
||||||
|
type: "privateAlbumOffer",
|
||||||
|
copy: "There are more private photos waiting for you.",
|
||||||
|
ctaLabel: "Open private zone",
|
||||||
|
target: "privateZone",
|
||||||
|
ruleId: "private_album_after_appearance_praise",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: options.paidImageFlow && message.includes("给我发图片")
|
||||||
? paidImageChatSendResponse
|
? paidImageChatSendResponse
|
||||||
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
|
||||||
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { apiEnvelope } from "../data/common";
|
|||||||
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
|
||||||
|
|
||||||
export async function registerPaymentMocks(page: Page) {
|
export async function registerPaymentMocks(page: Page) {
|
||||||
|
await page.route("**/api/payment/commercial-offers/*/accept", async (route) => route.fulfill({ json: apiEnvelope({
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
planId: "vip_annual",
|
||||||
|
subscriptionType: "vip",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-24T08:00:00+00:00",
|
||||||
|
message: "I got it for you.",
|
||||||
|
promotionType: "commercial_seven_discount",
|
||||||
|
}) }));
|
||||||
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
|
||||||
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
|
||||||
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
|
||||||
|
|||||||
@@ -50,14 +50,28 @@ export const refreshedEmailLoginResponse = {
|
|||||||
refreshToken: "e2e-refreshed-email-refresh-token",
|
refreshToken: "e2e-refreshed-email-refresh-token",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const psidLoginResponse = {
|
export const topUpHandoffResponse = {
|
||||||
token: "e2e-psid-guest-token",
|
token: "e2e-messenger-token",
|
||||||
refreshToken: "",
|
refreshToken: "e2e-messenger-refresh-token",
|
||||||
matchedBy: "psid",
|
loginStatus: "facebookMessenger",
|
||||||
fbAsid: "",
|
merged: false,
|
||||||
fbPsid: "e2e-facebook-psid",
|
user: {
|
||||||
hasCompleteFacebookIdentity: false,
|
...e2eEmailUser,
|
||||||
isGuest: true,
|
id: "user_e2e_messenger",
|
||||||
user: e2eUser,
|
username: "E2E Messenger User",
|
||||||
userId: e2eUser.id,
|
email: "messenger_hash@messenger.cozsweet.invalid",
|
||||||
|
loginProvider: "facebook_messenger",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkoutHandoffResponse = {
|
||||||
|
token: "e2e-checkout-token",
|
||||||
|
refreshToken: "e2e-checkout-refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
user: e2eEmailUser,
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ export const paidImageChatSendResponse = {
|
|||||||
messageId: paidImageMessageId,
|
messageId: paidImageMessageId,
|
||||||
isGuest: true,
|
isGuest: true,
|
||||||
timestamp: 1_782_180_725_000,
|
timestamp: 1_782_180_725_000,
|
||||||
image: { type: "elio_schedule", url: paidImageUrl },
|
image: { type: "elio_schedule", url: null },
|
||||||
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
export function createPaidImageHistoryResponse(unlocked: boolean) {
|
||||||
return {
|
return {
|
||||||
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: paidImageUrl }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: unlocked ? paidImageUrl : null }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
|
||||||
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
export async function completeVipPayment(page: Page) {
|
export async function completeVipPayment(page: Page) {
|
||||||
|
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(checkoutButton).toBeEnabled();
|
||||||
|
|
||||||
|
await checkoutButton.click();
|
||||||
|
const renewalDialog = page.getByRole("dialog", {
|
||||||
|
name: "Automatic Renewal Confirmation",
|
||||||
|
});
|
||||||
|
await expect(renewalDialog).toBeVisible();
|
||||||
|
|
||||||
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||||
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
||||||
await page.getByRole("button", { name: /Pay and Activ/i }).click();
|
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
|
||||||
const createOrderRequest = await createOrderRequestPromise;
|
const createOrderRequest = await createOrderRequestPromise;
|
||||||
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
||||||
await orderStatusRequestPromise;
|
await orderStatusRequestPromise;
|
||||||
|
|||||||
@@ -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,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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -26,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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const characters = [
|
|||||||
displayName: "Elio Silvestri",
|
displayName: "Elio Silvestri",
|
||||||
shortName: "Elio",
|
shortName: "Elio",
|
||||||
cover: "elio.png",
|
cover: "elio.png",
|
||||||
|
splashCover: "elio.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "maya-tan",
|
id: "maya-tan",
|
||||||
@@ -20,6 +21,7 @@ const characters = [
|
|||||||
displayName: "Maya Tan",
|
displayName: "Maya Tan",
|
||||||
shortName: "Maya",
|
shortName: "Maya",
|
||||||
cover: "maya.webp",
|
cover: "maya.webp",
|
||||||
|
splashCover: "maya-home.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "nayeli-cervantes",
|
id: "nayeli-cervantes",
|
||||||
@@ -27,6 +29,7 @@ const characters = [
|
|||||||
displayName: "Nayeli Cervantes",
|
displayName: "Nayeli Cervantes",
|
||||||
shortName: "Nayeli",
|
shortName: "Nayeli",
|
||||||
cover: "nayeli.webp",
|
cover: "nayeli.webp",
|
||||||
|
splashCover: "nayeli.webp",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -56,9 +59,11 @@ for (const character of characters) {
|
|||||||
|
|
||||||
await page.goto(`/characters/${character.slug}/splash`);
|
await page.goto(`/characters/${character.slug}/splash`);
|
||||||
|
|
||||||
const cover = page.locator(`img[src*="${character.cover}"]`);
|
const cover = page.locator(`img[src*="${character.splashCover}"]`);
|
||||||
await expect(cover).toBeVisible();
|
await expect(cover).toBeVisible();
|
||||||
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("navigation", { name: "Primary navigation" }),
|
||||||
|
).toContainText("Private Zone");
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
cover.evaluate((image: HTMLImageElement) =>
|
cover.evaluate((image: HTMLImageElement) =>
|
||||||
@@ -84,6 +89,13 @@ for (const character of characters) {
|
|||||||
test(`${character.displayName} can open a character-scoped Private Zone`, async ({
|
test(`${character.displayName} can open a character-scoped Private Zone`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
const momentsRequest = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
return (
|
||||||
|
url.pathname === "/api/private-zone/posts" &&
|
||||||
|
url.searchParams.get("characterId") === character.id
|
||||||
|
);
|
||||||
|
});
|
||||||
const albumRequest = page.waitForRequest((request) => {
|
const albumRequest = page.waitForRequest((request) => {
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
return (
|
return (
|
||||||
@@ -94,10 +106,15 @@ for (const character of characters) {
|
|||||||
|
|
||||||
await page.goto(`/characters/${character.slug}/private-zone`);
|
await page.goto(`/characters/${character.slug}/private-zone`);
|
||||||
|
|
||||||
await albumRequest;
|
await Promise.all([momentsRequest, albumRequest]);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Private albums" }),
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab")).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
||||||
const albumButton = page.getByRole("button", {
|
const albumButton = page.getByRole("button", {
|
||||||
name: `View locked collection with 8 images from ${character.displayName}`,
|
name: `View locked collection with 8 images from ${character.displayName}`,
|
||||||
@@ -152,6 +169,21 @@ test("a real Private Zone request failure still offers Retry", async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> {
|
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> {
|
||||||
|
await page.route("**/api/private-zone/posts**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
characterId,
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
creditBalance: 1000,
|
||||||
|
currency: "credits",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/private-zone/albums**", async (route) => {
|
await page.route("**/api/private-zone/albums**", async (route) => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
const characterId = url.searchParams.get("characterId") ?? "elio";
|
const characterId = url.searchParams.get("characterId") ?? "elio";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { createPaidImageHistoryResponse } from "@e2e/fixtures/data/chat";
|
||||||
import {
|
import {
|
||||||
paidImageDisplayMessageId,
|
paidImageDisplayMessageId,
|
||||||
paidImageMessageId,
|
paidImageMessageId,
|
||||||
@@ -17,10 +19,6 @@ import {
|
|||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
|
||||||
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, {
|
await mockCoreApis(page, {
|
||||||
@@ -46,26 +44,18 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
message: "给我发图片",
|
message: "给我发图片",
|
||||||
});
|
});
|
||||||
|
|
||||||
const paidImageButton = page.getByRole("button", {
|
const lockedImageCard = page.getByRole("group", {
|
||||||
name: "Open image in fullscreen",
|
name: "Locked private image",
|
||||||
}).last();
|
}).last();
|
||||||
await expect(paidImageButton).toBeVisible();
|
await expect(lockedImageCard).toBeVisible();
|
||||||
|
await expect(lockedImageCard.locator("img")).toHaveCount(0);
|
||||||
await paidImageButton.click();
|
await lockedImageCard
|
||||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
.getByRole("button", { name: "Unlock private image" })
|
||||||
|
|
||||||
const lockedImageDialog = page.getByRole("dialog", {
|
|
||||||
name: "Locked fullscreen image",
|
|
||||||
});
|
|
||||||
await expect(lockedImageDialog).toBeVisible();
|
|
||||||
|
|
||||||
await lockedImageDialog
|
|
||||||
.getByRole("button", { name: "Unlock high-definition large image" })
|
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||||
`${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
|
defaultCharacterChatPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
@@ -73,7 +63,7 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await submitEmailLogin(page, {
|
await submitEmailLogin(page, {
|
||||||
expectedUrl: paidImageOverlayUrl,
|
expectedUrl: new RegExp(`${defaultCharacterChatPath}$`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
@@ -97,3 +87,38 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fullscreen image closes from its back button, the image, and browser history Back", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/chat/history**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope(createPaidImageHistoryResponse(true)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await enterChatFromSplash(page);
|
||||||
|
await dismissChatInterruptions(page);
|
||||||
|
|
||||||
|
const imageButton = page.getByRole("button", {
|
||||||
|
name: "Open image in fullscreen",
|
||||||
|
});
|
||||||
|
const imageDialog = page.getByRole("dialog", { name: "Fullscreen image" });
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await imageDialog.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await imageDialog.locator("img").click();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
|
||||||
|
await imageButton.click();
|
||||||
|
await expect(imageDialog).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(imageDialog).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(defaultCharacterChatPath);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles";
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 390, height: 844 },
|
||||||
|
{ width: 405, height: 797 },
|
||||||
|
{ width: 540, height: 900 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
test(`payment portal stays viewport-bound at ${viewport.width}x${viewport.height}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/subscription?type=topup", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
|
||||||
|
await mountPaymentPanel(page, 180);
|
||||||
|
await expectViewportScrim(page, viewport);
|
||||||
|
await expectCenteredShortPanel(page, viewport);
|
||||||
|
|
||||||
|
await mountPaymentPanel(page, 1_200);
|
||||||
|
await expectViewportScrim(page, viewport);
|
||||||
|
await expectScrollableTallPanel(page, viewport);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPaymentPanel(page: Page, contentHeight: number) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ dialogClassName, overlayClassName, requestedContentHeight }) => {
|
||||||
|
document.querySelector('[data-testid="payment-layout-overlay"]')?.remove();
|
||||||
|
document.querySelector('[data-testid="transformed-checkout-host"]')?.remove();
|
||||||
|
|
||||||
|
const transformedCheckoutHost = document.createElement("div");
|
||||||
|
transformedCheckoutHost.dataset.testid = "transformed-checkout-host";
|
||||||
|
transformedCheckoutHost.style.transform = "translateX(50%)";
|
||||||
|
document.body.appendChild(transformedCheckoutHost);
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.dataset.testid = "payment-layout-overlay";
|
||||||
|
overlay.className = overlayClassName;
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.dataset.testid = "payment-layout-panel";
|
||||||
|
panel.className = dialogClassName;
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.style.height = `${requestedContentHeight}px`;
|
||||||
|
content.textContent = "Complete payment";
|
||||||
|
panel.appendChild(content);
|
||||||
|
overlay.appendChild(panel);
|
||||||
|
|
||||||
|
// Mirrors ModalPortal: the overlay must be a body child, never a child of
|
||||||
|
// the transformed checkout host that triggered it.
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dialogClassName: stripePaymentDialogStyles.dialog,
|
||||||
|
overlayClassName: stripePaymentDialogStyles.overlay,
|
||||||
|
requestedContentHeight: contentHeight,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectViewportScrim(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const overlay = page.getByTestId("payment-layout-overlay");
|
||||||
|
await expect(overlay).toBeVisible();
|
||||||
|
const box = await overlay.boundingBox();
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.x).toBeCloseTo(0, 0);
|
||||||
|
expect(box?.y).toBeCloseTo(0, 0);
|
||||||
|
expect(box?.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(box?.height).toBeCloseTo(viewport.height, 0);
|
||||||
|
await expect(overlay.evaluate((node) => node.parentElement === document.body))
|
||||||
|
.resolves.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCenteredShortPanel(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const panel = page.getByTestId("payment-layout-panel");
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.y ?? 0).toBeGreaterThan(100);
|
||||||
|
expect(Math.abs((box?.y ?? 0) * 2 + (box?.height ?? 0) - viewport.height))
|
||||||
|
.toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectScrollableTallPanel(
|
||||||
|
page: Page,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const panel = page.getByTestId("payment-layout-panel");
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
const scrollMetrics = await panel.evaluate((node) => ({
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.y ?? 0).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(box?.y ?? 100).toBeLessThanOrEqual(25);
|
||||||
|
expect((box?.y ?? 0) + (box?.height ?? 0))
|
||||||
|
.toBeLessThanOrEqual(viewport.height - 15);
|
||||||
|
expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const facebookAndroidUserAgent =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
|
||||||
|
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||||
|
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
userAgent: facebookAndroidUserAgent,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumes the handoff, removes its token, and does not create an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page, { checkoutHandoffFlow: true });
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const consumeRequest = page.waitForRequest(
|
||||||
|
"**/api/auth/handoff/checkout/consume",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/,
|
||||||
|
);
|
||||||
|
expect(page.url()).not.toContain("handoffToken");
|
||||||
|
await expect.poll(() => createOrderRequests).toBe(0);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers the Facebook external-browser path before creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/handoff/checkout", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
|
||||||
|
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||||
|
const externalButton = page.getByRole("button", {
|
||||||
|
name: "Open in browser for more payment methods",
|
||||||
|
});
|
||||||
|
const paymentButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(externalButton).toBeVisible();
|
||||||
|
await expect(externalButton).toBeEnabled();
|
||||||
|
await expect(paymentButton).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
const externalBox = await externalButton.boundingBox();
|
||||||
|
const paymentBox = await paymentButton.boundingBox();
|
||||||
|
expect(externalBox).not.toBeNull();
|
||||||
|
expect(paymentBox).not.toBeNull();
|
||||||
|
expect(externalBox!.y).toBeLessThan(paymentBox!.y);
|
||||||
|
expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844);
|
||||||
|
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
|
||||||
|
await externalButton.click();
|
||||||
|
expect((await handoffRequest).postDataJSON()).toMatchObject({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
});
|
||||||
|
expect(createOrderRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,22 +2,16 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import {
|
import {
|
||||||
paidImageDisplayMessageId,
|
|
||||||
paidImageMessageId,
|
paidImageMessageId,
|
||||||
} from "@e2e/fixtures/test-data";
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
defaultCharacterChatPath,
|
|
||||||
dismissChatInterruptions,
|
dismissChatInterruptions,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
signInWithEmailAndOpenChat,
|
signInWithEmailAndOpenChat,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
|
||||||
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
await clearBrowserState(context, page, baseURL);
|
await clearBrowserState(context, page, baseURL);
|
||||||
await mockCoreApis(page, {
|
await mockCoreApis(page, {
|
||||||
@@ -25,30 +19,23 @@ test.beforeEach(async ({ baseURL, context, page }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user sees insufficient credits when unlocking a paid image from fullscreen and can navigate to subscription", async ({
|
test("user sees insufficient credits when unlocking from the locked image card and can navigate to subscription", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await signInWithEmailAndOpenChat(page);
|
await signInWithEmailAndOpenChat(page);
|
||||||
await dismissChatInterruptions(page);
|
await dismissChatInterruptions(page);
|
||||||
|
|
||||||
const paidImageButton = page.getByRole("button", {
|
const lockedImageCard = page.getByRole("group", {
|
||||||
name: "Open image in fullscreen",
|
name: "Locked private image",
|
||||||
}).last();
|
}).last();
|
||||||
await expect(paidImageButton).toBeVisible({ timeout: 10_000 });
|
await expect(lockedImageCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(lockedImageCard.locator("img")).toHaveCount(0);
|
||||||
await paidImageButton.click();
|
|
||||||
await expect(page).toHaveURL(paidImageOverlayUrl);
|
|
||||||
|
|
||||||
const lockedImageDialog = page.getByRole("dialog", {
|
|
||||||
name: "Locked fullscreen image",
|
|
||||||
});
|
|
||||||
await expect(lockedImageDialog).toBeVisible();
|
|
||||||
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await lockedImageDialog
|
await lockedImageCard
|
||||||
.getByRole("button", { name: "Unlock high-definition large image" })
|
.getByRole("button", { name: "Unlock private image" })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
|||||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
|
NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
|
||||||
|
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ NEXT_PUBLIC_API_BASE_URL=https://api.banlv-ai.com
|
|||||||
|
|
||||||
# WebSocket URL(生产环境)
|
# WebSocket URL(生产环境)
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
|
||||||
|
NEXT_PUBLIC_ENABLE_VOICE_CALL=false
|
||||||
|
|
||||||
# Stripe 支付(publishable key 可暴露在浏览器端)
|
# Stripe 支付(publishable key 可暴露在浏览器端)
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 19 KiB |
@@ -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",
|
||||||
|
|||||||
@@ -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: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 482 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 |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,6 +82,7 @@ describe("core Tailwind components", () => {
|
|||||||
privateZoneLabel="Maya Private Zone"
|
privateZoneLabel="Maya Private Zone"
|
||||||
onChatClick={() => undefined}
|
onChatClick={() => undefined}
|
||||||
onPrivateZoneClick={() => undefined}
|
onPrivateZoneClick={() => undefined}
|
||||||
|
onMenuClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const privateZoneHtml = renderToStaticMarkup(
|
const privateZoneHtml = renderToStaticMarkup(
|
||||||
@@ -91,6 +92,7 @@ describe("core Tailwind components", () => {
|
|||||||
privateZoneLabel="Maya Private Zone"
|
privateZoneLabel="Maya Private Zone"
|
||||||
onChatClick={() => undefined}
|
onChatClick={() => undefined}
|
||||||
onPrivateZoneClick={() => undefined}
|
onPrivateZoneClick={() => undefined}
|
||||||
|
onMenuClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,5 +109,7 @@ describe("core Tailwind components", () => {
|
|||||||
expect(privateZoneHtml).toContain(
|
expect(privateZoneHtml).toContain(
|
||||||
'data-analytics-key="navigation.private_zone"',
|
'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,10 +1,10 @@
|
|||||||
"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" | "privateZone";
|
export type AppBottomNavItem = "chat" | "privateZone" | "menu";
|
||||||
export type AppBottomNavVariant = "warm" | "dark";
|
export type AppBottomNavVariant = "warm" | "dark";
|
||||||
|
|
||||||
export interface AppBottomNavProps {
|
export interface AppBottomNavProps {
|
||||||
@@ -13,6 +13,7 @@ export interface AppBottomNavProps {
|
|||||||
privateZoneLabel: string;
|
privateZoneLabel: string;
|
||||||
onChatClick: () => void;
|
onChatClick: () => void;
|
||||||
onPrivateZoneClick: () => void;
|
onPrivateZoneClick: () => void;
|
||||||
|
onMenuClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBottomNav({
|
export function AppBottomNav({
|
||||||
@@ -21,6 +22,7 @@ export function AppBottomNav({
|
|||||||
privateZoneLabel,
|
privateZoneLabel,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
onPrivateZoneClick,
|
onPrivateZoneClick,
|
||||||
|
onMenuClick,
|
||||||
}: AppBottomNavProps) {
|
}: AppBottomNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav className={getRootClass(variant)} aria-label="Primary navigation">
|
<nav className={getRootClass(variant)} aria-label="Primary navigation">
|
||||||
@@ -46,6 +48,17 @@ export function AppBottomNav({
|
|||||||
<Camera size={20} aria-hidden="true" />
|
<Camera size={20} aria-hidden="true" />
|
||||||
<span>{privateZoneLabel}</span>
|
<span>{privateZoneLabel}</span>
|
||||||
</button>
|
</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>
|
||||||
</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()}
|
||||||
|
|||||||
@@ -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,28 +51,145 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
launch={hiddenLaunch}
|
launch={hiddenLaunch}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the shared Ezpay confirmation content", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<PaymentLaunchDialogs
|
|
||||||
currentOrderId="order-123"
|
|
||||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
|
||||||
ezpayDescription="Your coffee order is ready."
|
|
||||||
launch={{
|
|
||||||
...hiddenLaunch,
|
|
||||||
isConfirmingEzpay: true,
|
|
||||||
shouldShowEzpayConfirmDialog: true,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('role="alertdialog"');
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||||
expect(html).toContain("Continue to GCash?");
|
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull();
|
||||||
expect(html).toContain("Your coffee order is ready.");
|
});
|
||||||
expect(html).toContain("Order No. order-123");
|
|
||||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
|
||||||
expect(html).toContain("Opening...");
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<PaymentLaunchDialogs
|
||||||
|
currentOrderId="order-123"
|
||||||
|
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||||
|
ezpayDescription="Your coffee order is ready."
|
||||||
|
launch={{
|
||||||
|
...hiddenLaunch,
|
||||||
|
isConfirmingEzpay: true,
|
||||||
|
shouldShowEzpayConfirmDialog: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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?.textContent).toContain("Continue to payment?");
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels Indonesia Ezpay as QRIS without the GCash logo", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<PaymentMethodSelector
|
||||||
|
config={{
|
||||||
|
canChoosePaymentMethod: true,
|
||||||
|
initialPayChannel: "ezpay",
|
||||||
|
showPaymentMethodSelector: true,
|
||||||
|
ezpayDisplayName: "QRIS",
|
||||||
|
}}
|
||||||
|
value="ezpay"
|
||||||
|
caption="QRIS by default in Indonesia"
|
||||||
|
analyticsKey="subscription.payment_method"
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('aria-label="QRIS"');
|
||||||
|
expect(html).toContain("QRIS by default in Indonesia");
|
||||||
|
expect(html).not.toContain("gcash-logo.svg");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders compact controls without the caption", () => {
|
it("renders compact controls without the caption", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<PaymentMethodSelector
|
<PaymentMethodSelector
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||||
|
const elementsSubmit = vi.fn(async () => ({}));
|
||||||
|
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||||
|
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||||
|
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@stripe/stripe-js", () => ({
|
||||||
|
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@stripe/react-stripe-js", () => ({
|
||||||
|
Elements: ({
|
||||||
|
children,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
capturedElementsOptions = options;
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedExpressProps = props;
|
||||||
|
return <div data-testid="express-checkout" />;
|
||||||
|
},
|
||||||
|
PaymentElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedPaymentProps = props;
|
||||||
|
return <div data-testid="payment-element" />;
|
||||||
|
},
|
||||||
|
useElements: () => ({ submit: elementsSubmit }),
|
||||||
|
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||||
|
|
||||||
|
describe("StripePaymentDialog", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedElementsOptions = null;
|
||||||
|
capturedExpressProps = null;
|
||||||
|
capturedPaymentProps = null;
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
customerSessionClientSecret="cuss_123_secret_saved"
|
||||||
|
savedPaymentMethodsEnabled
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedElementsOptions).toMatchObject({
|
||||||
|
clientSecret: "pi_123_secret_payment",
|
||||||
|
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||||
|
});
|
||||||
|
expect(capturedExpressProps?.options).toMatchObject({
|
||||||
|
paymentMethodOrder: [
|
||||||
|
"apple_pay",
|
||||||
|
"google_pay",
|
||||||
|
"link",
|
||||||
|
"paypal",
|
||||||
|
"amazon_pay",
|
||||||
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "always",
|
||||||
|
googlePay: "always",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(capturedPaymentProps?.options).toMatchObject({
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["card"],
|
||||||
|
wallets: { applePay: "never", googlePay: "never", link: "never" },
|
||||||
|
});
|
||||||
|
const text = document.body.textContent ?? "";
|
||||||
|
expect(text).toContain("Pay by card");
|
||||||
|
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||||
|
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||||
|
expect(express).not.toBeNull();
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
express!.compareDocumentPosition(card!) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
customerSessionClientSecret="invalid"
|
||||||
|
savedPaymentMethodsEnabled={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedElementsOptions).not.toHaveProperty(
|
||||||
|
"customerSessionClientSecret",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||||
|
const onConfirmed = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfirm = capturedExpressProps?.onConfirm as
|
||||||
|
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
expect(onConfirm).toBeTypeOf("function");
|
||||||
|
await act(async () => {
|
||||||
|
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
elements: expect.any(Object),
|
||||||
|
redirect: "if_required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
|
||||||
|
|
||||||
|
export interface ExternalBrowserCheckoutButtonProps {
|
||||||
|
checkoutIntent: CheckoutIntent;
|
||||||
|
disabled?: boolean;
|
||||||
|
characterSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalBrowserCheckoutButton({
|
||||||
|
checkoutIntent,
|
||||||
|
disabled = false,
|
||||||
|
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
|
}: ExternalBrowserCheckoutButtonProps) {
|
||||||
|
const isFacebookBrowser = useSyncExternalStore(
|
||||||
|
() => () => undefined,
|
||||||
|
() => BrowserDetector.isFacebookInAppBrowser(),
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const [isOpening, setIsOpening] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isFacebookBrowser) return null;
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (disabled || isOpening) return;
|
||||||
|
setIsOpening(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
const result = await createCheckoutHandoff(checkoutIntent);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
result.error,
|
||||||
|
"Could not open this checkout in your browser. Please sign in and try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const verifiedCharacter =
|
||||||
|
getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||||
|
const externalUrl = new URL(
|
||||||
|
result.data.externalUrl,
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
externalUrl.searchParams.set("character", verifiedCharacter);
|
||||||
|
UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString());
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
error,
|
||||||
|
"Could not open this checkout in your browser. Please try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 w-full text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
disabled={disabled || isOpening}
|
||||||
|
onClick={() => void handleOpen()}
|
||||||
|
>
|
||||||
|
{isOpening
|
||||||
|
? "Opening browser..."
|
||||||
|
: "Open in browser for more payment methods"}
|
||||||
|
</button>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
|
|
||||||
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
|
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -20,22 +22,28 @@ export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
|
|||||||
return <StripePaymentDialog {...props} />;
|
return <StripePaymentDialog {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StripePaymentDialogLoading() {
|
export function StripePaymentDialogLoading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={() => undefined}
|
||||||
aria-labelledby="stripe-payment-loading-title"
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy="stripe-payment-loading-title"
|
||||||
|
ariaDescribedBy="stripe-payment-loading-description"
|
||||||
>
|
>
|
||||||
<div className={styles.dialog} aria-busy="true">
|
<div aria-busy="true">
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 id="stripe-payment-loading-title" className={styles.title}>
|
<h2 id="stripe-payment-loading-title" className={styles.title}>
|
||||||
Preparing secure payment
|
Preparing secure payment
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>Loading payment methods...</p>
|
<p id="stripe-payment-loading-description" className={styles.content}>
|
||||||
|
Loading payment methods...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useId, type ReactNode } from "react";
|
import { useId, type ReactNode } from "react";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
|
|
||||||
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
||||||
@@ -11,12 +13,19 @@ type PaymentLaunchDialogFlow = Pick<
|
|||||||
PaymentLaunchFlow,
|
PaymentLaunchFlow,
|
||||||
| "handleEzpayCancel"
|
| "handleEzpayCancel"
|
||||||
| "handleEzpayConfirm"
|
| "handleEzpayConfirm"
|
||||||
|
| "handleRegionalQrClose"
|
||||||
| "handleStripeClose"
|
| "handleStripeClose"
|
||||||
| "handleStripeConfirmed"
|
| "handleStripeConfirmed"
|
||||||
| "isConfirmingEzpay"
|
| "isConfirmingEzpay"
|
||||||
|
| "regionalQrErrorMessage"
|
||||||
|
| "regionalQrPayment"
|
||||||
|
| "regionalQrStatus"
|
||||||
| "shouldShowEzpayConfirmDialog"
|
| "shouldShowEzpayConfirmDialog"
|
||||||
|
| "shouldShowRegionalQrDialog"
|
||||||
| "shouldShowStripeDialog"
|
| "shouldShowStripeDialog"
|
||||||
| "stripeClientSecret"
|
| "stripeClientSecret"
|
||||||
|
| "stripeCustomerSessionClientSecret"
|
||||||
|
| "savedPaymentMethodsEnabled"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface PaymentLaunchDialogsProps {
|
export interface PaymentLaunchDialogsProps {
|
||||||
@@ -46,9 +55,21 @@ export function PaymentLaunchDialogs({
|
|||||||
onConfirm={launch.handleEzpayConfirm}
|
onConfirm={launch.handleEzpayConfirm}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
|
||||||
|
<RegionalQrPaymentDialog
|
||||||
|
payment={launch.regionalQrPayment}
|
||||||
|
status={launch.regionalQrStatus}
|
||||||
|
errorMessage={launch.regionalQrErrorMessage}
|
||||||
|
onClose={launch.handleRegionalQrClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||||
<LazyStripePaymentDialog
|
<LazyStripePaymentDialog
|
||||||
clientSecret={launch.stripeClientSecret}
|
clientSecret={launch.stripeClientSecret}
|
||||||
|
customerSessionClientSecret={
|
||||||
|
launch.stripeCustomerSessionClientSecret
|
||||||
|
}
|
||||||
|
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||||
returnPath={stripeReturnPath}
|
returnPath={stripeReturnPath}
|
||||||
onClose={launch.handleStripeClose}
|
onClose={launch.handleStripeClose}
|
||||||
onConfirmed={launch.handleStripeConfirmed}
|
onConfirmed={launch.handleStripeConfirmed}
|
||||||
@@ -58,6 +79,151 @@ export function PaymentLaunchDialogs({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RegionalQrPaymentDialogProps {
|
||||||
|
errorMessage: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
|
||||||
|
status: PaymentLaunchFlow["regionalQrStatus"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRegionalQrAmount(amountCents: number, currency: string): string {
|
||||||
|
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(
|
||||||
|
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
|
||||||
|
{
|
||||||
|
style: "currency",
|
||||||
|
currency: normalizedCurrency,
|
||||||
|
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||||
|
},
|
||||||
|
).format(amountCents / 100);
|
||||||
|
} catch {
|
||||||
|
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionalQrStatusMessage(
|
||||||
|
status: PaymentLaunchFlow["regionalQrStatus"],
|
||||||
|
errorMessage: string | null,
|
||||||
|
paymentName: string,
|
||||||
|
): string {
|
||||||
|
if (status === "failed") {
|
||||||
|
return errorMessage || "Payment failed. Please try again.";
|
||||||
|
}
|
||||||
|
if (status === "expired") {
|
||||||
|
return errorMessage || `This ${paymentName} order has expired.`;
|
||||||
|
}
|
||||||
|
return "Waiting for payment";
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegionalQrPaymentDialog({
|
||||||
|
errorMessage,
|
||||||
|
onClose,
|
||||||
|
payment,
|
||||||
|
status,
|
||||||
|
}: RegionalQrPaymentDialogProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const copy = regionalQrCopy(payment.experience);
|
||||||
|
const statusMessage = regionalQrStatusMessage(
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
copy.paymentName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
|
>
|
||||||
|
<div className={`${styles.header} text-center`}>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{copy.title}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.content}>
|
||||||
|
{copy.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
|
||||||
|
{payment.qrData ? (
|
||||||
|
<QRCodeSVG
|
||||||
|
value={payment.qrData}
|
||||||
|
title={copy.qrTitle}
|
||||||
|
size={256}
|
||||||
|
level="M"
|
||||||
|
marginSize={2}
|
||||||
|
className="h-auto w-full max-w-64"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{copy.unavailableMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 flex flex-col gap-2 text-center">
|
||||||
|
<p className={styles.content}>Order No. {payment.orderId}</p>
|
||||||
|
<p className="m-0 text-xl font-bold text-text-foreground">
|
||||||
|
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{statusMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${styles.secondary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionalQrCopy(
|
||||||
|
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
|
||||||
|
) {
|
||||||
|
if (experience === "gcashQrPh") {
|
||||||
|
return {
|
||||||
|
paymentName: "GCash / QR Ph",
|
||||||
|
title: "Pay with GCash / QR Ph",
|
||||||
|
description:
|
||||||
|
"Open GCash or another QR Ph-compatible app and scan this code.",
|
||||||
|
qrTitle: "GCash / QR Ph payment QR code",
|
||||||
|
unavailableMessage:
|
||||||
|
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (experience === "qris") {
|
||||||
|
return {
|
||||||
|
paymentName: "QRIS",
|
||||||
|
title: "Scan to pay with QRIS",
|
||||||
|
description:
|
||||||
|
"Open a QRIS-compatible banking or wallet app and scan this code.",
|
||||||
|
qrTitle: "QRIS payment QR code",
|
||||||
|
unavailableMessage:
|
||||||
|
"QRIS data is unavailable. Please close this dialog and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
paymentName: "payment QR",
|
||||||
|
title: "Scan to pay",
|
||||||
|
description: "Open a compatible banking or wallet app and scan this code.",
|
||||||
|
qrTitle: "Payment QR code",
|
||||||
|
unavailableMessage:
|
||||||
|
"Payment QR data is unavailable. Please close this dialog and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface EzpayRedirectConfirmDialogProps {
|
interface EzpayRedirectConfirmDialogProps {
|
||||||
description: ReactNode;
|
description: ReactNode;
|
||||||
externalCheckoutAnalyticsKey: string;
|
externalCheckoutAnalyticsKey: string;
|
||||||
@@ -78,41 +244,43 @@ function EzpayRedirectConfirmDialog({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onCancel}
|
||||||
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
ariaLabelledBy={titleId}
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
<div className={styles.header}>
|
||||||
<div className={styles.header}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
Continue to payment?
|
||||||
Continue to GCash?
|
</h2>
|
||||||
</h2>
|
<p className={styles.content}>{description}</p>
|
||||||
<p className={styles.content}>{description}</p>
|
<p className={styles.content}>Order No. {orderId}</p>
|
||||||
<p className={styles.content}>Order No. {orderId}</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.button} ${styles.secondary}`}
|
|
||||||
disabled={isConfirming}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-analytics-key={externalCheckoutAnalyticsKey}
|
|
||||||
data-analytics-label="Continue to external checkout"
|
|
||||||
className={`${styles.button} ${styles.primary}`}
|
|
||||||
disabled={isConfirming}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{isConfirming ? "Opening..." : "Continue"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${styles.secondary}`}
|
||||||
|
disabled={isConfirming}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key={externalCheckoutAnalyticsKey}
|
||||||
|
data-analytics-label="Continue to external checkout"
|
||||||
|
className={`${styles.button} ${styles.primary}`}
|
||||||
|
disabled={isConfirming}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isConfirming ? "Opening..." : "Continue"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,24 @@ export function PaymentMethodSelector({
|
|||||||
}: PaymentMethodSelectorProps) {
|
}: PaymentMethodSelectorProps) {
|
||||||
if (!config.showPaymentMethodSelector) return null;
|
if (!config.showPaymentMethodSelector) return null;
|
||||||
const isCompact = density === "compact";
|
const isCompact = density === "compact";
|
||||||
|
const ezpayDisplayName = config.ezpayDisplayName ?? "GCash";
|
||||||
|
const configuredPaymentMethods = PAYMENT_METHODS.map((method) =>
|
||||||
|
method.channel === "ezpay"
|
||||||
|
? {
|
||||||
|
...method,
|
||||||
|
title: ezpayDisplayName,
|
||||||
|
logoSrc:
|
||||||
|
ezpayDisplayName === "GCash" ? method.logoSrc : undefined,
|
||||||
|
}
|
||||||
|
: method,
|
||||||
|
);
|
||||||
|
|
||||||
const paymentMethods =
|
const paymentMethods =
|
||||||
config.initialPayChannel === "ezpay"
|
config.initialPayChannel === "ezpay"
|
||||||
? [...PAYMENT_METHODS].sort((a, b) =>
|
? [...configuredPaymentMethods].sort((a, b) =>
|
||||||
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
||||||
)
|
)
|
||||||
: PAYMENT_METHODS;
|
: configuredPaymentMethods;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ export const stripePaymentDialogStyles = {
|
|||||||
overlay:
|
overlay:
|
||||||
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0))]",
|
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0))]",
|
||||||
dialog:
|
dialog:
|
||||||
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
|
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto overscroll-contain rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
|
||||||
header: "mb-(--page-section-gap,18px) text-left",
|
header: "mb-(--page-section-gap,18px) text-left",
|
||||||
title:
|
title:
|
||||||
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
|
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
|
||||||
content:
|
content:
|
||||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||||
form: "flex flex-col gap-(--page-section-gap,18px)",
|
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||||
|
expressSection: "w-full empty:hidden",
|
||||||
|
cardSection:
|
||||||
|
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
|
||||||
|
cardTitle:
|
||||||
|
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
|
||||||
error:
|
error:
|
||||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||||
|
|||||||
@@ -4,18 +4,28 @@
|
|||||||
*
|
*
|
||||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||||
*/
|
*/
|
||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Elements,
|
Elements,
|
||||||
|
ExpressCheckoutElement,
|
||||||
PaymentElement,
|
PaymentElement,
|
||||||
useElements,
|
useElements,
|
||||||
useStripe,
|
useStripe,
|
||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import {
|
||||||
|
loadStripe,
|
||||||
|
type AvailablePaymentMethods,
|
||||||
|
type StripeExpressCheckoutElementConfirmEvent,
|
||||||
|
} from "@stripe/stripe-js";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -25,8 +35,16 @@ const stripePromise = stripePublishableKey
|
|||||||
? loadStripe(stripePublishableKey)
|
? loadStripe(stripePublishableKey)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||||
|
|
||||||
|
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||||
|
return value === true || (typeof value === "object" && value.available);
|
||||||
|
}
|
||||||
|
|
||||||
export interface StripePaymentDialogProps {
|
export interface StripePaymentDialogProps {
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
|
customerSessionClientSecret?: string | null;
|
||||||
|
savedPaymentMethodsEnabled?: boolean;
|
||||||
returnPath?: string;
|
returnPath?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmed?: () => void;
|
onConfirmed?: () => void;
|
||||||
@@ -34,73 +52,98 @@ export interface StripePaymentDialogProps {
|
|||||||
|
|
||||||
export function StripePaymentDialog({
|
export function StripePaymentDialog({
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
customerSessionClientSecret = null,
|
||||||
|
savedPaymentMethodsEnabled = false,
|
||||||
returnPath = ROUTES.subscription + "/success",
|
returnPath = ROUTES.subscription + "/success",
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: StripePaymentDialogProps) {
|
}: StripePaymentDialogProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
||||||
|
provider: "stripe",
|
||||||
|
customerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!stripePromise) {
|
if (!stripePromise) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} role="alertdialog" aria-modal="true">
|
<ModalPortal
|
||||||
<div className={styles.dialog}>
|
open
|
||||||
<div className={styles.header}>
|
persistent
|
||||||
<h2 className={styles.title}>Payment unavailable</h2>
|
onClose={onClose}
|
||||||
<p className={styles.content}>
|
scrimClassName={styles.overlay}
|
||||||
Stripe publishable key is not configured for this build.
|
panelClassName={styles.dialog}
|
||||||
</p>
|
scrimOpacity={0.5}
|
||||||
</div>
|
role="alertdialog"
|
||||||
<div className={styles.actions}>
|
ariaLabelledBy={titleId}
|
||||||
<button
|
ariaDescribedBy={descriptionId}
|
||||||
type="button"
|
>
|
||||||
className={`${styles.button} ${styles.primary}`}
|
<div className={styles.header}>
|
||||||
onClick={onClose}
|
<h2 id={titleId} className={styles.title}>
|
||||||
>
|
Payment unavailable
|
||||||
OK
|
</h2>
|
||||||
</button>
|
<p id={descriptionId} className={styles.content}>
|
||||||
</div>
|
Stripe publishable key is not configured for this build.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${styles.primary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="stripe-payment-title"
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
|
ariaDescribedBy={descriptionId}
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
<div className={styles.header}>
|
||||||
<div className={styles.header}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
<h2 id="stripe-payment-title" className={styles.title}>
|
Complete payment
|
||||||
Complete payment
|
</h2>
|
||||||
</h2>
|
<p id={descriptionId} className={styles.content}>
|
||||||
<p className={styles.content}>
|
Enter your payment details securely through Stripe.
|
||||||
Enter your payment details securely through Stripe.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Elements
|
|
||||||
key={clientSecret}
|
|
||||||
stripe={stripePromise}
|
|
||||||
options={{
|
|
||||||
clientSecret,
|
|
||||||
appearance: {
|
|
||||||
theme: "stripe",
|
|
||||||
variables: {
|
|
||||||
borderRadius: "14px",
|
|
||||||
colorPrimary: "#ff52a2",
|
|
||||||
colorText: "#171717",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StripePaymentForm
|
|
||||||
returnPath={returnPath}
|
|
||||||
onClose={onClose}
|
|
||||||
onConfirmed={onConfirmed}
|
|
||||||
/>
|
|
||||||
</Elements>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Elements
|
||||||
|
key={clientSecret}
|
||||||
|
stripe={stripePromise}
|
||||||
|
options={{
|
||||||
|
clientSecret,
|
||||||
|
...(savedCardClientSecret
|
||||||
|
? { customerSessionClientSecret: savedCardClientSecret }
|
||||||
|
: {}),
|
||||||
|
appearance: {
|
||||||
|
theme: "stripe",
|
||||||
|
variables: {
|
||||||
|
borderRadius: "14px",
|
||||||
|
colorPrimary: "#ff52a2",
|
||||||
|
colorText: "#171717",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StripePaymentForm
|
||||||
|
returnPath={returnPath}
|
||||||
|
onClose={onClose}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +159,20 @@ function StripePaymentForm({
|
|||||||
const elements = useElements();
|
const elements = useElements();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
|
||||||
|
boolean | null
|
||||||
|
>(null);
|
||||||
|
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") return;
|
||||||
|
const query = window.matchMedia("(min-width: 640px)");
|
||||||
|
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
|
||||||
|
updateColumns();
|
||||||
|
query.addEventListener("change", updateColumns);
|
||||||
|
return () => query.removeEventListener("change", updateColumns);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoadError = (event: Parameters<
|
const handleLoadError = (event: Parameters<
|
||||||
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||||
@@ -134,38 +191,118 @@ function StripePaymentForm({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const updateExpressAvailability = (
|
||||||
event.preventDefault();
|
availablePaymentMethods:
|
||||||
if (!stripe || !elements || isSubmitting) return;
|
| AvailablePaymentMethods
|
||||||
|
| {
|
||||||
|
applePay?: { available: boolean };
|
||||||
|
googlePay?: { available: boolean };
|
||||||
|
link?: { available: boolean };
|
||||||
|
paypal?: { available: boolean };
|
||||||
|
amazonPay?: { available: boolean };
|
||||||
|
klarna?: { available: boolean };
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
|
const availability = {
|
||||||
|
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
||||||
|
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
||||||
|
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
||||||
|
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
||||||
|
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
||||||
|
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
||||||
|
};
|
||||||
|
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
||||||
|
const browser = BrowserDetector.isFacebookInAppBrowser()
|
||||||
|
? "facebook"
|
||||||
|
: BrowserDetector.getBrowserName() || "unknown";
|
||||||
|
const platform = PlatformDetector.getPlatform();
|
||||||
|
const metadata = {
|
||||||
|
browser,
|
||||||
|
platform,
|
||||||
|
availablePaymentMethods: availability,
|
||||||
|
};
|
||||||
|
log.info(
|
||||||
|
"[stripe-payment-dialog] Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"stripe.express_checkout_availability",
|
||||||
|
"Stripe Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpressLoadError = (event: Parameters<
|
||||||
|
NonNullable<
|
||||||
|
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
||||||
|
>
|
||||||
|
>[0]) => {
|
||||||
|
setHasExpressPaymentMethods(false);
|
||||||
|
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
||||||
|
type: event.error.type,
|
||||||
|
code: event.error.code,
|
||||||
|
message: event.error.message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPayment = async (
|
||||||
|
options: {
|
||||||
|
submitPaymentElement: boolean;
|
||||||
|
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!stripe || !elements || submittingRef.current) return;
|
||||||
|
|
||||||
|
submittingRef.current = true;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
const { error: submitError } = await elements.submit();
|
if (options.submitPaymentElement) {
|
||||||
if (submitError) {
|
let submitError: unknown;
|
||||||
setErrorMessage(
|
try {
|
||||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
({ error: submitError } = await elements.submit());
|
||||||
);
|
} catch (error) {
|
||||||
setIsSubmitting(false);
|
submitError = error;
|
||||||
return;
|
}
|
||||||
|
if (submitError) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
submitError,
|
||||||
|
"Please check your payment info.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
submittingRef.current = false;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(
|
const returnUrl = new URL(
|
||||||
returnPath ?? ROUTES.subscription + "/success",
|
returnPath ?? ROUTES.subscription + "/success",
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const { error } = await stripe.confirmPayment({
|
let confirmationError: unknown;
|
||||||
elements,
|
try {
|
||||||
confirmParams: {
|
({ error: confirmationError } = await stripe.confirmPayment({
|
||||||
return_url: returnUrl.toString(),
|
elements,
|
||||||
},
|
confirmParams: {
|
||||||
redirect: "if_required",
|
return_url: returnUrl.toString(),
|
||||||
});
|
},
|
||||||
|
redirect: "if_required",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
confirmationError = error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (confirmationError) {
|
||||||
setErrorMessage(
|
const message = ExceptionHandler.message(
|
||||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
confirmationError,
|
||||||
|
"Payment confirmation failed.",
|
||||||
);
|
);
|
||||||
|
setErrorMessage(message);
|
||||||
|
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||||
|
submittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,9 +310,74 @@ function StripePaymentForm({
|
|||||||
onConfirmed?.();
|
onConfirmed?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await confirmPayment({ submitPaymentElement: true });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<PaymentElement onLoadError={handleLoadError} />
|
<section
|
||||||
|
className={styles.expressSection}
|
||||||
|
hidden={hasExpressPaymentMethods === false}
|
||||||
|
aria-label="Express payment methods"
|
||||||
|
>
|
||||||
|
<ExpressCheckoutElement
|
||||||
|
options={{
|
||||||
|
paymentMethodOrder: [
|
||||||
|
"apple_pay",
|
||||||
|
"google_pay",
|
||||||
|
"link",
|
||||||
|
"paypal",
|
||||||
|
"amazon_pay",
|
||||||
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "always",
|
||||||
|
googlePay: "always",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
maxColumns: expressMaxColumns,
|
||||||
|
maxRows: 6,
|
||||||
|
overflow: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onReady={(event) =>
|
||||||
|
updateExpressAvailability(event.availablePaymentMethods)
|
||||||
|
}
|
||||||
|
onAvailablePaymentMethodsChange={(event) =>
|
||||||
|
updateExpressAvailability(event.paymentMethods)
|
||||||
|
}
|
||||||
|
onLoadError={handleExpressLoadError}
|
||||||
|
onConfirm={(event) =>
|
||||||
|
void confirmPayment({
|
||||||
|
submitPaymentElement: false,
|
||||||
|
expressEvent: event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
|
||||||
|
<h3 id="pay-by-card-title" className={styles.cardTitle}>
|
||||||
|
Pay by card
|
||||||
|
</h3>
|
||||||
|
<PaymentElement
|
||||||
|
options={{
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["card"],
|
||||||
|
wallets: {
|
||||||
|
applePay: "never",
|
||||||
|
googlePay: "never",
|
||||||
|
link: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onLoadError={handleLoadError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getPaymentUrlHostname,
|
||||||
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
|
resolveEzpayLaunchTarget,
|
||||||
} from "@/lib/payment/payment_launch";
|
} from "@/lib/payment/payment_launch";
|
||||||
import { behaviorAnalytics } from "@/lib/analytics";
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
import type {
|
import type {
|
||||||
@@ -40,19 +43,63 @@ export interface UsePaymentLaunchFlowInput {
|
|||||||
subscriptionType: PendingPaymentSubscriptionType;
|
subscriptionType: PendingPaymentSubscriptionType;
|
||||||
giftCategory?: string | null;
|
giftCategory?: string | null;
|
||||||
giftPlanId?: string | null;
|
giftPlanId?: string | null;
|
||||||
|
countryCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentLaunchFlow {
|
export interface PaymentLaunchFlow {
|
||||||
ezpayPaymentUrl: string | null;
|
ezpayPaymentUrl: string | null;
|
||||||
handleEzpayCancel: () => void;
|
handleEzpayCancel: () => void;
|
||||||
handleEzpayConfirm: () => void;
|
handleEzpayConfirm: () => void;
|
||||||
|
handleRegionalQrClose: () => void;
|
||||||
handleStripeClose: () => void;
|
handleStripeClose: () => void;
|
||||||
handleStripeConfirmed: () => void;
|
handleStripeConfirmed: () => void;
|
||||||
isConfirmingEzpay: boolean;
|
isConfirmingEzpay: boolean;
|
||||||
|
regionalQrErrorMessage: string | null;
|
||||||
|
regionalQrPayment: RegionalQrPaymentDetails | null;
|
||||||
|
regionalQrStatus: PaymentContextState["orderStatus"];
|
||||||
resetLaunchState: () => void;
|
resetLaunchState: () => void;
|
||||||
shouldShowEzpayConfirmDialog: boolean;
|
shouldShowEzpayConfirmDialog: boolean;
|
||||||
|
shouldShowRegionalQrDialog: boolean;
|
||||||
shouldShowStripeDialog: boolean;
|
shouldShowStripeDialog: boolean;
|
||||||
stripeClientSecret: string | null;
|
stripeClientSecret: string | null;
|
||||||
|
stripeCustomerSessionClientSecret: string | null;
|
||||||
|
savedPaymentMethodsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionalQrPaymentDetails {
|
||||||
|
amountCents: number;
|
||||||
|
currency: string;
|
||||||
|
experience: "gcashQrPh" | "qris" | "paymentQr";
|
||||||
|
orderId: string;
|
||||||
|
qrData: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentParamString(
|
||||||
|
payParams: Record<string, unknown>,
|
||||||
|
...keys: string[]
|
||||||
|
): string | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = payParams[key];
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentParamAmountCents(
|
||||||
|
payParams: Record<string, unknown>,
|
||||||
|
): number | null {
|
||||||
|
for (const key of [
|
||||||
|
"firstChargeAmountCents",
|
||||||
|
"first_charge_amount_cents",
|
||||||
|
"amountCents",
|
||||||
|
"amount_cents",
|
||||||
|
]) {
|
||||||
|
const value = payParams[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackPaymentCheckoutOpened(
|
function trackPaymentCheckoutOpened(
|
||||||
@@ -112,6 +159,7 @@ export function usePaymentLaunchFlow({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
giftCategory,
|
giftCategory,
|
||||||
giftPlanId,
|
giftPlanId,
|
||||||
|
countryCode,
|
||||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||||
const launchedNonceRef = useRef(0);
|
const launchedNonceRef = useRef(0);
|
||||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||||
@@ -121,9 +169,45 @@ export function usePaymentLaunchFlow({
|
|||||||
const stripeClientSecret = payment.payParams
|
const stripeClientSecret = payment.payParams
|
||||||
? getStripeClientSecret(payment.payParams)
|
? getStripeClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
const ezpayPaymentUrl = payment.payParams
|
const stripeCustomerSessionClientSecret = payment.payParams
|
||||||
? getPaymentUrl(payment.payParams)
|
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
|
const selectedPlan = payment.plans.find(
|
||||||
|
(item) => item.planId === payment.selectedPlanId,
|
||||||
|
);
|
||||||
|
const paymentCurrency = payment.payParams
|
||||||
|
? paymentParamString(payment.payParams, "currency") ??
|
||||||
|
selectedPlan?.currency ??
|
||||||
|
null
|
||||||
|
: selectedPlan?.currency ?? null;
|
||||||
|
const ezpayLaunchTarget =
|
||||||
|
payment.payParams && isEzpayPayment(payment.payParams)
|
||||||
|
? resolveEzpayLaunchTarget(payment.payParams, {
|
||||||
|
countryCode,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const ezpayPaymentUrl =
|
||||||
|
ezpayLaunchTarget?.kind === "url"
|
||||||
|
? ezpayLaunchTarget.paymentUrl
|
||||||
|
: null;
|
||||||
|
const regionalQrPayment: RegionalQrPaymentDetails | null =
|
||||||
|
payment.payParams &&
|
||||||
|
payment.currentOrderId &&
|
||||||
|
ezpayLaunchTarget?.kind === "qr"
|
||||||
|
? {
|
||||||
|
amountCents:
|
||||||
|
paymentParamAmountCents(payment.payParams) ??
|
||||||
|
selectedPlan?.amountCents ??
|
||||||
|
0,
|
||||||
|
currency:
|
||||||
|
paymentCurrency ??
|
||||||
|
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
|
||||||
|
experience: ezpayLaunchTarget.experience,
|
||||||
|
orderId: payment.currentOrderId,
|
||||||
|
qrData: ezpayLaunchTarget.qrData,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -136,39 +220,95 @@ export function usePaymentLaunchFlow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
const isEzpay = isEzpayPayment(payment.payParams);
|
||||||
if (paymentUrl) {
|
if (isEzpay) {
|
||||||
const isEzpay = isEzpayPayment(payment.payParams);
|
const target = resolveEzpayLaunchTarget(payment.payParams, {
|
||||||
|
countryCode,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
});
|
||||||
|
const channelType = paymentParamString(
|
||||||
|
payment.payParams,
|
||||||
|
"channelType",
|
||||||
|
"channel_type",
|
||||||
|
);
|
||||||
|
const channelCode = paymentParamString(
|
||||||
|
payment.payParams,
|
||||||
|
"channelCode",
|
||||||
|
"channel_code",
|
||||||
|
);
|
||||||
|
const namedPaymentUrl = getPaymentUrl(payment.payParams);
|
||||||
|
log.debug(`[${logScope}] ezpay launch target resolved`, {
|
||||||
|
countryCode: countryCode?.trim().toUpperCase() ?? null,
|
||||||
|
currency: paymentCurrency?.trim().toUpperCase() ?? null,
|
||||||
|
channelType: channelType?.toUpperCase() ?? null,
|
||||||
|
channelCode,
|
||||||
|
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
|
||||||
|
hasQrData: target.kind === "qr",
|
||||||
|
paymentUrlHost:
|
||||||
|
target.kind === "url"
|
||||||
|
? getPaymentUrlHostname(target.paymentUrl)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
if (target.kind === "error") {
|
||||||
|
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentLaunchFailed",
|
||||||
|
errorMessage: target.errorMessage,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!AppEnvUtil.isProduction() && isEzpay) {
|
if (target.kind === "qr") {
|
||||||
|
if (!payment.currentOrderId) {
|
||||||
|
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentLaunchFailed",
|
||||||
|
errorMessage: "Missing order id before showing payment QR code.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackPaymentCheckoutOpened(
|
||||||
|
payment,
|
||||||
|
target.experience === "qris"
|
||||||
|
? "qris_embedded"
|
||||||
|
: target.experience === "gcashQrPh"
|
||||||
|
? "gcash_qrph_embedded"
|
||||||
|
: "payment_qr_embedded",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentUrl = target.paymentUrl;
|
||||||
|
if (!AppEnvUtil.isProduction()) {
|
||||||
log.debug(`[${logScope}] ezpay confirmation required`, {
|
log.debug(`[${logScope}] ezpay confirmation required`, {
|
||||||
orderId: payment.currentOrderId,
|
orderId: payment.currentOrderId,
|
||||||
paymentUrl,
|
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEzpay) {
|
void launchEzpayRedirect({
|
||||||
void launchEzpayRedirect({
|
orderId: payment.currentOrderId,
|
||||||
orderId: payment.currentOrderId,
|
paymentUrl,
|
||||||
paymentUrl,
|
subscriptionType,
|
||||||
subscriptionType,
|
giftCategory,
|
||||||
giftCategory,
|
giftPlanId,
|
||||||
giftPlanId,
|
...(returnTo ? { returnTo } : {}),
|
||||||
...(returnTo ? { returnTo } : {}),
|
...(characterSlug ? { characterSlug } : {}),
|
||||||
...(characterSlug ? { characterSlug } : {}),
|
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
||||||
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
onFailed: (errorMessage) => {
|
||||||
onFailed: (errorMessage) => {
|
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
|
||||||
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
|
},
|
||||||
},
|
});
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||||
|
if (paymentUrl) {
|
||||||
try {
|
try {
|
||||||
window.location.href = paymentUrl;
|
window.location.assign(paymentUrl);
|
||||||
trackPaymentCheckoutOpened(payment, paymentUrl);
|
trackPaymentCheckoutOpened(payment, paymentUrl);
|
||||||
} catch {
|
} catch {
|
||||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||||
@@ -189,6 +329,7 @@ export function usePaymentLaunchFlow({
|
|||||||
log,
|
log,
|
||||||
logScope,
|
logScope,
|
||||||
characterSlug,
|
characterSlug,
|
||||||
|
countryCode,
|
||||||
payment.currentOrderId,
|
payment.currentOrderId,
|
||||||
payment,
|
payment,
|
||||||
payment.launchNonce,
|
payment.launchNonce,
|
||||||
@@ -197,6 +338,7 @@ export function usePaymentLaunchFlow({
|
|||||||
payment.plans,
|
payment.plans,
|
||||||
payment.selectedPlanId,
|
payment.selectedPlanId,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
|
paymentCurrency,
|
||||||
returnTo,
|
returnTo,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
giftCategory,
|
giftCategory,
|
||||||
@@ -213,6 +355,9 @@ export function usePaymentLaunchFlow({
|
|||||||
payParams: payment.payParams,
|
payParams: payment.payParams,
|
||||||
paymentUrl: ezpayPaymentUrl,
|
paymentUrl: ezpayPaymentUrl,
|
||||||
});
|
});
|
||||||
|
const shouldShowRegionalQrDialog = Boolean(
|
||||||
|
regionalQrPayment && !payment.isPaid,
|
||||||
|
);
|
||||||
|
|
||||||
function resetLaunchState(): void {
|
function resetLaunchState(): void {
|
||||||
setIsConfirmingEzpay(false);
|
setIsConfirmingEzpay(false);
|
||||||
@@ -263,16 +408,33 @@ export function usePaymentLaunchFlow({
|
|||||||
paymentDispatch({ type: "PaymentReset" });
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRegionalQrClose(): void {
|
||||||
|
log.debug(`[${logScope}] regional payment QR dialog closed`, {
|
||||||
|
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
|
||||||
|
experience: regionalQrPayment?.experience ?? null,
|
||||||
|
subscriptionType,
|
||||||
|
});
|
||||||
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ezpayPaymentUrl,
|
ezpayPaymentUrl,
|
||||||
handleEzpayCancel,
|
handleEzpayCancel,
|
||||||
handleEzpayConfirm,
|
handleEzpayConfirm,
|
||||||
|
handleRegionalQrClose,
|
||||||
handleStripeClose,
|
handleStripeClose,
|
||||||
handleStripeConfirmed,
|
handleStripeConfirmed,
|
||||||
isConfirmingEzpay,
|
isConfirmingEzpay,
|
||||||
|
regionalQrErrorMessage: payment.errorMessage,
|
||||||
|
regionalQrPayment,
|
||||||
|
regionalQrStatus: payment.orderStatus,
|
||||||
resetLaunchState,
|
resetLaunchState,
|
||||||
shouldShowEzpayConfirmDialog,
|
shouldShowEzpayConfirmDialog,
|
||||||
|
shouldShowRegionalQrDialog,
|
||||||
shouldShowStripeDialog,
|
shouldShowStripeDialog,
|
||||||
stripeClientSecret,
|
stripeClientSecret,
|
||||||
|
stripeCustomerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled:
|
||||||
|
stripeCustomerSessionClientSecret !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface UsePaymentRouteFlowInput {
|
|||||||
characterId?: string;
|
characterId?: string;
|
||||||
initialCategory?: string | null;
|
initialCategory?: string | null;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
|
commercialOfferId?: string | null;
|
||||||
|
resumeOrderId?: string | null;
|
||||||
|
chatActionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentRouteFlow {
|
export interface PaymentRouteFlow {
|
||||||
@@ -41,15 +45,23 @@ export function usePaymentRouteFlow({
|
|||||||
characterId,
|
characterId,
|
||||||
initialCategory = null,
|
initialCategory = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
|
commercialOfferId = null,
|
||||||
|
resumeOrderId = null,
|
||||||
|
chatActionId = null,
|
||||||
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
|
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const initializedCatalogKeyRef = useRef<string | null>(null);
|
const initializedCatalogKeyRef = useRef<string | null>(null);
|
||||||
|
const resumedOrderIdRef = useRef<string | null>(null);
|
||||||
const catalogKey = [
|
const catalogKey = [
|
||||||
catalog,
|
catalog,
|
||||||
characterId ?? "",
|
characterId ?? "",
|
||||||
initialCategory ?? "",
|
initialCategory ?? "",
|
||||||
initialPlanId ?? "",
|
initialPlanId ?? "",
|
||||||
|
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||||
|
commercialOfferId ?? "",
|
||||||
|
chatActionId ?? "",
|
||||||
].join(":");
|
].join(":");
|
||||||
|
|
||||||
usePendingPaymentOrderLifecycle({
|
usePendingPaymentOrderLifecycle({
|
||||||
@@ -69,16 +81,30 @@ export function usePaymentRouteFlow({
|
|||||||
...(characterId ? { characterId } : {}),
|
...(characterId ? { characterId } : {}),
|
||||||
...(initialCategory ? { category: initialCategory } : {}),
|
...(initialCategory ? { category: initialCategory } : {}),
|
||||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||||
|
...(initialAutoRenew === null
|
||||||
|
? {}
|
||||||
|
: { autoRenew: initialAutoRenew }),
|
||||||
|
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||||
|
...(chatActionId ? { chatActionId } : {}),
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
catalog,
|
catalog,
|
||||||
catalogKey,
|
catalogKey,
|
||||||
characterId,
|
characterId,
|
||||||
|
commercialOfferId,
|
||||||
|
chatActionId,
|
||||||
initialCategory,
|
initialCategory,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resumeOrderId || resumedOrderIdRef.current === resumeOrderId) return;
|
||||||
|
resumedOrderIdRef.current = resumeOrderId;
|
||||||
|
paymentDispatch({ type: "PaymentReturned", orderId: resumeOrderId });
|
||||||
|
}, [paymentDispatch, resumeOrderId]);
|
||||||
|
|
||||||
return { payment, paymentDispatch };
|
return { payment, paymentDispatch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { VoiceCallPlayer } from "@/app/call/voice-call-player";
|
||||||
|
|
||||||
|
describe("VoiceCallPlayer", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules decoded sentence audio on one continuous Web Audio timeline", async () => {
|
||||||
|
const starts: number[] = [];
|
||||||
|
const sources: Array<{ onended: (() => void) | null }> = [];
|
||||||
|
const context = {
|
||||||
|
state: "running",
|
||||||
|
currentTime: 0,
|
||||||
|
destination: {},
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
decodeAudioData: vi.fn(async () => ({ duration: 1 })),
|
||||||
|
createBufferSource: vi.fn(() => {
|
||||||
|
const source = {
|
||||||
|
buffer: null,
|
||||||
|
onended: null as (() => void) | null,
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
start: vi.fn((at: number) => starts.push(at)),
|
||||||
|
};
|
||||||
|
sources.push(source);
|
||||||
|
return source;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("AudioContext", function AudioContextMock() {
|
||||||
|
return context;
|
||||||
|
});
|
||||||
|
|
||||||
|
const player = new VoiceCallPlayer();
|
||||||
|
const gaps: number[] = [];
|
||||||
|
player.onGap = (milliseconds) => gaps.push(milliseconds);
|
||||||
|
const meta = {
|
||||||
|
callId: "call-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
index: 0,
|
||||||
|
mimeType: "audio/mpeg",
|
||||||
|
byteLength: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
player.enqueue(meta, new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
player.enqueue({ ...meta, index: 1 }, new Uint8Array([4, 5, 6]).buffer);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(starts).toHaveLength(2));
|
||||||
|
expect(starts).toEqual([0.025, 1.025]);
|
||||||
|
expect(gaps).toEqual([0]);
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
player.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
|
export default function LegacyCallPage() {
|
||||||
|
redirect(getCharacterRoutes(DEFAULT_CHARACTER_SLUG).call);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CallAudioChunkMeta } from "@/core/net/voice-call-transport";
|
||||||
|
|
||||||
|
export class VoiceCallPlayer {
|
||||||
|
private context: AudioContext | null = null;
|
||||||
|
private sources = new Set<AudioBufferSourceNode>();
|
||||||
|
private decodeChain: Promise<void> = Promise.resolve();
|
||||||
|
private scheduledUntil = 0;
|
||||||
|
private pendingDecodes = 0;
|
||||||
|
private generation = 0;
|
||||||
|
onPlaying: (() => void) | null = null;
|
||||||
|
onIdle: (() => void) | null = null;
|
||||||
|
onGap: ((milliseconds: number) => void) | null = null;
|
||||||
|
onError: ((error: Error) => void) | null = null;
|
||||||
|
|
||||||
|
get isPlaying(): boolean {
|
||||||
|
return this.sources.size > 0 || this.pendingDecodes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepare(): Promise<void> {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (context.state === "suspended") await context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(_meta: CallAudioChunkMeta, bytes: ArrayBuffer): void {
|
||||||
|
const generation = this.generation;
|
||||||
|
this.pendingDecodes += 1;
|
||||||
|
this.decodeChain = this.decodeChain
|
||||||
|
.then(async () => {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (context.state === "suspended") await context.resume();
|
||||||
|
const audioBuffer = await context.decodeAudioData(bytes.slice(0));
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
this.schedule(context, audioBuffer);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
this.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.pendingDecodes = Math.max(0, this.pendingDecodes - 1);
|
||||||
|
this.notifyIdleIfNeeded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.generation += 1;
|
||||||
|
for (const source of this.sources) {
|
||||||
|
source.onended = null;
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {
|
||||||
|
// 已自然结束的 source 无需重复停止。
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
}
|
||||||
|
this.sources.clear();
|
||||||
|
this.scheduledUntil = 0;
|
||||||
|
this.onIdle?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop();
|
||||||
|
const context = this.context;
|
||||||
|
this.context = null;
|
||||||
|
if (context && context.state !== "closed") void context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContext(): AudioContext {
|
||||||
|
if (!this.context || this.context.state === "closed") {
|
||||||
|
this.context = new AudioContext({ latencyHint: "interactive" });
|
||||||
|
}
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(context: AudioContext, buffer: AudioBuffer): void {
|
||||||
|
const leadSeconds = 0.025;
|
||||||
|
const earliestStart = context.currentTime + leadSeconds;
|
||||||
|
const hadPreviousSegment = this.scheduledUntil > 0;
|
||||||
|
const startAt = Math.max(earliestStart, this.scheduledUntil);
|
||||||
|
if (hadPreviousSegment) {
|
||||||
|
this.onGap?.(Math.max(0, (startAt - this.scheduledUntil) * 1000));
|
||||||
|
}
|
||||||
|
const source = context.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(context.destination);
|
||||||
|
this.sources.add(source);
|
||||||
|
this.scheduledUntil = startAt + buffer.duration;
|
||||||
|
source.onended = () => {
|
||||||
|
source.disconnect();
|
||||||
|
this.sources.delete(source);
|
||||||
|
this.notifyIdleIfNeeded();
|
||||||
|
};
|
||||||
|
source.start(startAt);
|
||||||
|
this.onPlaying?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyIdleIfNeeded(): void {
|
||||||
|
if (this.sources.size || this.pendingDecodes) return;
|
||||||
|
this.scheduledUntil = 0;
|
||||||
|
this.onIdle?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--app-viewport-height, 100dvh);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #fff;
|
||||||
|
background: #090810;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background { object-fit: cover; opacity: .48; }
|
||||||
|
.scrim { position: absolute; inset: 0; background: radial-gradient(circle at 50% 32%, rgba(239,83,155,.18), transparent 36%), linear-gradient(180deg, rgba(9,8,16,.28), rgba(9,8,16,.94)); }
|
||||||
|
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: calc(18px + var(--app-safe-top, 0px)) 20px 12px; }
|
||||||
|
.iconButton { display: inline-flex; width: 42px; height: 42px; cursor: pointer; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.16); border-radius: 999px; color: #fff; background: rgba(10,9,18,.55); backdrop-filter: blur(14px); }
|
||||||
|
.balances { display: flex; gap: 8px; font-size: 12px; font-weight: 650; }
|
||||||
|
.balances span { padding: 7px 10px; border: 1px solid rgba(255,255,255,.12); border-radius: 999px; background: rgba(10,9,18,.58); backdrop-filter: blur(14px); }
|
||||||
|
.content { position: relative; z-index: 1; display: flex; min-height: 0; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 24px 28px; text-align: center; }
|
||||||
|
.avatarRing { position: relative; display: grid; width: 174px; height: 174px; place-items: center; border-radius: 50%; background: linear-gradient(135deg, rgba(255,255,255,.24), rgba(239,83,155,.42)); box-shadow: 0 28px 72px rgba(0,0,0,.42); transition: transform .25s ease, box-shadow .25s ease; }
|
||||||
|
.avatarRing::after { position: absolute; inset: -10px; border: 1px solid rgba(255,255,255,.16); border-radius: inherit; content: ""; }
|
||||||
|
.avatarRing.listening, .avatarRing.speaking { animation: callPulse 1.8s ease-in-out infinite; box-shadow: 0 0 0 12px rgba(239,83,155,.08), 0 28px 72px rgba(0,0,0,.42); }
|
||||||
|
.avatar { width: 158px; height: 158px; border: 4px solid rgba(9,8,16,.8); border-radius: 50%; object-fit: cover; }
|
||||||
|
.content h1 { margin: 30px 0 8px; font-size: clamp(27px, 7vw, 36px); letter-spacing: -.025em; }
|
||||||
|
.state { display: flex; min-height: 28px; align-items: center; gap: 7px; color: rgba(255,255,255,.78); font-size: 15px; }
|
||||||
|
.lowBalance { margin: 12px 0 0; padding: 7px 11px; border-radius: 999px; color: #ffd6e8; background: rgba(210,48,121,.2); font-size: 12px; }
|
||||||
|
.transcript { width: min(100%, 440px); min-height: 105px; margin-top: 24px; text-align: left; }
|
||||||
|
.transcript p { margin: 0 0 10px; padding: 11px 13px; border: 1px solid rgba(255,255,255,.09); border-radius: 15px; color: rgba(255,255,255,.9); background: rgba(14,12,24,.48); font-size: 14px; line-height: 1.45; backdrop-filter: blur(12px); }
|
||||||
|
.transcript span { display: block; margin-bottom: 4px; color: #f3a3c8; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||||
|
.error { max-width: 360px; margin: 8px 0 0; color: #ffc2ca; font-size: 13px; }
|
||||||
|
.footer { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; padding: 10px 24px calc(24px + var(--app-safe-bottom, 0px)); }
|
||||||
|
.footer p { margin: 12px 0 0; color: rgba(255,255,255,.52); font-size: 12px; }
|
||||||
|
.startButton { display: inline-flex; min-width: 170px; cursor: pointer; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 999px; padding: 15px 24px; color: #fff; background: linear-gradient(135deg, #ee579d, #ce357c); box-shadow: 0 16px 36px rgba(212,54,127,.32); font-size: 15px; font-weight: 750; }
|
||||||
|
.endButton { display: inline-flex; width: 66px; height: 66px; cursor: pointer; align-items: center; justify-content: center; border: 0; border-radius: 50%; color: #fff; background: #e24456; box-shadow: 0 15px 34px rgba(226,68,86,.34); }
|
||||||
|
.topUpBackdrop { position: absolute; z-index: 10; inset: 0; display: flex; align-items: flex-end; justify-content: center; padding: 18px; background: rgba(5,4,9,.68); backdrop-filter: blur(8px); }
|
||||||
|
.topUpDialog { width: min(100%, 460px); padding: 24px; border: 1px solid rgba(255,255,255,.12); border-radius: 24px; background: #17131f; box-shadow: 0 24px 70px rgba(0,0,0,.5); text-align: center; }
|
||||||
|
.topUpDialog h2 { margin: 0; font-size: 21px; }
|
||||||
|
.topUpDialog p { margin: 10px 0 20px; color: rgba(255,255,255,.66); font-size: 14px; line-height: 1.5; }
|
||||||
|
.topUpDialog button { width: 100%; cursor: pointer; border: 0; border-radius: 999px; padding: 13px 18px; color: #fff; background: #df478d; font-weight: 750; }
|
||||||
|
.topUpDialog .secondaryButton { margin-top: 8px; color: rgba(255,255,255,.72); background: transparent; }
|
||||||
|
|
||||||
|
@keyframes callPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.025); } }
|
||||||
|
|
||||||
|
@media (max-height: 700px) {
|
||||||
|
.avatarRing { width: 138px; height: 138px; }
|
||||||
|
.avatar { width: 124px; height: 124px; }
|
||||||
|
.content h1 { margin-top: 20px; }
|
||||||
|
.transcript { min-height: 72px; margin-top: 16px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Mic, PhoneOff, RotateCcw, Volume2, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
import { getApiConfig } from "@/core/net/config/api_config";
|
||||||
|
import {
|
||||||
|
VoiceCallTransport,
|
||||||
|
type CallBillingStatusPayload,
|
||||||
|
} from "@/core/net/voice-call-transport";
|
||||||
|
import { VoiceCallAudioCapture } from "@/integrations/voice-call-audio";
|
||||||
|
import { getSessionAccessToken } from "@/lib/auth/auth_session";
|
||||||
|
import { useActiveCharacter, useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
|
import { VoiceCallPlayer } from "./voice-call-player";
|
||||||
|
import styles from "./voice-call-screen.module.css";
|
||||||
|
|
||||||
|
type CallUiState = "idle" | "connecting" | "listening" | "thinking" | "speaking" | "ended" | "error";
|
||||||
|
|
||||||
|
const STATE_COPY: Record<CallUiState, string> = {
|
||||||
|
idle: "Ready to call",
|
||||||
|
connecting: "Connecting…",
|
||||||
|
listening: "Listening…",
|
||||||
|
thinking: "Thinking…",
|
||||||
|
speaking: "Speaking…",
|
||||||
|
ended: "Call ended",
|
||||||
|
error: "Call unavailable",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getSessionToken(): Promise<string> {
|
||||||
|
const token = await getSessionAccessToken();
|
||||||
|
if (token) return token;
|
||||||
|
throw new Error("Please sign in before starting a call");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceCallScreen() {
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const routes = useActiveCharacterRoutes();
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
const [uiState, setUiState] = useState<CallUiState>("idle");
|
||||||
|
const [userTranscript, setUserTranscript] = useState("");
|
||||||
|
const [aiTranscript, setAiTranscript] = useState("");
|
||||||
|
const [freeTurns, setFreeTurns] = useState(0);
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
const [cost, setCost] = useState(2);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showTopUp, setShowTopUp] = useState(false);
|
||||||
|
const transportRef = useRef<VoiceCallTransport | null>(null);
|
||||||
|
const captureRef = useRef<VoiceCallAudioCapture | null>(null);
|
||||||
|
const playerRef = useRef<VoiceCallPlayer | null>(null);
|
||||||
|
const callIdRef = useRef("");
|
||||||
|
const activeTurnRef = useRef("");
|
||||||
|
const reconnectTurnRef = useRef("");
|
||||||
|
const resumeVersionRef = useRef(0);
|
||||||
|
const endingRef = useRef(false);
|
||||||
|
const pendingTopUpRef = useRef(false);
|
||||||
|
|
||||||
|
const finishCall = useCallback((navigate = true) => {
|
||||||
|
if (endingRef.current) return;
|
||||||
|
endingRef.current = true;
|
||||||
|
captureRef.current?.dispose();
|
||||||
|
playerRef.current?.dispose();
|
||||||
|
if (callIdRef.current) transportRef.current?.endCall(callIdRef.current);
|
||||||
|
transportRef.current?.disconnect();
|
||||||
|
reconnectTurnRef.current = "";
|
||||||
|
resumeVersionRef.current = 0;
|
||||||
|
setUiState("ended");
|
||||||
|
if (navigate) navigator.push(routes.chat);
|
||||||
|
}, [navigator, routes.chat]);
|
||||||
|
|
||||||
|
const beginListening = useCallback((turnId: string) => {
|
||||||
|
const capture = captureRef.current;
|
||||||
|
const transport = transportRef.current;
|
||||||
|
const callId = callIdRef.current;
|
||||||
|
if (!capture || !transport || !callId || !turnId) return;
|
||||||
|
activeTurnRef.current = turnId;
|
||||||
|
reconnectTurnRef.current = turnId;
|
||||||
|
setUiState(playerRef.current?.isPlaying ? "speaking" : "listening");
|
||||||
|
const preRoll: ArrayBuffer[] = [];
|
||||||
|
let streamOpened = false;
|
||||||
|
capture.startTurn({
|
||||||
|
onChunk: (chunk) => {
|
||||||
|
if (streamOpened) {
|
||||||
|
transport.sendAudioChunk(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preRoll.push(chunk);
|
||||||
|
if (preRoll.length > 5) preRoll.shift();
|
||||||
|
},
|
||||||
|
onSpeechStart: () => {
|
||||||
|
setError(null);
|
||||||
|
if (playerRef.current?.isPlaying) {
|
||||||
|
playerRef.current.stop();
|
||||||
|
transport.interrupt(callId);
|
||||||
|
}
|
||||||
|
streamOpened = transport.startAudio(
|
||||||
|
callId,
|
||||||
|
turnId,
|
||||||
|
capture.mimeType,
|
||||||
|
capture.sampleRate,
|
||||||
|
);
|
||||||
|
if (streamOpened) {
|
||||||
|
for (const chunk of preRoll) transport.sendAudioChunk(chunk);
|
||||||
|
preRoll.length = 0;
|
||||||
|
setUserTranscript("");
|
||||||
|
setAiTranscript("");
|
||||||
|
setUiState("listening");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTurnEnd: (vadLatencyMs) => {
|
||||||
|
if (streamOpened) {
|
||||||
|
transport.endAudio(callId, turnId, vadLatencyMs);
|
||||||
|
setUiState("thinking");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (captureError) => {
|
||||||
|
setError(captureError.message);
|
||||||
|
setUiState("error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyBilling = useCallback((status: CallBillingStatusPayload) => {
|
||||||
|
setBalance(status.creditBalance);
|
||||||
|
setFreeTurns(status.freeTurnsRemaining);
|
||||||
|
setCost(status.requiredCredits || 2);
|
||||||
|
if (status.canContinue && status.nextTurnId) {
|
||||||
|
beginListening(status.nextTurnId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureRef.current?.stopTurn();
|
||||||
|
pendingTopUpRef.current = true;
|
||||||
|
if (!playerRef.current?.isPlaying) setShowTopUp(true);
|
||||||
|
}, [beginListening]);
|
||||||
|
|
||||||
|
const startCall = useCallback(async () => {
|
||||||
|
if (uiState !== "idle" && uiState !== "error") return;
|
||||||
|
endingRef.current = false;
|
||||||
|
pendingTopUpRef.current = false;
|
||||||
|
setError(null);
|
||||||
|
setUiState("connecting");
|
||||||
|
try {
|
||||||
|
const player = new VoiceCallPlayer();
|
||||||
|
playerRef.current = player;
|
||||||
|
const playerReady = player.prepare();
|
||||||
|
const capture = new VoiceCallAudioCapture();
|
||||||
|
await Promise.all([capture.prepare(), playerReady]);
|
||||||
|
captureRef.current = capture;
|
||||||
|
const token = await getSessionToken();
|
||||||
|
const transport = new VoiceCallTransport(getApiConfig().wsUrl, token);
|
||||||
|
transportRef.current = transport;
|
||||||
|
player.onPlaying = () => setUiState("speaking");
|
||||||
|
player.onIdle = () => {
|
||||||
|
if (pendingTopUpRef.current) setShowTopUp(true);
|
||||||
|
else if (activeTurnRef.current) setUiState("listening");
|
||||||
|
};
|
||||||
|
player.onError = (playbackError) => setError(playbackError.message);
|
||||||
|
player.onGap = (milliseconds) => {
|
||||||
|
transport.sendClientMetric("audioChunkGap", milliseconds);
|
||||||
|
};
|
||||||
|
transport.onOpen = () => {
|
||||||
|
if (!reconnectTurnRef.current) reconnectTurnRef.current = crypto.randomUUID();
|
||||||
|
transport.startCall(
|
||||||
|
character.id,
|
||||||
|
reconnectTurnRef.current,
|
||||||
|
resumeVersionRef.current || undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
transport.onReconnecting = () => {
|
||||||
|
capture.stopTurn();
|
||||||
|
player.stop();
|
||||||
|
callIdRef.current = "";
|
||||||
|
setUiState("connecting");
|
||||||
|
};
|
||||||
|
transport.onStarted = (started) => {
|
||||||
|
callIdRef.current = started.callId;
|
||||||
|
activeTurnRef.current = started.turnId;
|
||||||
|
reconnectTurnRef.current = started.turnId;
|
||||||
|
resumeVersionRef.current = started.resumeVersion;
|
||||||
|
setFreeTurns(started.freeTurnsRemaining);
|
||||||
|
setBalance(started.creditBalance);
|
||||||
|
setCost(started.costPerPaidTurn || 2);
|
||||||
|
beginListening(started.turnId);
|
||||||
|
};
|
||||||
|
transport.onState = setUiState;
|
||||||
|
transport.onTranscript = (text) => setUserTranscript(text);
|
||||||
|
transport.onAiSentence = (text) => {
|
||||||
|
setAiTranscript((current) => `${current}${current ? " " : ""}${text}`);
|
||||||
|
};
|
||||||
|
transport.onAudioChunk = (meta, bytes) => player.enqueue(meta, bytes);
|
||||||
|
transport.onBillingStatus = applyBilling;
|
||||||
|
transport.onEnded = () => setUiState("ended");
|
||||||
|
transport.onError = (code, message) => {
|
||||||
|
setError(message);
|
||||||
|
if (
|
||||||
|
activeTurnRef.current
|
||||||
|
&& (code === "CALL_TURN_FAILED" || code === "CALL_STT_FAILED")
|
||||||
|
) {
|
||||||
|
setUiState("listening");
|
||||||
|
} else {
|
||||||
|
setUiState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
transport.connect();
|
||||||
|
} catch (startError) {
|
||||||
|
captureRef.current?.dispose();
|
||||||
|
playerRef.current?.dispose();
|
||||||
|
setError(startError instanceof Error ? startError.message : String(startError));
|
||||||
|
setUiState("error");
|
||||||
|
}
|
||||||
|
}, [applyBilling, beginListening, character.id, uiState]);
|
||||||
|
|
||||||
|
useEffect(() => () => finishCall(false), [finishCall]);
|
||||||
|
|
||||||
|
const lowBalance = freeTurns === 0 && balance < cost * 2 && balance >= cost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileShell background="#090810">
|
||||||
|
<main className={styles.screen}>
|
||||||
|
<Image src={character.assets.chatBackground} alt="" fill priority className={styles.background} />
|
||||||
|
<div className={styles.scrim} />
|
||||||
|
<header className={styles.header}>
|
||||||
|
<button type="button" className={styles.iconButton} onClick={() => finishCall()} aria-label="Close call">
|
||||||
|
<X size={22} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className={styles.balances}>
|
||||||
|
<span>{freeTurns} free</span>
|
||||||
|
<span>{balance} credits</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.content} aria-live="polite">
|
||||||
|
<div className={`${styles.avatarRing} ${styles[uiState]}`}>
|
||||||
|
<Image src={character.assets.avatar} alt={character.displayName} width={148} height={148} className={styles.avatar} priority />
|
||||||
|
</div>
|
||||||
|
<h1>{character.displayName}</h1>
|
||||||
|
<div className={styles.state}>
|
||||||
|
{uiState === "listening" ? <Mic size={18} aria-hidden="true" /> : null}
|
||||||
|
{uiState === "speaking" ? <Volume2 size={18} aria-hidden="true" /> : null}
|
||||||
|
<span>{STATE_COPY[uiState]}</span>
|
||||||
|
</div>
|
||||||
|
{lowBalance ? <p className={styles.lowBalance}>Your balance covers fewer than two paid turns.</p> : null}
|
||||||
|
<div className={styles.transcript}>
|
||||||
|
{userTranscript ? <p><span>You</span>{userTranscript}</p> : null}
|
||||||
|
{aiTranscript ? <p><span>{character.shortName}</span>{aiTranscript}</p> : null}
|
||||||
|
</div>
|
||||||
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
{uiState === "idle" || uiState === "error" ? (
|
||||||
|
<button type="button" className={styles.startButton} onClick={() => void startCall()}>
|
||||||
|
{uiState === "error" ? <RotateCcw size={20} aria-hidden="true" /> : <Mic size={20} aria-hidden="true" />}
|
||||||
|
{uiState === "error" ? "Try again" : "Start call"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={styles.endButton} onClick={() => finishCall()} aria-label="End call">
|
||||||
|
<PhoneOff size={27} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p>Speak naturally — you can interrupt at any time.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{showTopUp ? (
|
||||||
|
<div className={styles.topUpBackdrop} role="presentation">
|
||||||
|
<section className={styles.topUpDialog} role="dialog" aria-modal="true" aria-labelledby="call-topup-title">
|
||||||
|
<h2 id="call-topup-title">Not enough credits for another turn</h2>
|
||||||
|
<p>Your current reply has finished. Top up to keep the conversation going.</p>
|
||||||
|
<button type="button" onClick={() => navigator.openSubscription({ type: "topup", returnTo: "chat" })}>Top up credits</button>
|
||||||
|
<button type="button" className={styles.secondaryButton} onClick={() => finishCall()}>End call</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { VoiceCallScreen } from "@/app/call/voice-call-screen";
|
||||||
|
|
||||||
|
export default function CharacterCallPage() {
|
||||||
|
return <VoiceCallScreen />;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export default async function CharacterTipPage({
|
|||||||
const initialPlanId = normalizeTipGiftParam(
|
const initialPlanId = normalizeTipGiftParam(
|
||||||
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
|
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
|
||||||
);
|
);
|
||||||
|
const chatActionId = getFirstPaymentSearchParam(query.chatActionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TipScreen
|
<TipScreen
|
||||||
@@ -30,6 +31,7 @@ export default async function CharacterTipPage({
|
|||||||
initialPlanId={initialPlanId}
|
initialPlanId={initialPlanId}
|
||||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||||
initialPayChannel={paymentReturn.initialPayChannel}
|
initialPayChannel={paymentReturn.initialPayChannel}
|
||||||
|
chatActionId={chatActionId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ChatAction } from "@/data/schemas/chat";
|
||||||
|
import type { PaymentOrderStatusResponse } from "@/data/schemas/payment";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveChatActionTarget,
|
||||||
|
type ChatActionNavigationContext,
|
||||||
|
} from "../chat-action-navigation";
|
||||||
|
|
||||||
|
const baseAction: ChatAction = {
|
||||||
|
actionId: "action-1",
|
||||||
|
kind: "support",
|
||||||
|
type: "openProfile",
|
||||||
|
copy: "Open it here.",
|
||||||
|
ctaLabel: "Open",
|
||||||
|
ruleId: null,
|
||||||
|
orderId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(
|
||||||
|
order: Partial<PaymentOrderStatusResponse> = {},
|
||||||
|
): ChatActionNavigationContext {
|
||||||
|
const orderResponse: PaymentOrderStatusResponse = {
|
||||||
|
orderId: "order-1",
|
||||||
|
status: "pending",
|
||||||
|
orderType: "dol",
|
||||||
|
planId: "topup-100",
|
||||||
|
creditsAdded: 0,
|
||||||
|
...order,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
characterRoutes: {
|
||||||
|
splash: "/characters/elio/splash",
|
||||||
|
chat: "/characters/elio/chat",
|
||||||
|
call: "/characters/elio/call",
|
||||||
|
privateZone: "/characters/elio/private-zone",
|
||||||
|
tip: "/characters/elio/tip",
|
||||||
|
},
|
||||||
|
characterSlug: "elio",
|
||||||
|
profileUrl: "/profile?returnTo=chat",
|
||||||
|
feedbackUrl: "/feedback?returnTo=chat",
|
||||||
|
coinsRulesUrl: "/coins-rules?returnTo=chat",
|
||||||
|
getOrderStatus: vi.fn(async () => orderResponse),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveChatActionTarget", () => {
|
||||||
|
it.each([
|
||||||
|
["giftOffer", "/characters/elio/tip?chatActionId=action-1"],
|
||||||
|
["privateAlbumOffer", "/characters/elio/private-zone"],
|
||||||
|
["openProfile", "/profile?returnTo=chat"],
|
||||||
|
["openWallet", "/profile?returnTo=chat"],
|
||||||
|
["openCoinsRules", "/coins-rules?returnTo=chat"],
|
||||||
|
["openFeedback", "/feedback?returnTo=chat"],
|
||||||
|
] as const)("maps %s to an allowlisted internal page", async (type, expected) => {
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget({ ...baseAction, type }, createContext()),
|
||||||
|
).resolves.toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps VIP and top-up actions to typed subscription routes", async () => {
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(
|
||||||
|
{ ...baseAction, kind: "commercial", type: "activateVip" },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).resolves.toBe(
|
||||||
|
"/subscription?type=vip&returnTo=chat&character=elio&chatActionId=action-1",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(
|
||||||
|
{ ...baseAction, kind: "commercial", type: "topUp" },
|
||||||
|
createContext(),
|
||||||
|
),
|
||||||
|
).resolves.toBe(
|
||||||
|
"/subscription?type=topup&returnTo=chat&character=elio&chatActionId=action-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes a pending VIP order using its owned order id", async () => {
|
||||||
|
const action: ChatAction = {
|
||||||
|
...baseAction,
|
||||||
|
type: "resumePayment",
|
||||||
|
orderId: "order-1",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(
|
||||||
|
action,
|
||||||
|
createContext({ orderType: "vip_monthly" }),
|
||||||
|
),
|
||||||
|
).resolves.toBe(
|
||||||
|
"/subscription?type=vip&returnTo=chat&character=elio&resumeOrderId=order-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resume an order that is no longer pending", async () => {
|
||||||
|
const action: ChatAction = {
|
||||||
|
...baseAction,
|
||||||
|
type: "resumePayment",
|
||||||
|
orderId: "order-1",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(action, createContext({ status: "paid" })),
|
||||||
|
).resolves.toBe("/profile?returnTo=chat");
|
||||||
|
await expect(
|
||||||
|
resolveChatActionTarget(action, createContext({ status: "expired" })),
|
||||||
|
).resolves.toBe("/feedback?returnTo=chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { deriveChatSupportCta } from "../chat-support-cta";
|
||||||
|
|
||||||
|
describe("deriveChatSupportCta", () => {
|
||||||
|
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||||
|
|
||||||
|
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: true,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "firstRecharge",
|
||||||
|
label: "Support me · 50% OFF · First recharge",
|
||||||
|
commercialOfferId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server-anchored 24-hour role offer countdown", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "maya-tan",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "commercialOffer",
|
||||||
|
label: "Support me · 30% OFF · 23:59:59",
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to Support me after an offer expires or is absent", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: null,
|
||||||
|
nowMs: now,
|
||||||
|
}).label,
|
||||||
|
).toBe("Support me");
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-27T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}).label,
|
||||||
|
).toBe("Support me");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ChatAction } from "@/data/schemas/chat";
|
||||||
|
import type { PaymentOrderStatusResponse } from "@/data/schemas/payment";
|
||||||
|
import type { CharacterRoutes } from "@/router/routes";
|
||||||
|
import { appendRouteSearchParams, ROUTE_BUILDERS } from "@/router/routes";
|
||||||
|
|
||||||
|
export interface ChatActionNavigationContext {
|
||||||
|
characterRoutes: CharacterRoutes;
|
||||||
|
characterSlug: string;
|
||||||
|
profileUrl: string;
|
||||||
|
feedbackUrl: string;
|
||||||
|
coinsRulesUrl: string;
|
||||||
|
getOrderStatus: (orderId: string) => Promise<PaymentOrderStatusResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve only application-owned, allowlisted destinations for a chat action. */
|
||||||
|
export async function resolveChatActionTarget(
|
||||||
|
action: ChatAction,
|
||||||
|
context: ChatActionNavigationContext,
|
||||||
|
): Promise<string> {
|
||||||
|
switch (action.type) {
|
||||||
|
case "giftOffer":
|
||||||
|
return appendRouteSearchParams(context.characterRoutes.tip, {
|
||||||
|
chatActionId: action.actionId,
|
||||||
|
});
|
||||||
|
case "privateAlbumOffer":
|
||||||
|
return context.characterRoutes.privateZone;
|
||||||
|
case "openProfile":
|
||||||
|
case "openWallet":
|
||||||
|
return context.profileUrl;
|
||||||
|
case "openCoinsRules":
|
||||||
|
return context.coinsRulesUrl;
|
||||||
|
case "activateVip":
|
||||||
|
return ROUTE_BUILDERS.subscription("vip", {
|
||||||
|
sourceCharacterSlug: context.characterSlug,
|
||||||
|
returnTo: "chat",
|
||||||
|
chatActionId: action.actionId,
|
||||||
|
});
|
||||||
|
case "topUp":
|
||||||
|
return ROUTE_BUILDERS.subscription("topup", {
|
||||||
|
sourceCharacterSlug: context.characterSlug,
|
||||||
|
returnTo: "chat",
|
||||||
|
chatActionId: action.actionId,
|
||||||
|
});
|
||||||
|
case "openFeedback":
|
||||||
|
return context.feedbackUrl;
|
||||||
|
case "resumePayment": {
|
||||||
|
if (!action.orderId) return context.feedbackUrl;
|
||||||
|
const order = await context.getOrderStatus(action.orderId);
|
||||||
|
if (order.status === "paid") return context.profileUrl;
|
||||||
|
if (order.status !== "pending") return context.feedbackUrl;
|
||||||
|
if (order.orderType === "tip") return context.characterRoutes.tip;
|
||||||
|
return ROUTE_BUILDERS.subscription(
|
||||||
|
order.orderType === "vip" || order.orderType.startsWith("vip_")
|
||||||
|
? "vip"
|
||||||
|
: "topup",
|
||||||
|
{
|
||||||
|
sourceCharacterSlug: context.characterSlug,
|
||||||
|
returnTo: "chat",
|
||||||
|
resumeOrderId: action.orderId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, type CSSProperties } from "react";
|
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
@@ -17,7 +17,14 @@ import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
|||||||
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
|
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
|
||||||
import { buildGlobalPageUrl } from "@/router/global-route-context";
|
import { buildGlobalPageUrl } from "@/router/global-route-context";
|
||||||
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
|
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
|
||||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||||
|
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
||||||
|
import { paymentApi } from "@/data/services/api";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
import {
|
||||||
|
recordChatActionEvent,
|
||||||
|
rememberChatActionArrival,
|
||||||
|
} from "@/lib/chat/chat_action_events";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -26,8 +33,8 @@ import {
|
|||||||
ChatHeader,
|
ChatHeader,
|
||||||
ChatInputBar,
|
ChatInputBar,
|
||||||
ChatInsufficientCreditsBanner,
|
ChatInsufficientCreditsBanner,
|
||||||
|
ChatSupportButton,
|
||||||
ChatUnlockDialogs,
|
ChatUnlockDialogs,
|
||||||
FirstRechargeOfferBanner,
|
|
||||||
FullscreenImageViewer,
|
FullscreenImageViewer,
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
@@ -38,14 +45,15 @@ import {
|
|||||||
} from "./chat-image-overlay-url";
|
} from "./chat-image-overlay-url";
|
||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
shouldStartExternalBrowserPrompt,
|
|
||||||
} from "./chat-screen.helpers";
|
} from "./chat-screen.helpers";
|
||||||
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
|
import { useChatSupportCta } from "./hooks/use-chat-support-cta";
|
||||||
|
import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages";
|
||||||
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
||||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||||
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
||||||
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
|
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
|
||||||
|
import { resolveChatActionTarget } from "./chat-action-navigation";
|
||||||
|
|
||||||
const chatShellStyle = {
|
const chatShellStyle = {
|
||||||
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
|
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
|
||||||
@@ -63,11 +71,21 @@ export function ChatScreen() {
|
|||||||
ROUTES.profile,
|
ROUTES.profile,
|
||||||
characterRoutes.chat,
|
characterRoutes.chat,
|
||||||
);
|
);
|
||||||
|
const feedbackUrl = buildGlobalPageUrl(
|
||||||
|
ROUTES.feedback,
|
||||||
|
characterRoutes.chat,
|
||||||
|
);
|
||||||
|
const coinsRulesUrl = buildGlobalPageUrl(
|
||||||
|
ROUTES.coinsRules,
|
||||||
|
characterRoutes.chat,
|
||||||
|
);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
|
const imageOpenedFromChatRef = useRef(false);
|
||||||
|
const imageViewerClosingRef = useRef(false);
|
||||||
useSplashLatestMessageSync({
|
useSplashLatestMessageSync({
|
||||||
characterId: state.characterId,
|
characterId: state.characterId,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
@@ -111,19 +129,19 @@ export function ChatScreen() {
|
|||||||
const messageLimitBanner = useChatMessageLimitBanner({
|
const messageLimitBanner = useChatMessageLimitBanner({
|
||||||
upgradePromptVisible: state.upgradePromptVisible,
|
upgradePromptVisible: state.upgradePromptVisible,
|
||||||
upgradeReason: state.upgradeReason,
|
upgradeReason: state.upgradeReason,
|
||||||
|
paymentGuidance: state.paymentGuidance,
|
||||||
});
|
});
|
||||||
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
const supportCta = useChatSupportCta({
|
||||||
|
characterId: character.id,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
useChatCommercialMessages({
|
||||||
|
characterId: character.id,
|
||||||
|
loginStatus: authState.loginStatus,
|
||||||
|
});
|
||||||
const shouldShowPwaInstall =
|
const shouldShowPwaInstall =
|
||||||
state.historyLoaded && state.historyMessages.length >= 10;
|
state.historyLoaded && state.historyMessages.length >= 10;
|
||||||
const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({
|
|
||||||
hasInitialized: authState.hasInitialized,
|
|
||||||
isLoading: authState.isLoading,
|
|
||||||
loginStatus: authState.loginStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
useChatGuestLogin({
|
useChatGuestLogin({
|
||||||
dispatch: authDispatch,
|
dispatch: authDispatch,
|
||||||
hasInitialized: authState.hasInitialized,
|
hasInitialized: authState.hasInitialized,
|
||||||
@@ -131,6 +149,13 @@ export function ChatScreen() {
|
|||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageMessageId) {
|
||||||
|
imageOpenedFromChatRef.current = false;
|
||||||
|
imageViewerClosingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [imageMessageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!imageMessageId ||
|
!imageMessageId ||
|
||||||
@@ -222,6 +247,7 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenImage(displayMessageId: string): void {
|
function handleOpenImage(displayMessageId: string): void {
|
||||||
|
imageOpenedFromChatRef.current = true;
|
||||||
router.push(
|
router.push(
|
||||||
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
@@ -229,6 +255,13 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCloseImageViewer(): void {
|
function handleCloseImageViewer(): void {
|
||||||
|
if (imageViewerClosingRef.current) return;
|
||||||
|
imageViewerClosingRef.current = true;
|
||||||
|
if (imageOpenedFromChatRef.current) {
|
||||||
|
imageOpenedFromChatRef.current = false;
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.replace(
|
router.replace(
|
||||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
@@ -257,6 +290,124 @@ export function ChatScreen() {
|
|||||||
router.push(characterRoutes.privateZone);
|
router.push(characterRoutes.privateZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCommercialAction(action: CommercialAction): Promise<void> {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.commercial_action.open",
|
||||||
|
action.ctaLabel,
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
target: action.target,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (action.target === "discountConsent") {
|
||||||
|
const offer = await paymentApi.acceptCommercialOffer(action.actionId);
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.commercial_action.accepted",
|
||||||
|
action.ctaLabel,
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
planId: offer.planId,
|
||||||
|
discountPercent: offer.discountPercent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.push(
|
||||||
|
ROUTE_BUILDERS.subscription(offer.subscriptionType, {
|
||||||
|
sourceCharacterSlug: character.slug,
|
||||||
|
returnTo: "chat",
|
||||||
|
planId: offer.planId,
|
||||||
|
commercialOfferId: offer.commercialOfferId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(
|
||||||
|
action.target === "giftCatalog"
|
||||||
|
? characterRoutes.tip
|
||||||
|
: characterRoutes.privateZone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommercialActionDismiss(action: CommercialAction): void {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.commercial_action.dismiss",
|
||||||
|
"Dismiss commercial action",
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatActionViewed(action: ChatAction): void {
|
||||||
|
void recordChatActionEvent(action, state.characterId, "viewed").catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChatAction(action: ChatAction): Promise<void> {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.action.open",
|
||||||
|
action.ctaLabel,
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionKind: action.kind,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await recordChatActionEvent(
|
||||||
|
action,
|
||||||
|
state.characterId,
|
||||||
|
"opened",
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
const targetUrl = await resolveChatActionTarget(action, {
|
||||||
|
characterRoutes,
|
||||||
|
characterSlug: character.slug,
|
||||||
|
profileUrl,
|
||||||
|
feedbackUrl,
|
||||||
|
coinsRulesUrl,
|
||||||
|
getOrderStatus: (orderId) => paymentApi.getOrderStatus(orderId),
|
||||||
|
});
|
||||||
|
const authenticatedTarget = resolveAuthenticatedNavigation({
|
||||||
|
loginStatus: authState.loginStatus,
|
||||||
|
targetUrl,
|
||||||
|
});
|
||||||
|
rememberChatActionArrival(
|
||||||
|
action,
|
||||||
|
state.characterId,
|
||||||
|
targetUrl,
|
||||||
|
);
|
||||||
|
router.push(authenticatedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatActionDismiss(action: ChatAction): void {
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"chat.action.dismiss",
|
||||||
|
"Dismiss chat action",
|
||||||
|
{
|
||||||
|
actionId: action.actionId,
|
||||||
|
actionKind: action.kind,
|
||||||
|
actionType: action.type,
|
||||||
|
characterId: state.characterId,
|
||||||
|
ruleId: action.ruleId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
void recordChatActionEvent(
|
||||||
|
action,
|
||||||
|
state.characterId,
|
||||||
|
"dismissed",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div
|
<div
|
||||||
@@ -276,14 +427,14 @@ export function ChatScreen() {
|
|||||||
<div className="relative z-1 flex min-h-0 flex-[1_1_auto] flex-col">
|
<div className="relative z-1 flex min-h-0 flex-[1_1_auto] flex-col">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
showBrowserHint={shouldShowBrowserHint}
|
|
||||||
offerBanner={
|
offerBanner={
|
||||||
<FirstRechargeOfferBanner
|
supportCta.visible ? (
|
||||||
visible={firstRechargeOfferBanner.visible}
|
<ChatSupportButton
|
||||||
discountPercent={firstRechargeOfferBanner.discountPercent}
|
kind={supportCta.kind}
|
||||||
onClick={firstRechargeOfferBanner.claim}
|
label={supportCta.label}
|
||||||
onClose={firstRechargeOfferBanner.close}
|
onClick={supportCta.open}
|
||||||
/>
|
/>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -305,6 +456,11 @@ export function ChatScreen() {
|
|||||||
onOpenImage={handleOpenImage}
|
onOpenImage={handleOpenImage}
|
||||||
onUserAvatarClick={handleOpenUserProfile}
|
onUserAvatarClick={handleOpenUserProfile}
|
||||||
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
|
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
|
||||||
|
onCommercialAction={handleCommercialAction}
|
||||||
|
onCommercialActionDismiss={handleCommercialActionDismiss}
|
||||||
|
onChatActionViewed={handleChatActionViewed}
|
||||||
|
onChatAction={handleChatAction}
|
||||||
|
onChatActionDismiss={handleChatActionDismiss}
|
||||||
onLoadMoreHistory={() => {
|
onLoadMoreHistory={() => {
|
||||||
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
|
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
|
||||||
}}
|
}}
|
||||||
@@ -315,6 +471,7 @@ export function ChatScreen() {
|
|||||||
title={messageLimitBanner.title}
|
title={messageLimitBanner.title}
|
||||||
description={messageLimitBanner.description}
|
description={messageLimitBanner.description}
|
||||||
ctaLabel={messageLimitBanner.ctaLabel}
|
ctaLabel={messageLimitBanner.ctaLabel}
|
||||||
|
guidance={messageLimitBanner.guidance}
|
||||||
onUnlock={messageLimitBanner.unlock}
|
onUnlock={messageLimitBanner.unlock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CommercialOfferSummary } from "@/data/schemas/payment";
|
||||||
|
|
||||||
|
export type ChatSupportCtaKind =
|
||||||
|
| "support"
|
||||||
|
| "firstRecharge"
|
||||||
|
| "commercialOffer";
|
||||||
|
|
||||||
|
export interface ChatSupportCtaView {
|
||||||
|
kind: ChatSupportCtaKind;
|
||||||
|
label: string;
|
||||||
|
commercialOfferId: string | null;
|
||||||
|
planId: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveChatSupportCta(input: {
|
||||||
|
isFirstRecharge: boolean;
|
||||||
|
commercialOffer: CommercialOfferSummary | null;
|
||||||
|
nowMs: number;
|
||||||
|
}): ChatSupportCtaView {
|
||||||
|
if (input.isFirstRecharge) {
|
||||||
|
return {
|
||||||
|
kind: "firstRecharge",
|
||||||
|
label: "Support me · 50% OFF · First recharge",
|
||||||
|
commercialOfferId: null,
|
||||||
|
planId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = input.commercialOffer;
|
||||||
|
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
|
||||||
|
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
|
||||||
|
return {
|
||||||
|
kind: "commercialOffer",
|
||||||
|
label: `Support me · ${offer.discountPercent}% OFF · ${formatOfferCountdown(expiresAtMs, input.nowMs)}`,
|
||||||
|
commercialOfferId: offer.commercialOfferId,
|
||||||
|
planId: offer.planId,
|
||||||
|
expiresAt: offer.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "support",
|
||||||
|
label: "Support me",
|
||||||
|
commercialOfferId: null,
|
||||||
|
planId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOfferCountdown(expiresAtMs: number, nowMs: number): string {
|
||||||
|
const totalSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((expiresAtMs - nowMs) / 1000) - 1,
|
||||||
|
);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds]
|
||||||
|
.map((value) => String(value).padStart(2, "0"))
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ChatActionCard } from "../chat-action-card";
|
||||||
|
|
||||||
|
describe("ChatActionCard", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports one view, opens the action and can be dismissed", async () => {
|
||||||
|
const onViewed = vi.fn();
|
||||||
|
const onActivate = vi.fn(async () => undefined);
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const action = {
|
||||||
|
actionId: "action-1",
|
||||||
|
kind: "support" as const,
|
||||||
|
type: "openFeedback" as const,
|
||||||
|
copy: "Share the screenshot and approximate payment time here.",
|
||||||
|
ctaLabel: "Open Feedback",
|
||||||
|
ruleId: null,
|
||||||
|
orderId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ChatActionCard
|
||||||
|
action={action}
|
||||||
|
onViewed={onViewed}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onViewed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.querySelector("[data-chat-action='openFeedback']"))
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const openButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent?.includes("Open Feedback"),
|
||||||
|
);
|
||||||
|
await act(async () => openButton?.click());
|
||||||
|
expect(onActivate).toHaveBeenCalledWith(action);
|
||||||
|
|
||||||
|
const dismissButton = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Dismiss"]',
|
||||||
|
);
|
||||||
|
act(() => dismissButton?.click());
|
||||||
|
expect(onDismiss).toHaveBeenCalledWith(action);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,6 +124,36 @@ describe("ChatInputBar", () => {
|
|||||||
expect(getButton("Open chat actions")).not.toBeNull();
|
expect(getButton("Open chat actions")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses the Facebook WebView click that follows a pointer send for 300ms", () => {
|
||||||
|
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||||
|
renderBar();
|
||||||
|
setTextareaValue(getTextarea(), "Hello Maya");
|
||||||
|
const sendButton = getButton("Send message");
|
||||||
|
const pointerDown = new Event("pointerdown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(pointerDown, "pointerType", { value: "touch" });
|
||||||
|
|
||||||
|
act(() => sendButton.dispatchEvent(pointerDown));
|
||||||
|
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "ChatSendMessage",
|
||||||
|
content: "Hello Maya",
|
||||||
|
});
|
||||||
|
expect(getTextarea().value).toBe("");
|
||||||
|
expect(getButton("Open chat actions")).toBe(sendButton);
|
||||||
|
|
||||||
|
now.mockReturnValue(1_299);
|
||||||
|
act(() => sendButton.click());
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
|
||||||
|
now.mockReturnValue(1_300);
|
||||||
|
act(() => sendButton.click());
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes the menu on outside interaction and when disabled", () => {
|
it("closes the menu on outside interaction and when disabled", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
act(() => getButton("Open chat actions").click());
|
act(() => getButton("Open chat actions").click());
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { CommercialActionCard } from "../commercial-action-card";
|
||||||
|
|
||||||
|
describe("CommercialActionCard", () => {
|
||||||
|
it("renders a private-zone offer with a usable CTA", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CommercialActionCard
|
||||||
|
action={{
|
||||||
|
actionId: "action-1",
|
||||||
|
type: "privateAlbumOffer",
|
||||||
|
copy: "There are more private photos waiting for you.",
|
||||||
|
ctaLabel: "Open private zone",
|
||||||
|
target: "privateZone",
|
||||||
|
ruleId: "private_album_after_appearance_praise",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('data-commercial-action="privateAlbumOffer"');
|
||||||
|
expect(html).toContain("There are more private photos waiting for you.");
|
||||||
|
expect(html).toContain("Open private zone");
|
||||||
|
expect(html).toContain('aria-label="Dismiss offer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a consent-first discount action without a checkout URL", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CommercialActionCard
|
||||||
|
action={{
|
||||||
|
actionId: "offer-1",
|
||||||
|
type: "discountOffer",
|
||||||
|
copy: "Want me to ask for my best private offer?",
|
||||||
|
ctaLabel: "Yes, ask for me",
|
||||||
|
target: "discountConsent",
|
||||||
|
ruleId: "discount_after_price_objection",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('data-commercial-action="discountOffer"');
|
||||||
|
expect(html).toContain("Yes, ask for me");
|
||||||
|
expect(html).not.toContain("/subscription");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type Mock,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
|
||||||
|
import { FullscreenImageViewer } from "../fullscreen-image-viewer";
|
||||||
|
|
||||||
|
describe("FullscreenImageViewer", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let onClose: Mock<() => void>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
onClose = vi.fn<() => void>();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FullscreenImageViewer
|
||||||
|
characterId="elio-silvestri"
|
||||||
|
remoteMessageId="message-1"
|
||||||
|
imageUrl="/chat-image.png"
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes exactly once when the fullscreen image is tapped", () => {
|
||||||
|
const image = container.querySelector("img");
|
||||||
|
expect(image).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => image?.click());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes exactly once from the visible back button", () => {
|
||||||
|
const backButton = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label="Back"]',
|
||||||
|
);
|
||||||
|
expect(backButton).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => backButton?.click());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes from the backdrop and Escape key", () => {
|
||||||
|
const viewer = container.querySelector<HTMLDivElement>(
|
||||||
|
'[aria-label="Fullscreen image"]',
|
||||||
|
);
|
||||||
|
expect(viewer).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => viewer?.click());
|
||||||
|
act(() =>
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import { ImageBubble } from "../image-bubble";
|
|||||||
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
|
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
|
||||||
import { MessageAvatar } from "../message-avatar";
|
import { MessageAvatar } from "../message-avatar";
|
||||||
import { PrivateMessageCard } from "../private-message-card";
|
import { PrivateMessageCard } from "../private-message-card";
|
||||||
|
import { PaymentGuidanceCard } from "../payment-guidance-card";
|
||||||
import { PwaInstallDialog } from "../pwa-install-dialog";
|
import { PwaInstallDialog } from "../pwa-install-dialog";
|
||||||
import { TextBubble } from "../text-bubble";
|
import { TextBubble } from "../text-bubble";
|
||||||
|
|
||||||
@@ -223,6 +224,19 @@ describe("chat Tailwind components", () => {
|
|||||||
description="Credits keep the conversation going."
|
description="Credits keep the conversation going."
|
||||||
ctaLabel="Top up"
|
ctaLabel="Top up"
|
||||||
onUnlock={() => undefined}
|
onUnlock={() => undefined}
|
||||||
|
guidance={{
|
||||||
|
guidanceId: "guidance-elio",
|
||||||
|
characterId: "elio",
|
||||||
|
characterName: "Elio Silvestri",
|
||||||
|
scene: "insufficientCredits",
|
||||||
|
mode: "guide",
|
||||||
|
title: "Keep talking with Elio",
|
||||||
|
copy: "Baby, I want to keep talking with you. Adding credits supports Elio.",
|
||||||
|
ctaLabel: "View VIP & credit options",
|
||||||
|
purchaseOptions: ["vip", "topup"],
|
||||||
|
target: "subscription",
|
||||||
|
ruleId: "payment_guidance_insufficientCredits",
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,22 +246,61 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(html).toContain("<br/>");
|
expect(html).toContain("<br/>");
|
||||||
expect(html).toContain("Top up now");
|
expect(html).toContain("Top up now");
|
||||||
expect(html).toContain("Credits keep the conversation going.");
|
expect(html).toContain("Credits keep the conversation going.");
|
||||||
|
expect(html).toContain("supports Elio");
|
||||||
|
expect(html).toContain('data-payment-guidance-id="guidance-elio"');
|
||||||
expect(html).toContain('aria-label="Top up"');
|
expect(html).toContain('aria-label="Top up"');
|
||||||
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
||||||
expect(html).toContain("active:scale-98");
|
expect(html).toContain("active:scale-98");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders guidance only for the active character and hides care-only CTA", () => {
|
||||||
|
const guide = {
|
||||||
|
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
|
||||||
|
characterId: "elio",
|
||||||
|
characterName: "Elio Silvestri",
|
||||||
|
scene: "insufficientCredits" as const,
|
||||||
|
mode: "guide" as const,
|
||||||
|
title: "Keep talking with Elio",
|
||||||
|
copy: "Baby, I want to keep talking with you.",
|
||||||
|
ctaLabel: "View VIP & credit options",
|
||||||
|
purchaseOptions: ["vip", "topup"] as const,
|
||||||
|
target: "subscription" as const,
|
||||||
|
ruleId: "payment_guidance_insufficientCredits",
|
||||||
|
};
|
||||||
|
const guideHtml = renderWithCharacter(
|
||||||
|
<PaymentGuidanceCard guidance={guide} />,
|
||||||
|
);
|
||||||
|
const mismatchHtml = renderWithCharacter(
|
||||||
|
<PaymentGuidanceCard
|
||||||
|
guidance={{ ...guide, characterId: "maya-tan", characterName: "Maya Tan" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const careHtml = renderWithCharacter(
|
||||||
|
<PaymentGuidanceCard
|
||||||
|
guidance={{
|
||||||
|
...guide,
|
||||||
|
mode: "careOnly",
|
||||||
|
title: null,
|
||||||
|
ctaLabel: null,
|
||||||
|
purchaseOptions: [],
|
||||||
|
target: null,
|
||||||
|
copy: "Please keep money you need for food or medicine.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(guideHtml).toContain('data-payment-guidance-mode="guide"');
|
||||||
|
expect(guideHtml).toContain("View VIP & credit options");
|
||||||
|
expect(mismatchHtml).toBe("");
|
||||||
|
expect(careHtml).toContain("food or medicine");
|
||||||
|
expect(careHtml).not.toContain("View VIP");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders ChatHeader guest and member branches", () => {
|
it("renders ChatHeader guest and member branches", () => {
|
||||||
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
|
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
|
||||||
const memberHtml = renderWithCharacter(
|
const memberHtml = renderWithCharacter(
|
||||||
<ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />,
|
<ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />,
|
||||||
);
|
);
|
||||||
const memberWithHintHtml = renderWithCharacter(
|
|
||||||
<ChatHeader isGuest={false} showBrowserHint />,
|
|
||||||
);
|
|
||||||
const guestWithHintHtml = renderWithCharacter(
|
|
||||||
<ChatHeader isGuest={true} showBrowserHint />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(guestHtml).toContain('aria-label="Sign up to unlock more features"');
|
expect(guestHtml).toContain('aria-label="Sign up to unlock more features"');
|
||||||
expect(guestHtml).toContain("bg-accent");
|
expect(guestHtml).toContain("bg-accent");
|
||||||
@@ -257,50 +310,33 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
|
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
|
||||||
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
|
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
|
||||||
expect(guestHtml).not.toContain('aria-label="Profile"');
|
expect(guestHtml).not.toContain('aria-label="Profile"');
|
||||||
|
expect(guestHtml).toContain('aria-label="Save CozSweet"');
|
||||||
expect(memberHtml).toContain("Offer");
|
expect(memberHtml).toContain("Offer");
|
||||||
expect(memberHtml).toContain('href="/characters/elio/splash"');
|
expect(memberHtml).toContain('href="/characters/elio/splash"');
|
||||||
expect(memberHtml).toContain('aria-label="Back to home"');
|
expect(memberHtml).toContain('aria-label="Back to home"');
|
||||||
expect(memberHtml).toContain('aria-label="Profile"');
|
expect(memberHtml).toContain('aria-label="Save CozSweet"');
|
||||||
expect(memberHtml).toContain('data-analytics-key="chat.open_profile"');
|
expect(memberHtml).toContain('data-analytics-key="favorite.favorite"');
|
||||||
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
|
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
|
||||||
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
|
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
|
||||||
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
|
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
|
||||||
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
|
expect(memberHtml).toContain(
|
||||||
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
|
|
||||||
expect(memberHtml).toContain("lucide-user-round");
|
|
||||||
expect(memberWithHintHtml).toContain(
|
|
||||||
'data-analytics-key="chat.external_browser_hint"',
|
|
||||||
);
|
|
||||||
expect(memberWithHintHtml).toContain(
|
|
||||||
"grid-cols-[auto_minmax(0,1fr)_auto]",
|
"grid-cols-[auto_minmax(0,1fr)_auto]",
|
||||||
);
|
);
|
||||||
expect(memberWithHintHtml.indexOf('aria-label="Back to home"')).toBeLessThan(
|
expect(memberHtml).not.toContain(
|
||||||
memberWithHintHtml.indexOf(
|
|
||||||
'data-analytics-key="chat.external_browser_hint"',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
memberWithHintHtml.indexOf(
|
|
||||||
'data-analytics-key="chat.external_browser_hint"',
|
|
||||||
),
|
|
||||||
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"'));
|
|
||||||
expect(guestWithHintHtml).not.toContain(
|
|
||||||
'data-analytics-key="chat.external_browser_hint"',
|
'data-analytics-key="chat.external_browser_hint"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the user avatar in the ChatHeader Profile action", () => {
|
it("replaces the ChatHeader Profile avatar with the favorite action", () => {
|
||||||
userMocks.avatarUrl = "/images/avatar/profile-user.png";
|
userMocks.avatarUrl = "/images/avatar/profile-user.png";
|
||||||
|
|
||||||
const html = renderWithCharacter(<ChatHeader isGuest={false} />);
|
const html = renderWithCharacter(<ChatHeader isGuest={false} />);
|
||||||
|
|
||||||
expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png");
|
expect(html).not.toContain("%2Fimages%2Favatar%2Fprofile-user.png");
|
||||||
expect(html).toContain('aria-label="Profile"');
|
expect(html).not.toContain('aria-label="Profile"');
|
||||||
expect(html).toContain('data-analytics-key="chat.open_profile"');
|
expect(html).not.toContain('data-analytics-key="chat.open_profile"');
|
||||||
expect(html).toContain(
|
expect(html).toContain('aria-label="Save CozSweet"');
|
||||||
"var(--responsive-icon-button-size, 42px)",
|
expect(html).toContain("lucide-star");
|
||||||
);
|
|
||||||
expect(html).not.toContain("lucide-user-round");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("links guest chat back to the active character splash", () => {
|
it("links guest chat back to the active character splash", () => {
|
||||||
@@ -315,6 +351,7 @@ describe("chat Tailwind components", () => {
|
|||||||
|
|
||||||
expect(html).toContain('href="/characters/maya/splash"');
|
expect(html).toContain('href="/characters/maya/splash"');
|
||||||
expect(html).not.toContain('aria-label="Profile"');
|
expect(html).not.toContain('aria-label="Profile"');
|
||||||
|
expect(html).toContain('aria-label="Save CozSweet"');
|
||||||
expect(html).not.toContain(
|
expect(html).not.toContain(
|
||||||
'data-analytics-key="chat.external_browser_hint"',
|
'data-analytics-key="chat.external_browser_hint"',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CircleDollarSign,
|
||||||
|
CircleHelp,
|
||||||
|
Gift,
|
||||||
|
Images,
|
||||||
|
LoaderCircle,
|
||||||
|
MessageSquareWarning,
|
||||||
|
UserRound,
|
||||||
|
WalletCards,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { ChatAction } from "@/data/schemas/chat";
|
||||||
|
|
||||||
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
|
const ACTION_ICONS: Record<ChatAction["type"], LucideIcon> = {
|
||||||
|
giftOffer: Gift,
|
||||||
|
privateAlbumOffer: Images,
|
||||||
|
openProfile: UserRound,
|
||||||
|
openWallet: WalletCards,
|
||||||
|
openCoinsRules: WalletCards,
|
||||||
|
activateVip: CircleDollarSign,
|
||||||
|
topUp: CircleDollarSign,
|
||||||
|
openFeedback: MessageSquareWarning,
|
||||||
|
resumePayment: CircleHelp,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatActionCardProps {
|
||||||
|
action: ChatAction;
|
||||||
|
onViewed?: (action: ChatAction) => void;
|
||||||
|
onActivate?: (action: ChatAction) => void | Promise<void>;
|
||||||
|
onDismiss?: (action: ChatAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatActionCard({
|
||||||
|
action,
|
||||||
|
onViewed,
|
||||||
|
onActivate,
|
||||||
|
onDismiss,
|
||||||
|
}: ChatActionCardProps) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [activating, setActivating] = useState(false);
|
||||||
|
const [activationError, setActivationError] = useState<string | null>(null);
|
||||||
|
const viewedActionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewedActionIdRef.current === action.actionId) return;
|
||||||
|
viewedActionIdRef.current = action.actionId;
|
||||||
|
onViewed?.(action);
|
||||||
|
}, [action, onViewed]);
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
const Icon = ACTION_ICONS[action.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={styles.commercialAction}
|
||||||
|
aria-label={action.ctaLabel}
|
||||||
|
data-chat-action={action.type}
|
||||||
|
data-chat-action-kind={action.kind}
|
||||||
|
>
|
||||||
|
<div className={styles.commercialActionHeader}>
|
||||||
|
<Icon size={18} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionDismiss}
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onDismiss?.(action);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.commercialActionCopy}>{action.copy}</p>
|
||||||
|
{activationError ? (
|
||||||
|
<p className={styles.commercialActionError} role="alert">
|
||||||
|
{activationError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionButton}
|
||||||
|
disabled={activating}
|
||||||
|
onClick={() => {
|
||||||
|
setActivating(true);
|
||||||
|
setActivationError(null);
|
||||||
|
void Promise.resolve(onActivate?.(action))
|
||||||
|
.catch(() =>
|
||||||
|
setActivationError(
|
||||||
|
"This option is unavailable right now. Please try again.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => setActivating(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{action.ctaLabel}</span>
|
||||||
|
{activating ? (
|
||||||
|
<LoaderCircle
|
||||||
|
className={styles.commercialActionSpinner}
|
||||||
|
size={17}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ArrowRight size={17} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,6 +131,84 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commercialAction {
|
||||||
|
width: min(100%, 286px);
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(25, 21, 31, 0.92);
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionHeader {
|
||||||
|
display: flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #f4b860;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionDismiss {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.66);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionCopy {
|
||||||
|
margin: 7px 0 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: var(--responsive-caption, var(--font-size-sm, 13px));
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionButton {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 38px;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f4b860;
|
||||||
|
color: #201710;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--responsive-caption, var(--font-size-sm, 13px));
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionButton span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionButton:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionError {
|
||||||
|
margin: -4px 0 10px;
|
||||||
|
color: #ffb4ab;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commercialActionSpinner {
|
||||||
|
animation: commercial-action-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes commercial-action-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.bubbleAi {
|
.bubbleAi {
|
||||||
max-width: var(--chat-bubble-max-width, 75%);
|
max-width: var(--chat-bubble-max-width, 75%);
|
||||||
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||||
|
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
||||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +62,11 @@ export interface ChatAreaProps {
|
|||||||
onLoadMoreHistory?: () => void;
|
onLoadMoreHistory?: () => void;
|
||||||
onUserAvatarClick?: () => void;
|
onUserAvatarClick?: () => void;
|
||||||
onCharacterAvatarClick?: () => void;
|
onCharacterAvatarClick?: () => void;
|
||||||
|
onCommercialAction?: (action: CommercialAction) => void;
|
||||||
|
onCommercialActionDismiss?: (action: CommercialAction) => void;
|
||||||
|
onChatActionViewed?: (action: ChatAction) => void;
|
||||||
|
onChatAction?: (action: ChatAction) => void | Promise<void>;
|
||||||
|
onChatActionDismiss?: (action: ChatAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessageAction = (
|
type ChatMessageAction = (
|
||||||
@@ -85,6 +91,11 @@ export function ChatArea({
|
|||||||
onLoadMoreHistory,
|
onLoadMoreHistory,
|
||||||
onUserAvatarClick,
|
onUserAvatarClick,
|
||||||
onCharacterAvatarClick,
|
onCharacterAvatarClick,
|
||||||
|
onCommercialAction,
|
||||||
|
onCommercialActionDismiss,
|
||||||
|
onChatActionViewed,
|
||||||
|
onChatAction,
|
||||||
|
onChatActionDismiss,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -257,6 +268,11 @@ export function ChatArea({
|
|||||||
onOpenImage,
|
onOpenImage,
|
||||||
onUserAvatarClick,
|
onUserAvatarClick,
|
||||||
onCharacterAvatarClick,
|
onCharacterAvatarClick,
|
||||||
|
onCommercialAction,
|
||||||
|
onCommercialActionDismiss,
|
||||||
|
onChatActionViewed,
|
||||||
|
onChatAction,
|
||||||
|
onChatActionDismiss,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplyingAI ? (
|
{isReplyingAI ? (
|
||||||
@@ -295,6 +311,11 @@ function renderMessagesWithDateHeaders(
|
|||||||
onOpenImage?: (displayMessageId: string) => void,
|
onOpenImage?: (displayMessageId: string) => void,
|
||||||
onUserAvatarClick?: () => void,
|
onUserAvatarClick?: () => void,
|
||||||
onCharacterAvatarClick?: () => void,
|
onCharacterAvatarClick?: () => void,
|
||||||
|
onCommercialAction?: (action: CommercialAction) => void,
|
||||||
|
onCommercialActionDismiss?: (action: CommercialAction) => void,
|
||||||
|
onChatActionViewed?: (action: ChatAction) => void,
|
||||||
|
onChatAction?: (action: ChatAction) => void | Promise<void>,
|
||||||
|
onChatActionDismiss?: (action: ChatAction) => void,
|
||||||
) {
|
) {
|
||||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||||
item.type === "date" ? (
|
item.type === "date" ? (
|
||||||
@@ -314,6 +335,9 @@ function renderMessagesWithDateHeaders(
|
|||||||
lockReason={item.message.lockReason}
|
lockReason={item.message.lockReason}
|
||||||
lockedPrivate={item.message.lockedPrivate}
|
lockedPrivate={item.message.lockedPrivate}
|
||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
|
commercialAction={item.message.commercialAction}
|
||||||
|
chatAction={item.message.chatAction}
|
||||||
|
paymentGuidance={item.message.paymentGuidance}
|
||||||
isUnlockingMessage={
|
isUnlockingMessage={
|
||||||
isUnlockingMessage === true &&
|
isUnlockingMessage === true &&
|
||||||
item.message.displayId === unlockingMessageId
|
item.message.displayId === unlockingMessageId
|
||||||
@@ -324,6 +348,11 @@ function renderMessagesWithDateHeaders(
|
|||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
onUserAvatarClick={onUserAvatarClick}
|
onUserAvatarClick={onUserAvatarClick}
|
||||||
onCharacterAvatarClick={onCharacterAvatarClick}
|
onCharacterAvatarClick={onCharacterAvatarClick}
|
||||||
|
onCommercialAction={onCommercialAction}
|
||||||
|
onCommercialActionDismiss={onCommercialActionDismiss}
|
||||||
|
onChatActionViewed={onChatActionViewed}
|
||||||
|
onChatAction={onChatAction}
|
||||||
|
onChatActionDismiss={onChatActionDismiss}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,37 +2,32 @@
|
|||||||
/**
|
/**
|
||||||
* ChatHeader 顶部栏
|
* ChatHeader 顶部栏
|
||||||
*
|
*
|
||||||
* Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />。
|
* 顶部保持返回、首充优惠、收藏入口三段结构。
|
||||||
*/
|
*/
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Lock, UserRound } from "lucide-react";
|
import { Lock, Phone } from "lucide-react";
|
||||||
|
|
||||||
import { BackButton, UserMessageAvatar } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
import { FavoriteEntryButton } from "@/app/_components/core";
|
||||||
import { buildGlobalPageUrl } from "@/router/global-route-context";
|
import {
|
||||||
import { ROUTES } from "@/router/routes";
|
useActiveCharacter,
|
||||||
|
useActiveCharacterRoutes,
|
||||||
|
} from "@/providers/character-provider";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
|
||||||
|
|
||||||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
|
||||||
|
|
||||||
export interface ChatHeaderProps {
|
export interface ChatHeaderProps {
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
offerBanner?: ReactNode;
|
offerBanner?: ReactNode;
|
||||||
showBrowserHint?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
isGuest,
|
isGuest,
|
||||||
offerBanner,
|
offerBanner,
|
||||||
showBrowserHint = false,
|
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const character = useActiveCharacter();
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
|
const voiceCallEnabled = process.env.NEXT_PUBLIC_ENABLE_VOICE_CALL === "true";
|
||||||
const handleOpenProfile = () => {
|
|
||||||
navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
||||||
@@ -52,17 +47,9 @@ export function ChatHeader({
|
|||||||
/>
|
/>
|
||||||
<span>Sign up to unlock more features</span>
|
<span>Sign up to unlock more features</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
offerBanner
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--chat-inline-padding,16px) py-(--spacing-sm,8px)">
|
||||||
className={
|
|
||||||
isGuest
|
|
||||||
? "flex items-center px-(--chat-inline-padding,16px) py-(--spacing-sm,8px)"
|
|
||||||
: "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--chat-inline-padding,16px) py-(--spacing-sm,8px)"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BackButton
|
<BackButton
|
||||||
href={characterRoutes.splash}
|
href={characterRoutes.splash}
|
||||||
variant="dark"
|
variant="dark"
|
||||||
@@ -70,34 +57,26 @@ export function ChatHeader({
|
|||||||
analyticsKey="chat.back_to_home"
|
analyticsKey="chat.back_to_home"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isGuest ? (
|
<div className="flex min-w-0 justify-center">{offerBanner}</div>
|
||||||
<>
|
|
||||||
<div className="flex min-w-0 justify-center">
|
|
||||||
{showBrowserHint ? <BrowserHintOverlay /> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{avatarUrl ? (
|
<div className="flex items-center gap-2">
|
||||||
<UserMessageAvatar
|
{voiceCallEnabled ? (
|
||||||
avatarUrl={avatarUrl}
|
<button
|
||||||
size="var(--responsive-icon-button-size, 42px)"
|
type="button"
|
||||||
actionLabel="Profile"
|
className="inline-flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-white shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition hover:bg-[rgba(28,24,39,0.82)] active:scale-96"
|
||||||
analyticsKey="chat.open_profile"
|
aria-label={`Call ${character.shortName}`}
|
||||||
onClick={handleOpenProfile}
|
data-analytics-key="chat.start_voice_call"
|
||||||
/>
|
data-analytics-label="Start voice call"
|
||||||
) : (
|
onClick={() => navigator.push(characterRoutes.call)}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Phone size={19} strokeWidth={2.3} aria-hidden="true" />
|
||||||
data-analytics-key="chat.open_profile"
|
</button>
|
||||||
data-analytics-label="Open profile"
|
) : null}
|
||||||
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
<FavoriteEntryButton
|
||||||
onClick={handleOpenProfile}
|
characterSlug={character.slug}
|
||||||
aria-label="Profile"
|
tone="dark"
|
||||||
>
|
/>
|
||||||
<UserRound size={24} aria-hidden="true" />
|
</div>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ChatSendButton } from "./chat-send-button";
|
|||||||
import styles from "./chat-input-bar.module.css";
|
import styles from "./chat-input-bar.module.css";
|
||||||
|
|
||||||
const log = new Logger("AppChatComponentsChatInputBar");
|
const log = new Logger("AppChatComponentsChatInputBar");
|
||||||
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
|
const POINTER_SEND_DEBOUNCE_MS = 300;
|
||||||
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
|
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
|
||||||
|
|
||||||
export interface ChatInputBarProps {
|
export interface ChatInputBarProps {
|
||||||
@@ -82,7 +82,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
|
const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
|
||||||
if (source === "click") {
|
if (source === "click") {
|
||||||
const elapsed = Date.now() - lastPointerSendAtRef.current;
|
const elapsed = Date.now() - lastPointerSendAtRef.current;
|
||||||
if (elapsed >= 0 && elapsed < POINTER_SEND_CLICK_DEDUPE_MS) {
|
if (elapsed >= 0 && elapsed < POINTER_SEND_DEBOUNCE_MS) {
|
||||||
log.debug("[chat-input-bar] suppress duplicate click", {
|
log.debug("[chat-input-bar] suppress duplicate click", {
|
||||||
elapsed,
|
elapsed,
|
||||||
contentLength: input.length,
|
contentLength: input.length,
|
||||||
@@ -115,6 +115,15 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
|
|
||||||
const handleMenuToggle = () => {
|
const handleMenuToggle = () => {
|
||||||
if (disabled || hasContent) return;
|
if (disabled || hasContent) return;
|
||||||
|
const elapsed = Date.now() - lastPointerSendAtRef.current;
|
||||||
|
if (elapsed >= 0 && elapsed < POINTER_SEND_DEBOUNCE_MS) {
|
||||||
|
log.debug("[chat-input-bar] suppress post-send menu toggle", {
|
||||||
|
elapsed,
|
||||||
|
disabled,
|
||||||
|
isFocused,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isActionMenuOpen) textareaRef.current?.blur();
|
if (!isActionMenuOpen) textareaRef.current?.blur();
|
||||||
setIsActionMenuOpen((open) => !open);
|
setIsActionMenuOpen((open) => !open);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
||||||
*/
|
*/
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
|
||||||
export interface ChatInsufficientCreditsBannerProps {
|
export interface ChatInsufficientCreditsBannerProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
ctaLabel?: string;
|
ctaLabel?: string | null;
|
||||||
|
guidance?: PaymentGuidance | null;
|
||||||
/**
|
/**
|
||||||
* 自定义点击回调(不传则默认走统一订阅导航)
|
* 自定义点击回调(不传则默认走统一订阅导航)
|
||||||
* - 测试时可传 mock
|
* - 测试时可传 mock
|
||||||
@@ -34,9 +36,11 @@ export function ChatInsufficientCreditsBanner({
|
|||||||
title = "Insufficient credits\nTop up to continue chatting",
|
title = "Insufficient credits\nTop up to continue chatting",
|
||||||
description,
|
description,
|
||||||
ctaLabel = "Top up credits to continue",
|
ctaLabel = "Top up credits to continue",
|
||||||
|
guidance = null,
|
||||||
onUnlock,
|
onUnlock,
|
||||||
}: ChatInsufficientCreditsBannerProps) {
|
}: ChatInsufficientCreditsBannerProps) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const activeGuidance = guidance;
|
||||||
const titleLines = title.split("\n");
|
const titleLines = title.split("\n");
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -53,6 +57,7 @@ export function ChatInsufficientCreditsBanner({
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
data-testid="chat-insufficient-credits-banner"
|
data-testid="chat-insufficient-credits-banner"
|
||||||
|
data-payment-guidance-id={activeGuidance?.guidanceId}
|
||||||
>
|
>
|
||||||
<p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95">
|
<p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95">
|
||||||
{titleLines.map((line, index) => (
|
{titleLines.map((line, index) => (
|
||||||
@@ -67,7 +72,12 @@ export function ChatInsufficientCreditsBanner({
|
|||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
{activeGuidance?.copy ? (
|
||||||
|
<p className="mx-auto mb-(--spacing-lg,16px) mt-0 max-w-[min(100%,360px)] text-(length:--responsive-caption,14px) font-semibold leading-[1.4] text-white">
|
||||||
|
{activeGuidance.copy}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ctaLabel ? <button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="chat.topup"
|
data-analytics-key="chat.topup"
|
||||||
data-analytics-label="Top up chat credits"
|
data-analytics-label="Top up chat credits"
|
||||||
@@ -76,7 +86,7 @@ export function ChatInsufficientCreditsBanner({
|
|||||||
aria-label={ctaLabel}
|
aria-label={ctaLabel}
|
||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</button>
|
</button> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Heart, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import type { ChatSupportCtaKind } from "../chat-support-cta";
|
||||||
|
|
||||||
|
export interface ChatSupportButtonProps {
|
||||||
|
kind: ChatSupportCtaKind;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSupportButton({
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: ChatSupportButtonProps) {
|
||||||
|
const Icon = kind === "support" ? Heart : Sparkles;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key="chat.support_character"
|
||||||
|
data-support-state={kind}
|
||||||
|
className="inline-flex min-h-9 max-w-full items-center justify-center gap-1.5 rounded-full border border-[rgba(255,255,255,0.22)] bg-[linear-gradient(135deg,rgba(246,87,160,0.96),rgba(255,119,84,0.94))] px-3 py-1.5 text-center text-[12px] font-extrabold leading-tight text-white shadow-[0_8px_24px_rgba(246,87,160,0.3)] backdrop-blur-md transition active:scale-95"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon className="shrink-0" size={14} strokeWidth={2.5} aria-hidden="true" />
|
||||||
|
<span className="whitespace-normal text-balance">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export function ChatUnlockDialogs({
|
|||||||
lockedCount={model.historyUnlock.lockedCount}
|
lockedCount={model.historyUnlock.lockedCount}
|
||||||
isLoading={model.historyUnlock.isLoading}
|
isLoading={model.historyUnlock.isLoading}
|
||||||
errorMessage={model.historyUnlock.errorMessage}
|
errorMessage={model.historyUnlock.errorMessage}
|
||||||
|
guidance={model.historyUnlock.guidance}
|
||||||
onClose={model.closeHistoryUnlock}
|
onClose={model.closeHistoryUnlock}
|
||||||
onConfirm={model.confirmHistoryUnlock}
|
onConfirm={model.confirmHistoryUnlock}
|
||||||
/>
|
/>
|
||||||
@@ -27,6 +28,7 @@ export function ChatUnlockDialogs({
|
|||||||
creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
|
creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
|
||||||
requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
|
requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
|
||||||
shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
|
shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
|
||||||
|
guidance={model.unlockPaywallRequest?.paymentGuidance ?? null}
|
||||||
onClose={model.closePaywall}
|
onClose={model.closePaywall}
|
||||||
onConfirm={model.confirmPaywall}
|
onConfirm={model.confirmPaywall}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight, BadgePercent, Gift, Images, LoaderCircle, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { CommercialAction } from "@/data/schemas/chat";
|
||||||
|
|
||||||
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
|
export interface CommercialActionCardProps {
|
||||||
|
action: CommercialAction;
|
||||||
|
onActivate?: (action: CommercialAction) => void | Promise<void>;
|
||||||
|
onDismiss?: (action: CommercialAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommercialActionCard({
|
||||||
|
action,
|
||||||
|
onActivate,
|
||||||
|
onDismiss,
|
||||||
|
}: CommercialActionCardProps) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [activating, setActivating] = useState(false);
|
||||||
|
const [activationError, setActivationError] = useState<string | null>(null);
|
||||||
|
if (dismissed) return null;
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
action.type === "giftOffer"
|
||||||
|
? Gift
|
||||||
|
: action.type === "discountOffer"
|
||||||
|
? BadgePercent
|
||||||
|
: Images;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={styles.commercialAction}
|
||||||
|
aria-label={action.ctaLabel}
|
||||||
|
data-commercial-action={action.type}
|
||||||
|
>
|
||||||
|
<div className={styles.commercialActionHeader}>
|
||||||
|
<Icon size={18} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionDismiss}
|
||||||
|
title="Dismiss offer"
|
||||||
|
aria-label="Dismiss offer"
|
||||||
|
onClick={() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onDismiss?.(action);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.commercialActionCopy}>{action.copy}</p>
|
||||||
|
{activationError ? (
|
||||||
|
<p className={styles.commercialActionError} role="alert">
|
||||||
|
{activationError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionButton}
|
||||||
|
disabled={activating}
|
||||||
|
onClick={() => {
|
||||||
|
setActivating(true);
|
||||||
|
setActivationError(null);
|
||||||
|
void Promise.resolve(onActivate?.(action))
|
||||||
|
.catch(() => setActivationError("This private offer is unavailable right now. Please try again."))
|
||||||
|
.finally(() => setActivating(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{action.ctaLabel}</span>
|
||||||
|
{activating ? (
|
||||||
|
<LoaderCircle className={styles.commercialActionSpinner} size={17} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ArrowRight size={17} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,8 +121,74 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compactButton {
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 240px);
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 240px;
|
||||||
|
min-height: 50px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.68), transparent 38%),
|
||||||
|
linear-gradient(135deg, #fff0b8 0%, #ffd0df 50%, #ff6cab 100%);
|
||||||
|
color: #2c111d;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 9px 24px rgba(114, 21, 63, 0.2);
|
||||||
|
animation: firstRechargeBannerIn 260ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: #f90073;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactCopy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactCopy span,
|
||||||
|
.compactCopy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactCopy span {
|
||||||
|
color: rgba(44, 17, 29, 0.68);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactCopy strong {
|
||||||
|
color: #ec006d;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.contentButton:focus-visible,
|
.contentButton:focus-visible,
|
||||||
.closeButton:focus-visible {
|
.closeButton:focus-visible,
|
||||||
|
.compactButton:focus-visible {
|
||||||
outline: 2px solid rgba(255, 255, 255, 0.94);
|
outline: 2px solid rgba(255, 255, 255, 0.94);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
@@ -144,4 +210,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compactButton {
|
||||||
|
gap: 5px;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactIcon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface FirstRechargeOfferBannerProps {
|
|||||||
discountPercent: number;
|
discountPercent: number;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
variant?: "banner" | "compact";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FirstRechargeOfferBanner({
|
export function FirstRechargeOfferBanner({
|
||||||
@@ -16,9 +17,32 @@ export function FirstRechargeOfferBanner({
|
|||||||
discountPercent,
|
discountPercent,
|
||||||
onClick,
|
onClick,
|
||||||
onClose,
|
onClose,
|
||||||
|
variant = "banner",
|
||||||
}: FirstRechargeOfferBannerProps) {
|
}: FirstRechargeOfferBannerProps) {
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
if (variant === "compact") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key="chat.first_recharge_offer"
|
||||||
|
data-analytics-label="Open first recharge offer"
|
||||||
|
className={styles.compactButton}
|
||||||
|
aria-label={`First recharge offer, ${discountPercent}% off`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className={styles.compactIcon} aria-hidden="true">
|
||||||
|
<Sparkles size={14} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.compactCopy}>
|
||||||
|
<span>First recharge</span>
|
||||||
|
<strong>{discountPercent}% OFF</strong>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={14} strokeWidth={2.4} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.banner} aria-label="First recharge offer">
|
<section className={styles.banner} aria-label="First recharge offer">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* - 双指缩放(CSS `touch-action: pinch-zoom`)
|
* - 双指缩放(CSS `touch-action: pinch-zoom`)
|
||||||
*/
|
*/
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, type MouseEvent } from "react";
|
||||||
|
|
||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@ export function FullscreenImageViewer({
|
|||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
if (imagePaywalled) {
|
if (imagePaywalled) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -90,7 +94,7 @@ export function FullscreenImageViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.viewer}
|
className={styles.viewer}
|
||||||
onClick={onClose}
|
onClick={handleBackdropClick}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Fullscreen image"
|
aria-label="Fullscreen image"
|
||||||
@@ -110,7 +114,7 @@ export function FullscreenImageViewer({
|
|||||||
errorClassName="error"
|
errorClassName="error"
|
||||||
width={800}
|
width={800}
|
||||||
height={800}
|
height={800}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
|
||||||
export interface HistoryUnlockDialogProps {
|
export interface HistoryUnlockDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
lockedCount: number;
|
lockedCount: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
|
guidance?: PaymentGuidance | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
@@ -14,10 +17,15 @@ export function HistoryUnlockDialog({
|
|||||||
lockedCount,
|
lockedCount,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
guidance = null,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: HistoryUnlockDialogProps) {
|
}: HistoryUnlockDialogProps) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
const activeGuidance = guidance;
|
||||||
|
const showAction =
|
||||||
|
!activeGuidance ||
|
||||||
|
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -31,11 +39,16 @@ export function HistoryUnlockDialog({
|
|||||||
id="history-unlock-title"
|
id="history-unlock-title"
|
||||||
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
|
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
|
||||||
>
|
>
|
||||||
Unlock previous messages?
|
{activeGuidance?.title ?? "Unlock previous messages?"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
|
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
|
||||||
We found {lockedCount} locked messages in your chat history. You can
|
{activeGuidance?.copy ?? (
|
||||||
unlock them now to continue the conversation with full context.
|
<>
|
||||||
|
We found {lockedCount} locked messages in your chat history. You
|
||||||
|
can unlock them now to continue the conversation with full
|
||||||
|
context.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]">
|
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]">
|
||||||
@@ -51,7 +64,7 @@ export function HistoryUnlockDialog({
|
|||||||
>
|
>
|
||||||
Later
|
Later
|
||||||
</button>
|
</button>
|
||||||
<button
|
{showAction ? <button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="chat.unlock_history"
|
data-analytics-key="chat.unlock_history"
|
||||||
data-analytics-label="Unlock chat history"
|
data-analytics-label="Unlock chat history"
|
||||||
@@ -59,8 +72,10 @@ export function HistoryUnlockDialog({
|
|||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? "Unlocking..." : "Unlock now"}
|
{isLoading
|
||||||
</button>
|
? "Unlocking..."
|
||||||
|
: activeGuidance?.ctaLabel ?? "Unlock now"}
|
||||||
|
</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
export * from "./ai-disclosure-banner";
|
export * from "./ai-disclosure-banner";
|
||||||
export * from "./browser-hint-overlay";
|
export * from "./browser-hint-overlay";
|
||||||
export * from "./chat-area";
|
export * from "./chat-area";
|
||||||
|
export * from "./commercial-action-card";
|
||||||
|
export * from "./chat-action-card";
|
||||||
export * from "./chat-header";
|
export * from "./chat-header";
|
||||||
export * from "./chat-insufficient-credits-banner";
|
export * from "./chat-insufficient-credits-banner";
|
||||||
export * from "./chat-input-bar";
|
export * from "./chat-input-bar";
|
||||||
export * from "./chat-input-text-field";
|
export * from "./chat-input-text-field";
|
||||||
export * from "./chat-send-button";
|
export * from "./chat-send-button";
|
||||||
|
export * from "./chat-support-button";
|
||||||
export * from "./chat-unlock-dialogs";
|
export * from "./chat-unlock-dialogs";
|
||||||
export * from "./date-header";
|
export * from "./date-header";
|
||||||
export * from "./first-recharge-offer-banner";
|
export * from "./first-recharge-offer-banner";
|
||||||
@@ -20,6 +23,7 @@ export * from "./insufficient-credits-dialog";
|
|||||||
export * from "./message-avatar";
|
export * from "./message-avatar";
|
||||||
export * from "./message-bubble";
|
export * from "./message-bubble";
|
||||||
export * from "./message-content";
|
export * from "./message-content";
|
||||||
|
export * from "./payment-guidance-card";
|
||||||
export * from "./private-message-card";
|
export * from "./private-message-card";
|
||||||
export * from "./pwa-install-dialog";
|
export * from "./pwa-install-dialog";
|
||||||
export * from "./pwa-install-overlay";
|
export * from "./pwa-install-overlay";
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
|
||||||
export interface InsufficientCreditsDialogProps {
|
export interface InsufficientCreditsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
requiredCredits: number;
|
requiredCredits: number;
|
||||||
shortfallCredits: number;
|
shortfallCredits: number;
|
||||||
|
guidance?: PaymentGuidance | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
@@ -14,12 +17,17 @@ export function InsufficientCreditsDialog({
|
|||||||
creditBalance,
|
creditBalance,
|
||||||
requiredCredits,
|
requiredCredits,
|
||||||
shortfallCredits,
|
shortfallCredits,
|
||||||
|
guidance = null,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: InsufficientCreditsDialogProps) {
|
}: InsufficientCreditsDialogProps) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0;
|
const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0;
|
||||||
|
const activeGuidance = guidance;
|
||||||
|
const showPurchaseAction =
|
||||||
|
!activeGuidance ||
|
||||||
|
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -33,10 +41,11 @@ export function InsufficientCreditsDialog({
|
|||||||
id="insufficient-credits-title"
|
id="insufficient-credits-title"
|
||||||
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
|
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
|
||||||
>
|
>
|
||||||
Not enough credits
|
{activeGuidance?.title ?? "Not enough credits"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
|
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
|
||||||
Top up your credits to unlock this message and keep the moment going.
|
{activeGuidance?.copy ??
|
||||||
|
"Top up your credits to unlock this message and keep the moment going."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showCreditDetail ? (
|
{showCreditDetail ? (
|
||||||
@@ -70,15 +79,15 @@ export function InsufficientCreditsDialog({
|
|||||||
>
|
>
|
||||||
Later
|
Later
|
||||||
</button>
|
</button>
|
||||||
<button
|
{showPurchaseAction ? <button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="chat.topup"
|
data-analytics-key="chat.topup"
|
||||||
data-analytics-label="Top up chat credits"
|
data-analytics-label="Top up chat credits"
|
||||||
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
Top up now
|
{activeGuidance?.ctaLabel ?? "Top up now"}
|
||||||
</button>
|
</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* - 用户:[占位 spacer] [Content] [Avatar]
|
* - 用户:[占位 spacer] [Content] [Avatar]
|
||||||
*/
|
*/
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
import type { ChatAction, CommercialAction, PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
|
||||||
import { MessageAvatar } from "./message-avatar";
|
import { MessageAvatar } from "./message-avatar";
|
||||||
import { MessageContent } from "./message-content";
|
import { MessageContent } from "./message-content";
|
||||||
@@ -27,6 +28,9 @@ export interface MessageBubbleProps {
|
|||||||
lockReason?: string | null;
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
|
commercialAction?: CommercialAction | null;
|
||||||
|
chatAction?: ChatAction | null;
|
||||||
|
paymentGuidance?: PaymentGuidance | null;
|
||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: ChatMessageAction;
|
onUnlockPrivateMessage?: ChatMessageAction;
|
||||||
onUnlockVoiceMessage?: ChatMessageAction;
|
onUnlockVoiceMessage?: ChatMessageAction;
|
||||||
@@ -34,6 +38,11 @@ export interface MessageBubbleProps {
|
|||||||
onOpenImage?: (displayMessageId: string) => void;
|
onOpenImage?: (displayMessageId: string) => void;
|
||||||
onUserAvatarClick?: () => void;
|
onUserAvatarClick?: () => void;
|
||||||
onCharacterAvatarClick?: () => void;
|
onCharacterAvatarClick?: () => void;
|
||||||
|
onCommercialAction?: (action: CommercialAction) => void;
|
||||||
|
onCommercialActionDismiss?: (action: CommercialAction) => void;
|
||||||
|
onChatActionViewed?: (action: ChatAction) => void;
|
||||||
|
onChatAction?: (action: ChatAction) => void | Promise<void>;
|
||||||
|
onChatActionDismiss?: (action: ChatAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessageAction = (
|
type ChatMessageAction = (
|
||||||
@@ -54,6 +63,9 @@ export function MessageBubble({
|
|||||||
lockReason,
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
|
commercialAction,
|
||||||
|
chatAction,
|
||||||
|
paymentGuidance,
|
||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
@@ -61,6 +73,11 @@ export function MessageBubble({
|
|||||||
onOpenImage,
|
onOpenImage,
|
||||||
onUserAvatarClick,
|
onUserAvatarClick,
|
||||||
onCharacterAvatarClick,
|
onCharacterAvatarClick,
|
||||||
|
onCommercialAction,
|
||||||
|
onCommercialActionDismiss,
|
||||||
|
onChatActionViewed,
|
||||||
|
onChatAction,
|
||||||
|
onChatActionDismiss,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
|
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
|
||||||
|
|
||||||
@@ -89,11 +106,19 @@ export function MessageBubble({
|
|||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
|
commercialAction={commercialAction}
|
||||||
|
chatAction={chatAction}
|
||||||
|
paymentGuidance={paymentGuidance}
|
||||||
isUnlockingMessage={isUnlockingMessage}
|
isUnlockingMessage={isUnlockingMessage}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
onUnlockImageMessage={onUnlockImageMessage}
|
onUnlockImageMessage={onUnlockImageMessage}
|
||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
|
onCommercialAction={onCommercialAction}
|
||||||
|
onCommercialActionDismiss={onCommercialActionDismiss}
|
||||||
|
onChatActionViewed={onChatActionViewed}
|
||||||
|
onChatAction={onChatAction}
|
||||||
|
onChatActionDismiss={onChatActionDismiss}
|
||||||
/>
|
/>
|
||||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import type {
|
||||||
|
ChatAction,
|
||||||
|
CommercialAction,
|
||||||
|
PaymentGuidance,
|
||||||
|
} from "@/data/schemas/chat";
|
||||||
|
|
||||||
|
import { ChatActionCard } from "./chat-action-card";
|
||||||
|
import { CommercialActionCard } from "./commercial-action-card";
|
||||||
import { ImageBubble } from "./image-bubble";
|
import { ImageBubble } from "./image-bubble";
|
||||||
import { LockedImageMessageCard } from "./locked-image-message-card";
|
import { LockedImageMessageCard } from "./locked-image-message-card";
|
||||||
import { PrivateMessageCard } from "./private-message-card";
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
|
import { PaymentGuidanceCard } from "./payment-guidance-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
import { VoiceBubble } from "./voice-bubble";
|
import { VoiceBubble } from "./voice-bubble";
|
||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
@@ -19,11 +28,19 @@ export interface MessageContentProps {
|
|||||||
lockReason?: string | null;
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
|
commercialAction?: CommercialAction | null;
|
||||||
|
chatAction?: ChatAction | null;
|
||||||
|
paymentGuidance?: PaymentGuidance | null;
|
||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: ChatMessageAction;
|
onUnlockPrivateMessage?: ChatMessageAction;
|
||||||
onUnlockVoiceMessage?: ChatMessageAction;
|
onUnlockVoiceMessage?: ChatMessageAction;
|
||||||
onUnlockImageMessage?: ChatMessageAction;
|
onUnlockImageMessage?: ChatMessageAction;
|
||||||
onOpenImage?: (displayMessageId: string) => void;
|
onOpenImage?: (displayMessageId: string) => void;
|
||||||
|
onCommercialAction?: (action: CommercialAction) => void;
|
||||||
|
onCommercialActionDismiss?: (action: CommercialAction) => void;
|
||||||
|
onChatActionViewed?: (action: ChatAction) => void;
|
||||||
|
onChatAction?: (action: ChatAction) => void | Promise<void>;
|
||||||
|
onChatActionDismiss?: (action: ChatAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessageAction = (
|
type ChatMessageAction = (
|
||||||
@@ -46,11 +63,19 @@ export function MessageContent({
|
|||||||
lockReason,
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
|
commercialAction,
|
||||||
|
chatAction,
|
||||||
|
paymentGuidance,
|
||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
onUnlockImageMessage,
|
onUnlockImageMessage,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
|
onCommercialAction,
|
||||||
|
onCommercialActionDismiss,
|
||||||
|
onChatActionViewed,
|
||||||
|
onChatAction,
|
||||||
|
onChatActionDismiss,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||||
@@ -125,6 +150,22 @@ export function MessageContent({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isFromAI && paymentGuidance ? (
|
||||||
|
<PaymentGuidanceCard guidance={paymentGuidance} />
|
||||||
|
) : isFromAI && chatAction ? (
|
||||||
|
<ChatActionCard
|
||||||
|
action={chatAction}
|
||||||
|
onViewed={onChatActionViewed}
|
||||||
|
onActivate={onChatAction}
|
||||||
|
onDismiss={onChatActionDismiss}
|
||||||
|
/>
|
||||||
|
) : isFromAI && commercialAction ? (
|
||||||
|
<CommercialActionCard
|
||||||
|
action={commercialAction}
|
||||||
|
onActivate={onCommercialAction}
|
||||||
|
onDismiss={onCommercialActionDismiss}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
import type { PaymentAnalyticsTriggerReason } from "@/lib/analytics/payment_analytics_context";
|
||||||
|
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
|
export interface PaymentGuidanceCardProps {
|
||||||
|
guidance: PaymentGuidance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerReasonForGuidance(
|
||||||
|
scene: PaymentGuidance["scene"],
|
||||||
|
): PaymentAnalyticsTriggerReason {
|
||||||
|
if (scene === "supportCharacter" || scene === "vip" || scene === "purchaseOptions") {
|
||||||
|
return "vip_cta";
|
||||||
|
}
|
||||||
|
if (scene === "privateMessageLocked" || scene === "historyLocked") {
|
||||||
|
return "private_topic_limit";
|
||||||
|
}
|
||||||
|
return "insufficient_credits";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
const isVip = useUserSelector((state) => state.context.isVip);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const viewedIdRef = useRef<string | null>(null);
|
||||||
|
const belongsToCurrentCharacter = guidance.characterId === character.id;
|
||||||
|
const hasPurchaseAction =
|
||||||
|
guidance.mode === "guide" &&
|
||||||
|
guidance.target === "subscription" &&
|
||||||
|
guidance.ctaLabel !== null &&
|
||||||
|
guidance.purchaseOptions.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!belongsToCurrentCharacter) return;
|
||||||
|
if (viewedIdRef.current === guidance.guidanceId) return;
|
||||||
|
viewedIdRef.current = guidance.guidanceId;
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
character.id,
|
||||||
|
"viewed",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}, [belongsToCurrentCharacter, character.id, guidance.guidanceId]);
|
||||||
|
|
||||||
|
if (!belongsToCurrentCharacter || dismissed) return null;
|
||||||
|
|
||||||
|
const subscriptionType =
|
||||||
|
isVip || !guidance.purchaseOptions.includes("vip") ? "topup" : "vip";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={styles.commercialAction}
|
||||||
|
aria-label={guidance.title ?? guidance.characterName}
|
||||||
|
data-payment-guidance-id={guidance.guidanceId}
|
||||||
|
data-payment-guidance-mode={guidance.mode}
|
||||||
|
data-payment-guidance-scene={guidance.scene}
|
||||||
|
>
|
||||||
|
<div className={styles.commercialActionHeader}>
|
||||||
|
<CircleDollarSign size={18} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionDismiss}
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onClick={() => {
|
||||||
|
setDismissed(true);
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
character.id,
|
||||||
|
"dismissed",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{guidance.title ? (
|
||||||
|
<strong>{guidance.title}</strong>
|
||||||
|
) : null}
|
||||||
|
<p className={styles.commercialActionCopy}>{guidance.copy}</p>
|
||||||
|
{hasPurchaseAction ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.commercialActionButton}
|
||||||
|
onClick={() => {
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
character.id,
|
||||||
|
"opened",
|
||||||
|
).catch(() => undefined);
|
||||||
|
navigator.openSubscription({
|
||||||
|
type: subscriptionType,
|
||||||
|
returnTo: "chat",
|
||||||
|
chatActionId: guidance.guidanceId,
|
||||||
|
analytics: {
|
||||||
|
entryPoint: "chat_input",
|
||||||
|
triggerReason: triggerReasonForGuidance(guidance.scene),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{guidance.ctaLabel}</span>
|
||||||
|
<ArrowRight size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { createChatWebSocket } from "@/core/net/chat-websocket";
|
||||||
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import { getSessionAccessToken } from "@/lib/auth/auth_session";
|
||||||
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
|
|
||||||
|
export function useChatCommercialMessages(input: {
|
||||||
|
characterId: string;
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
}) {
|
||||||
|
const chatDispatch = useChatDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let socket: ReturnType<typeof createChatWebSocket> | null = null;
|
||||||
|
void getSessionAccessToken().then((token) => {
|
||||||
|
if (disposed || !token) return;
|
||||||
|
socket = createChatWebSocket(token);
|
||||||
|
socket.onCommercialMessage = (message) => {
|
||||||
|
if (message.characterId !== input.characterId) return;
|
||||||
|
chatDispatch({ type: "ChatCommercialMessageReceived", message });
|
||||||
|
};
|
||||||
|
socket.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
socket?.disconnect();
|
||||||
|
};
|
||||||
|
}, [chatDispatch, input.characterId, input.loginStatus]);
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { useEffect, useRef } from "react";
|
|||||||
import { behaviorAnalytics } from "@/lib/analytics";
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
|
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
|
||||||
|
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,10 +20,14 @@ import {
|
|||||||
export interface UseChatMessageLimitBannerInput {
|
export interface UseChatMessageLimitBannerInput {
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: ChatUpgradeReason | null;
|
upgradeReason: ChatUpgradeReason | null;
|
||||||
|
paymentGuidance: PaymentGuidance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageLimitBannerView
|
export interface ChatMessageLimitBannerView {
|
||||||
extends InsufficientCreditsMessageLimitView {
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ctaLabel: string | null;
|
||||||
|
guidance: PaymentGuidance | null;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
unlock: () => void;
|
unlock: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,10 +35,19 @@ export interface ChatMessageLimitBannerView
|
|||||||
export function useChatMessageLimitBanner({
|
export function useChatMessageLimitBanner({
|
||||||
upgradePromptVisible,
|
upgradePromptVisible,
|
||||||
upgradeReason,
|
upgradeReason,
|
||||||
|
paymentGuidance,
|
||||||
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
|
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
|
const character = useActiveCharacter();
|
||||||
const isVip = useUserSelector((state) => state.context.isVip);
|
const isVip = useUserSelector((state) => state.context.isVip);
|
||||||
const view = getInsufficientCreditsMessageLimitView();
|
const fallbackView = getInsufficientCreditsMessageLimitView();
|
||||||
|
const guidance =
|
||||||
|
paymentGuidance?.characterId === character.id ? paymentGuidance : null;
|
||||||
|
const view: InsufficientCreditsMessageLimitView = {
|
||||||
|
title: guidance?.title ?? fallbackView.title,
|
||||||
|
description: guidance?.copy ?? fallbackView.description,
|
||||||
|
ctaLabel: guidance?.ctaLabel ?? fallbackView.ctaLabel,
|
||||||
|
};
|
||||||
const visible = shouldShowMessageLimitBanner({
|
const visible = shouldShowMessageLimitBanner({
|
||||||
upgradePromptVisible,
|
upgradePromptVisible,
|
||||||
upgradeReason,
|
upgradeReason,
|
||||||
@@ -52,12 +68,31 @@ export function useChatMessageLimitBanner({
|
|||||||
},
|
},
|
||||||
{ isVip },
|
{ isVip },
|
||||||
);
|
);
|
||||||
}, [isVip, visible]);
|
if (guidance) {
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
character.id,
|
||||||
|
"viewed",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [character.id, guidance, isVip, visible]);
|
||||||
|
|
||||||
function unlock(): void {
|
function unlock(): void {
|
||||||
|
if (guidance?.mode !== "guide" && guidance !== null) return;
|
||||||
|
if (guidance) {
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
character.id,
|
||||||
|
"opened",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
navigator.openSubscription({
|
navigator.openSubscription({
|
||||||
type: getInsufficientCreditsSubscriptionType(isVip),
|
type:
|
||||||
|
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
|
||||||
|
? "topup"
|
||||||
|
: getInsufficientCreditsSubscriptionType(isVip),
|
||||||
returnTo: "chat",
|
returnTo: "chat",
|
||||||
|
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
|
||||||
analytics: {
|
analytics: {
|
||||||
entryPoint: "chat_input",
|
entryPoint: "chat_input",
|
||||||
triggerReason: "insufficient_credits",
|
triggerReason: "insufficient_credits",
|
||||||
@@ -67,6 +102,9 @@ export function useChatMessageLimitBanner({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...view,
|
...view,
|
||||||
|
ctaLabel:
|
||||||
|
guidance && guidance.mode !== "guide" ? null : view.ctaLabel,
|
||||||
|
guidance,
|
||||||
visible,
|
visible,
|
||||||
unlock,
|
unlock,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { shallowEqual } from "@xstate/react";
|
||||||
|
|
||||||
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import {
|
||||||
|
usePaymentDispatch,
|
||||||
|
usePaymentSelector,
|
||||||
|
} from "@/stores/payment/payment-context";
|
||||||
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
import { deriveChatSupportCta } from "../chat-support-cta";
|
||||||
|
|
||||||
|
export function useChatSupportCta(input: {
|
||||||
|
characterId: string;
|
||||||
|
historyLoaded: boolean;
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
}) {
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
const paymentDispatch = usePaymentDispatch();
|
||||||
|
const initializedCharacterRef = useRef<string | null>(null);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
const payment = usePaymentSelector(
|
||||||
|
(state) => ({
|
||||||
|
commercialOffer: state.context.commercialOffer,
|
||||||
|
isFirstRecharge: state.context.isFirstRecharge,
|
||||||
|
supportCharacterId: state.context.supportCharacterId,
|
||||||
|
}),
|
||||||
|
shallowEqual,
|
||||||
|
);
|
||||||
|
const countryCode = useUserSelector(
|
||||||
|
(state) => state.context.currentUser?.countryCode,
|
||||||
|
);
|
||||||
|
const isAuthenticated =
|
||||||
|
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
if (initializedCharacterRef.current === input.characterId) return;
|
||||||
|
initializedCharacterRef.current = input.characterId;
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentInit",
|
||||||
|
characterId: input.characterId,
|
||||||
|
payChannel: getDefaultPayChannelForCountryCode(countryCode),
|
||||||
|
});
|
||||||
|
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payment.commercialOffer) return;
|
||||||
|
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [payment.commercialOffer]);
|
||||||
|
|
||||||
|
const cta = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge:
|
||||||
|
isAuthenticated && payment.supportCharacterId === input.characterId
|
||||||
|
? payment.isFirstRecharge
|
||||||
|
: false,
|
||||||
|
commercialOffer:
|
||||||
|
isAuthenticated && payment.supportCharacterId === input.characterId
|
||||||
|
? payment.commercialOffer
|
||||||
|
: null,
|
||||||
|
nowMs,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
input.characterId,
|
||||||
|
isAuthenticated,
|
||||||
|
nowMs,
|
||||||
|
payment.commercialOffer,
|
||||||
|
payment.isFirstRecharge,
|
||||||
|
payment.supportCharacterId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
navigator.openSubscription({
|
||||||
|
type: cta.planId?.startsWith("dol_") ? "topup" : "vip",
|
||||||
|
returnTo: "chat",
|
||||||
|
...(cta.planId ? { planId: cta.planId } : {}),
|
||||||
|
...(cta.commercialOfferId
|
||||||
|
? { commercialOfferId: cta.commercialOfferId }
|
||||||
|
: {}),
|
||||||
|
analytics: {
|
||||||
|
entryPoint: "chat_offer_banner",
|
||||||
|
triggerReason:
|
||||||
|
cta.kind === "support" ? "vip_cta" : "commercial_offer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...cta, visible: input.historyLoaded, open };
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
|
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||||
|
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
|
||||||
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||||
@@ -48,6 +49,7 @@ export interface ChatUnlockDialogModel {
|
|||||||
lockedCount: number;
|
lockedCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
|
guidance: import("@/data/schemas/chat").PaymentGuidance | null;
|
||||||
};
|
};
|
||||||
closeHistoryUnlock: () => void;
|
closeHistoryUnlock: () => void;
|
||||||
confirmHistoryUnlock: () => void;
|
confirmHistoryUnlock: () => void;
|
||||||
@@ -87,6 +89,8 @@ export function useChatUnlockCoordinator({
|
|||||||
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
||||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||||
unlockHistoryError: state.context.unlockHistoryError,
|
unlockHistoryError: state.context.unlockHistoryError,
|
||||||
|
unlockHistoryPaymentGuidance:
|
||||||
|
state.context.unlockHistoryPaymentGuidance,
|
||||||
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
|
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
|
||||||
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
||||||
}),
|
}),
|
||||||
@@ -97,6 +101,20 @@ export function useChatUnlockCoordinator({
|
|||||||
? chatState.unlockPaywallRequest
|
? chatState.unlockPaywallRequest
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const guidance = chatState.unlockHistoryPaymentGuidance;
|
||||||
|
if (!enabled || guidance?.characterId !== chatState.characterId) return;
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
chatState.characterId,
|
||||||
|
"viewed",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}, [
|
||||||
|
chatState.characterId,
|
||||||
|
chatState.unlockHistoryPaymentGuidance,
|
||||||
|
enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unlockPaywallRequest) {
|
if (!unlockPaywallRequest) {
|
||||||
trackedPaywallRef.current = null;
|
trackedPaywallRef.current = null;
|
||||||
@@ -120,7 +138,15 @@ export function useChatUnlockCoordinator({
|
|||||||
},
|
},
|
||||||
{ isVip },
|
{ isVip },
|
||||||
);
|
);
|
||||||
}, [isVip, unlockPaywallRequest]);
|
const guidance = unlockPaywallRequest.paymentGuidance;
|
||||||
|
if (guidance?.characterId === chatState.characterId) {
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
chatState.characterId,
|
||||||
|
"viewed",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [chatState.characterId, isVip, unlockPaywallRequest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -212,6 +238,32 @@ export function useChatUnlockCoordinator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmHistoryUnlock(): void {
|
function confirmHistoryUnlock(): void {
|
||||||
|
const guidance =
|
||||||
|
chatState.unlockHistoryPaymentGuidance?.characterId ===
|
||||||
|
chatState.characterId
|
||||||
|
? chatState.unlockHistoryPaymentGuidance
|
||||||
|
: null;
|
||||||
|
if (guidance) {
|
||||||
|
if (guidance.mode !== "guide") return;
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
chatState.characterId,
|
||||||
|
"opened",
|
||||||
|
).catch(() => undefined);
|
||||||
|
navigator.openSubscription({
|
||||||
|
type:
|
||||||
|
isVip || !guidance.purchaseOptions.includes("vip")
|
||||||
|
? "topup"
|
||||||
|
: "vip",
|
||||||
|
returnTo: "chat",
|
||||||
|
chatActionId: guidance.guidanceId,
|
||||||
|
analytics: {
|
||||||
|
entryPoint: "chat_unlock",
|
||||||
|
triggerReason: "insufficient_credits",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
chatDispatch({ type: "ChatUnlockHistoryConfirmed" });
|
chatDispatch({ type: "ChatUnlockHistoryConfirmed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +273,12 @@ export function useChatUnlockCoordinator({
|
|||||||
|
|
||||||
function confirmPaywall(): void {
|
function confirmPaywall(): void {
|
||||||
if (!unlockPaywallRequest) return;
|
if (!unlockPaywallRequest) return;
|
||||||
|
const guidance =
|
||||||
|
unlockPaywallRequest.paymentGuidance?.characterId ===
|
||||||
|
chatState.characterId
|
||||||
|
? unlockPaywallRequest.paymentGuidance
|
||||||
|
: null;
|
||||||
|
if (guidance && guidance.mode !== "guide") return;
|
||||||
|
|
||||||
const returnUrl = resolveChatUnlockReturnUrl(
|
const returnUrl = resolveChatUnlockReturnUrl(
|
||||||
unlockPaywallRequest,
|
unlockPaywallRequest,
|
||||||
@@ -231,6 +289,13 @@ export function useChatUnlockCoordinator({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||||
|
if (guidance) {
|
||||||
|
void recordChatActionEventById(
|
||||||
|
guidance.guidanceId,
|
||||||
|
chatState.characterId,
|
||||||
|
"opened",
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
navigator.openSubscriptionForPendingUnlock({
|
navigator.openSubscriptionForPendingUnlock({
|
||||||
displayMessageId: unlockPaywallRequest.displayMessageId,
|
displayMessageId: unlockPaywallRequest.displayMessageId,
|
||||||
messageId: unlockPaywallRequest.messageId,
|
messageId: unlockPaywallRequest.messageId,
|
||||||
@@ -239,13 +304,17 @@ export function useChatUnlockCoordinator({
|
|||||||
clientLockId: unlockPaywallRequest.clientLockId,
|
clientLockId: unlockPaywallRequest.clientLockId,
|
||||||
promotion: unlockPaywallRequest.promotion,
|
promotion: unlockPaywallRequest.promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
type: getInsufficientCreditsSubscriptionType(isVip),
|
type:
|
||||||
|
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
|
||||||
|
? "topup"
|
||||||
|
: getInsufficientCreditsSubscriptionType(isVip),
|
||||||
analytics: {
|
analytics: {
|
||||||
entryPoint: "chat_unlock",
|
entryPoint: "chat_unlock",
|
||||||
triggerReason: unlockPaywallRequest.promotion
|
triggerReason: unlockPaywallRequest.promotion
|
||||||
? "ad_landing"
|
? "ad_landing"
|
||||||
: "insufficient_credits",
|
: "insufficient_credits",
|
||||||
},
|
},
|
||||||
|
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +327,7 @@ export function useChatUnlockCoordinator({
|
|||||||
lockedCount: chatState.lockedHistoryCount,
|
lockedCount: chatState.lockedHistoryCount,
|
||||||
isLoading: chatState.isUnlockingHistory,
|
isLoading: chatState.isUnlockingHistory,
|
||||||
errorMessage: chatState.unlockHistoryError,
|
errorMessage: chatState.unlockHistoryError,
|
||||||
|
guidance: chatState.unlockHistoryPaymentGuidance,
|
||||||
},
|
},
|
||||||
closeHistoryUnlock,
|
closeHistoryUnlock,
|
||||||
confirmHistoryUnlock,
|
confirmHistoryUnlock,
|
||||||
|
|||||||
@@ -14,9 +14,20 @@ vi.mock("@/stores/auth/auth-context", () => ({
|
|||||||
vi.mock("@/stores/user/user-context", () => ({
|
vi.mock("@/stores/user/user-context", () => ({
|
||||||
useUserDispatch: () => vi.fn(),
|
useUserDispatch: () => vi.fn(),
|
||||||
useUserState: () => ({
|
useUserState: () => ({
|
||||||
currentUser: {
|
currentUser: null,
|
||||||
dailyFreeChatLimit: 30,
|
entitlements: {
|
||||||
dailyFreePrivateLimit: 2,
|
quotas: {
|
||||||
|
normalChatFreeDaily: 47,
|
||||||
|
privateUnlockFreeDaily: 5,
|
||||||
|
},
|
||||||
|
costs: {
|
||||||
|
normal_message: 3,
|
||||||
|
private_message: 9,
|
||||||
|
voice_message: 11,
|
||||||
|
photo: 13,
|
||||||
|
private_album_10: 101,
|
||||||
|
private_album_20: 181,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -31,5 +42,11 @@ describe("CoinsRulesScreen", () => {
|
|||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
|
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
|
||||||
);
|
);
|
||||||
|
expect(html).toContain("47 free messages daily");
|
||||||
|
expect(html).toContain("5 free private messages daily");
|
||||||
|
expect(html).toContain("3 coins / message");
|
||||||
|
expect(html).toContain("10 photos / 101 coins");
|
||||||
|
expect(html).not.toContain("30 free messages daily");
|
||||||
|
expect(html).not.toContain("2 free private messages daily");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||