39 Commits

Author SHA1 Message Date
Codex 4e0551b7ea feat(payment): show account conversion discounts 2026-07-30 12:28:52 +08:00
Codex ec3e256a8c fix(payment): route Philippine GCash separately from QRIS 2026-07-29 19:21:20 +08:00
Codex 3547f49bd9 revert(payment): remove unsupported Stripe and EzPay upgrade 2026-07-29 17:23:01 +08:00
Codex ee6ab77f4d fix(payment): restore wallet order and English labels 2026-07-29 15:22:43 +08:00
Codex ce3f0af82d fix(payment): allow Stripe after closing QRIS 2026-07-29 14:31:49 +08:00
Codex 638fc054b1 fix(payment): keep character and mobile actions visible 2026-07-29 13:01:08 +08:00
Codex 897bce11db fix(payment): resume QRIS after closing dialog 2026-07-29 12:26:27 +08:00
Codex 1466cb01d2 feat(payment): confirm renewal only at checkout 2026-07-29 11:26:42 +08:00
Codex f6c75dcb86 fix(payment): inherit chat recipient without selector 2026-07-28 19:33:59 +08:00
Codex 187de82eaf ci: encode domestic SSH key secret 2026-07-28 18:26:14 +08:00
Codex d372541925 ci: add isolated domestic frontend deployment 2026-07-28 18:18:42 +08:00
Codex ef9b79bc83 feat(payment): add chat support discounts and gratitude 2026-07-28 17:43:08 +08:00
Codex aee35353a3 feat(payment): expand Stripe methods and checkout handoff 2026-07-28 16:27:03 +08:00
Codex fcb2492118 feat(external-entry): define Facebook business links 2026-07-28 14:53:58 +08:00
Codex cab2ba868f feat(payment): accept support character guidance scene 2026-07-28 12:09:33 +08:00
Codex dab9e2e45a fix(payment): hide first recharge banner 2026-07-28 11:10:51 +08:00
Codex 54ce485f41 feat(private-zone): default to albums tab 2026-07-27 18:56:21 +08:00
Codex e36f94add3 fix(payment): portal checkout dialogs to viewport 2026-07-27 18:50:39 +08:00
Codex ca9cac21f1 feat(chat): render role-bound payment guidance 2026-07-27 18:14:48 +08:00
Codex 662e5e67ea feat(auth): add Messenger topup handoff entry 2026-07-27 17:17:32 +08:00
Codex f403c47052 fix(ci): deploy frontend production as lzf 2026-07-27 16:57:31 +08:00
Codex ac36837add test(chat): enforce locked image URL masking 2026-07-27 14:42:34 +08:00
Codex fe9d31146b feat(subscription): separate selection from payment and add issue feedback 2026-07-27 11:35:42 +08:00
Codex 01c55f5630 fix(external-entry): preserve legacy private room links 2026-07-27 11:28:27 +08:00
Codex b21ef2f6ec feat(chat): send idempotent client message ids 2026-07-27 10:58:51 +08:00
Codex a44fc4860e feat(private-zone): add paid video moments 2026-07-24 20:17:28 +08:00
Codex ff2759cf0f feat(chat): add support action cards and live entitlements 2026-07-24 18:04:05 +08:00
Codex d746d04503 feat(call): add streaming voice call experience 2026-07-24 16:36:36 +08:00
Codex 2a90b4d9ec fix(ci): scope frontend image cleanup 2026-07-24 13:16:35 +08:00
Codex 9094bcbab0 fix(pwa): 同步预发 favicon 源图 2026-07-24 12:20:18 +08:00
Codex 29abd746da fix(pwa): 统一桌面安装图标 2026-07-24 12:17:53 +08:00
Codex 556bfd2919 feat(mobile): improve image viewer and install entry 2026-07-24 11:24:23 +08:00
Codex e3ef289b53 fix(chat): debounce post-send action menu 2026-07-23 20:08:16 +08:00
Codex 46588bd98c feat(payment): support user-bound seven-discount offers 2026-07-23 16:11:20 +08:00
Codex 05f625dd0b feat(chat): render commercial actions and persist greetings 2026-07-23 14:52:34 +08:00
Codex 3790fb813f feat(splash): simplify multi-character home layout 2026-07-23 13:27:16 +08:00
Codex a55a59bff4 fix(private-zone): hide incomplete albums and refresh Maya cover 2026-07-23 11:47:22 +08:00
Codex d2a46ff6b9 refactor(private-zone): use canonical product name 2026-07-23 10:53:41 +08:00
Codex a4c4ca6666 fix(multi-role): enable Maya and Nayeli commercial flows 2026-07-22 20:49:28 +08:00
40 changed files with 836 additions and 936 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
- pre
- test
workflow_dispatch:
jobs:
@@ -48,7 +48,7 @@ jobs:
DEPLOY_ENV="prod"
NEXT_ENV_FILE=".env.production"
;;
pre)
test)
DEPLOY_ENV="test"
NEXT_ENV_FILE=".env.local"
;;
+2 -2
View File
@@ -72,12 +72,12 @@ copy_env_by_branch() {
START_PORT=9185
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
;;
pre)
test)
cp -f env-example/.env.local.example .env.local
ENV_FILE=".env.local"
DEPLOY_ENV="test"
START_PORT=9135
echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
;;
dev)
cp -f env-example/.env.development.example .env.local
+8 -8
View File
@@ -12,7 +12,7 @@
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
| --- | --- | --- | --- | --- | --- |
| 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
当前 remote 配置示例:
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
## 本地发布流程
预发环境发布流程(脚本迁移前):
测试环境发布流程(脚本迁移前):
1. 进入 pre worktree`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
1. 进入 test worktree`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test`
2. 确认当前分支。
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。
4. 复制测试环境图标到 `public/`
5. 复制 `env-example/.env.local.example``.env.local`
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
旧生产环境发布流程(脚本迁移前):
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
| --- | --- | --- | --- | --- |
| `main` | `env-example/.env.production.example``.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
| `pre` | `env-example/.env.local.example``.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
| `test` | `env-example/.env.local.example``.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
| `dev` | `env-example/.env.development.example``.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
4. 生产推送当前使用 `git push --force production main`
5. 构建失败时不会重启容器,旧容器继续运行。
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
## 后续优化建议
+7 -7
View File
@@ -8,13 +8,13 @@
| Workflow | 触发时机 | 职责 |
| --- | --- | --- |
| `.gitea/workflows/ci.yml` | `dev``main``pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
| `.gitea/workflows/docker-image.yml` | `main``pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
| `.gitea/workflows/ci.yml` | `dev``main``test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
| `.gitea/workflows/docker-image.yml` | `main``test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test`
分支快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
`ci.yml` 会同时执行完整检查并提供测试结果。
## 必需 Secrets
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
## 镜像 Tag 规则
`pre` 分支发布时继续使用现有 `test-*` 运行镜像标签
`test` 分支发布:
```bash
REGISTRY_IMAGE:test-<short_sha>
@@ -81,7 +81,7 @@ volumes:
## 验证方式
推送到 `pre` 分支后,检查 Actions 日志中是否出现:
推送到 `test` 分支后,检查 Actions 日志中是否出现:
```text
Build Docker image
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*`
+7 -7
View File
@@ -3,7 +3,7 @@
当前推荐部署链路:
```text
push pre/main
push test/main
→ Gitea Actions 构建并推送 Docker 镜像
→ Actions 通过 SSH 登录目标服务器
→ 目标服务器 docker pull 指定镜像 tag
@@ -16,7 +16,7 @@ push pre/main
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
| --- | --- | --- | --- | --- | --- |
| `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
## Gitea Secrets
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`
## 本地发布入口
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions
```bash
# 预发环境
# 测试环境
./scripts/release/pre_release_web.sh
# 生产环境
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
也可以直接推送:
```bash
git push gitea pre
git push gitea test
git push --force gitea main
```
## 验证
预发环境:
测试环境:
```bash
ssh <user>@<pre-host>
ssh <user>@<test-host>
cd /opt/cozsweet-web-test
docker compose ps
curl -I http://127.0.0.1:9135/
+2 -2
View File
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier
on every pull request and `dev` / `test` / `main` push.
## Environment overrides
+16 -30
View File
@@ -283,14 +283,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder(
await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
@@ -300,20 +300,14 @@ test("closing QRIS restores a resumable checkout without creating a duplicate or
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
@@ -408,7 +402,7 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
const orderId = await expectQrisOrder(
await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
@@ -418,19 +412,11 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await resumeButton.click();
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
@@ -0,0 +1,153 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
+1 -1
View File
@@ -3,7 +3,7 @@
# 推送指定分支到指定远端。
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
# 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main"
# deploy_web_test.shpre)→ push_to_remote "gitea" "pre"
# deploy_web_test.shtest)→ push_to_remote "gitea" "test"
push_to_remote() {
local remote="$1"
local branch="$2"
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# ==========================================
# 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
# 原始 Dart: scripts/deploy/deploy_web_test.sh
# ==========================================
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
# 主函数
main() {
push_to_remote "gitea" "pre"
push_to_remote "gitea" "test"
}
main
+8 -8
View File
@@ -11,9 +11,9 @@ unset_git_hook_env() {
unset_git_hook_env
# pre 分支 worktree 路径
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
echo "=== 进入 pre 分支 worktree ==="
# Test 分支 worktree 路径
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test"
echo "=== 进入 test 分支 worktree ==="
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
echo "=== 检查当前分支 ==="
@@ -22,17 +22,17 @@ git branch --show-current
echo "=== 变基到 dev 分支 ==="
git rebase dev
# 复制预发环境图标(运行资源目录仍为 icons/test
echo "=== 复制预发环境图标(icons/test → public/ ==="
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/
echo "=== 复制测试环境图标(icons/test → public/ ==="
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
# 准备预发环境变量(运行配置仍使用 .env.local
echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local ==="
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local ==="
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
echo "=== 执行构建部署脚本(预发环境)==="
echo "=== 执行构建部署脚本(测试环境)==="
./scripts/deploy/deploy_web_test.sh
echo "=== 预发布完成 ==="
@@ -7,15 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleQrisClose: vi.fn(),
handleRegionalQrClose: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
qrisErrorMessage: null,
qrisPayment: null,
qrisStatus: null,
regionalQrErrorMessage: null,
regionalQrPayment: null,
regionalQrStatus: null,
shouldShowEzpayConfirmDialog: false,
shouldShowQrisDialog: false,
shouldShowRegionalQrDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
};
@@ -96,14 +96,15 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisPayment: {
regionalQrPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
qrisStatus: "pending",
shouldShowQrisDialog: true,
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
@@ -122,6 +123,44 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog?.textContent).toContain("Waiting for payment");
});
it("labels a Philippine QR fallback as GCash / QR Ph", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-ph-qr"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Pay with GCash."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "000201010212ph-qr-payload",
orderId: "order-ph-qr",
amountCents: 49_990,
currency: "PHP",
experience: "gcashQrPh",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
expect(dialog?.textContent).not.toContain("QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"GCash / QR Ph payment QR code",
);
expect(dialog?.textContent).toContain("₱");
const closeButton = Array.from(
dialog?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Close");
act(() => closeButton?.click());
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
});
it("shows QRIS failure state and handles empty QR data safely", () => {
act(() =>
root.render(
@@ -131,15 +170,16 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisErrorMessage: "Payment failed or was cancelled.",
qrisPayment: {
regionalQrErrorMessage: "Payment failed or was cancelled.",
regionalQrPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
qrisStatus: "failed",
shouldShowQrisDialog: true,
regionalQrStatus: "failed",
shouldShowRegionalQrDialog: true,
}}
/>,
),
@@ -13,15 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleQrisClose"
| "handleRegionalQrClose"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "qrisErrorMessage"
| "qrisPayment"
| "qrisStatus"
| "regionalQrErrorMessage"
| "regionalQrPayment"
| "regionalQrStatus"
| "shouldShowEzpayConfirmDialog"
| "shouldShowQrisDialog"
| "shouldShowRegionalQrDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
>;
@@ -53,12 +53,12 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
<QrisPaymentDialog
payment={launch.qrisPayment}
status={launch.qrisStatus}
errorMessage={launch.qrisErrorMessage}
onClose={launch.handleQrisClose}
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
<RegionalQrPaymentDialog
payment={launch.regionalQrPayment}
status={launch.regionalQrStatus}
errorMessage={launch.regionalQrErrorMessage}
onClose={launch.handleRegionalQrClose}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
@@ -73,43 +73,56 @@ export function PaymentLaunchDialogs({
);
}
interface QrisPaymentDialogProps {
interface RegionalQrPaymentDialogProps {
errorMessage: string | null;
onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
status: PaymentLaunchFlow["qrisStatus"];
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
status: PaymentLaunchFlow["regionalQrStatus"];
}
function formatQrisAmount(amountCents: number, currency: string): string {
function formatRegionalQrAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
}).format(amountCents / 100);
return new Intl.NumberFormat(
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
{
style: "currency",
currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
},
).format(amountCents / 100);
} catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
}
}
function qrisStatusMessage(
status: PaymentLaunchFlow["qrisStatus"],
function regionalQrStatusMessage(
status: PaymentLaunchFlow["regionalQrStatus"],
errorMessage: string | null,
paymentName: string,
): string {
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
if (status === "expired") return errorMessage || "This QRIS order has expired.";
if (status === "failed") {
return errorMessage || "Payment failed. Please try again.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
return "Waiting for payment";
}
function QrisPaymentDialog({
function RegionalQrPaymentDialog({
errorMessage,
onClose,
payment,
status,
}: QrisPaymentDialogProps) {
}: RegionalQrPaymentDialogProps) {
const titleId = useId();
const statusMessage = qrisStatusMessage(status, errorMessage);
const copy = regionalQrCopy(payment.experience);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
return (
<ModalPortal
@@ -123,17 +136,17 @@ function QrisPaymentDialog({
>
<div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}>
Scan to pay with QRIS
{copy.title}
</h2>
<p className={styles.content}>
Open a QRIS-compatible banking or wallet app and scan this code.
{copy.description}
</p>
</div>
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
{payment.qrData ? (
<QRCodeSVG
value={payment.qrData}
title="QRIS payment QR code"
title={copy.qrTitle}
size={256}
level="M"
marginSize={2}
@@ -141,14 +154,14 @@ function QrisPaymentDialog({
/>
) : (
<p className={styles.error} role="alert">
QRIS data is unavailable. Please close this dialog and try again.
{copy.unavailableMessage}
</p>
)}
</div>
<div className="mb-4 flex flex-col gap-2 text-center">
<p className={styles.content}>Order No. {payment.orderId}</p>
<p className="m-0 text-xl font-bold text-text-foreground">
{formatQrisAmount(payment.amountCents, payment.currency)}
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
</p>
<p
className={status === "failed" || status === "expired" ? styles.error : styles.content}
@@ -170,6 +183,41 @@ function QrisPaymentDialog({
);
}
function regionalQrCopy(
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
) {
if (experience === "gcashQrPh") {
return {
paymentName: "GCash / QR Ph",
title: "Pay with GCash / QR Ph",
description:
"Open GCash or another QR Ph-compatible app and scan this code.",
qrTitle: "GCash / QR Ph payment QR code",
unavailableMessage:
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
};
}
if (experience === "qris") {
return {
paymentName: "QRIS",
title: "Scan to pay with QRIS",
description:
"Open a QRIS-compatible banking or wallet app and scan this code.",
qrTitle: "QRIS payment QR code",
unavailableMessage:
"QRIS data is unavailable. Please close this dialog and try again.",
};
}
return {
paymentName: "payment QR",
title: "Scan to pay",
description: "Open a compatible banking or wallet app and scan this code.",
qrTitle: "Payment QR code",
unavailableMessage:
"Payment QR data is unavailable. Please close this dialog and try again.",
};
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
+78 -55
View File
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import {
getPaymentUrl,
getPaymentUrlHostname,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
@@ -41,31 +42,31 @@ export interface UsePaymentLaunchFlowInput {
subscriptionType: PendingPaymentSubscriptionType;
giftCategory?: string | null;
giftPlanId?: string | null;
countryCode?: string | null;
}
export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void;
handleEzpayConfirm: () => void;
handleQrisClose: () => void;
handleQrisResume: () => void;
handleRegionalQrClose: () => void;
handleStripeClose: () => void;
handleStripeConfirmed: () => void;
hasHiddenQrisPayment: boolean;
isConfirmingEzpay: boolean;
qrisErrorMessage: string | null;
qrisPayment: QrisPaymentDetails | null;
qrisStatus: PaymentContextState["orderStatus"];
regionalQrErrorMessage: string | null;
regionalQrPayment: RegionalQrPaymentDetails | null;
regionalQrStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean;
shouldShowQrisDialog: boolean;
shouldShowRegionalQrDialog: boolean;
shouldShowStripeDialog: boolean;
stripeClientSecret: string | null;
}
export interface QrisPaymentDetails {
export interface RegionalQrPaymentDetails {
amountCents: number;
currency: string;
experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string;
qrData: string;
}
@@ -155,30 +156,36 @@ export function usePaymentLaunchFlow({
subscriptionType,
giftCategory,
giftPlanId,
countryCode,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
null,
);
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const paymentCurrency = payment.payParams
? paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
null
: selectedPlan?.currency ?? null;
const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams, {
countryCode,
currency: paymentCurrency,
})
: null;
const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl
: null;
const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const qrisPayment: QrisPaymentDetails | null =
const regionalQrPayment: RegionalQrPaymentDetails | null =
payment.payParams &&
payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr"
@@ -188,9 +195,9 @@ export function usePaymentLaunchFlow({
selectedPlan?.amountCents ??
0,
currency:
paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
"IDR",
paymentCurrency ??
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
experience: ezpayLaunchTarget.experience,
orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData,
}
@@ -209,7 +216,33 @@ export function usePaymentLaunchFlow({
const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) {
const target = resolveEzpayLaunchTarget(payment.payParams);
const target = resolveEzpayLaunchTarget(payment.payParams, {
countryCode,
currency: paymentCurrency,
});
const channelType = paymentParamString(
payment.payParams,
"channelType",
"channel_type",
);
const channelCode = paymentParamString(
payment.payParams,
"channelCode",
"channel_code",
);
const namedPaymentUrl = getPaymentUrl(payment.payParams);
log.debug(`[${logScope}] ezpay launch target resolved`, {
countryCode: countryCode?.trim().toUpperCase() ?? null,
currency: paymentCurrency?.trim().toUpperCase() ?? null,
channelType: channelType?.toUpperCase() ?? null,
channelCode,
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
hasQrData: target.kind === "qr",
paymentUrlHost:
target.kind === "url"
? getPaymentUrlHostname(target.paymentUrl)
: null,
});
if (target.kind === "error") {
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
@@ -224,11 +257,18 @@ export function usePaymentLaunchFlow({
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Missing order id before showing QRIS.",
errorMessage: "Missing order id before showing payment QR code.",
});
return;
}
trackPaymentCheckoutOpened(payment, "qris_embedded");
trackPaymentCheckoutOpened(
payment,
target.experience === "qris"
? "qris_embedded"
: target.experience === "gcashQrPh"
? "gcash_qrph_embedded"
: "payment_qr_embedded",
);
return;
}
@@ -236,7 +276,7 @@ export function usePaymentLaunchFlow({
if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId,
paymentUrl,
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
subscriptionType,
});
return;
@@ -262,7 +302,7 @@ export function usePaymentLaunchFlow({
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
try {
window.location.href = paymentUrl;
window.location.assign(paymentUrl);
trackPaymentCheckoutOpened(payment, paymentUrl);
} catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -283,6 +323,7 @@ export function usePaymentLaunchFlow({
log,
logScope,
characterSlug,
countryCode,
payment.currentOrderId,
payment,
payment.launchNonce,
@@ -291,6 +332,7 @@ export function usePaymentLaunchFlow({
payment.plans,
payment.selectedPlanId,
paymentDispatch,
paymentCurrency,
returnTo,
subscriptionType,
giftCategory,
@@ -307,22 +349,13 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl,
});
const shouldShowQrisDialog = Boolean(
qrisPayment &&
qrisPayment.orderId !== hiddenQrisOrderId &&
!payment.isPaid,
);
const hasHiddenQrisPayment = Boolean(
qrisPayment &&
qrisPayment.orderId === hiddenQrisOrderId &&
payment.isPollingOrder &&
!payment.isPaid,
const shouldShowRegionalQrDialog = Boolean(
regionalQrPayment && !payment.isPaid,
);
function resetLaunchState(): void {
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
setHiddenQrisOrderId(null);
}
function handleStripeClose(): void {
@@ -369,39 +402,29 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" });
}
function handleQrisClose(): void {
log.debug(`[${logScope}] qris dialog closed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
function handleRegionalQrClose(): void {
log.debug(`[${logScope}] regional payment QR dialog closed`, {
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
experience: regionalQrPayment?.experience ?? null,
subscriptionType,
});
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
}
function handleQrisResume(): void {
if (!hasHiddenQrisPayment) return;
log.debug(`[${logScope}] qris dialog resumed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
subscriptionType,
});
setHiddenQrisOrderId(null);
paymentDispatch({ type: "PaymentReset" });
}
return {
ezpayPaymentUrl,
handleEzpayCancel,
handleEzpayConfirm,
handleQrisClose,
handleQrisResume,
handleRegionalQrClose,
handleStripeClose,
handleStripeConfirmed,
hasHiddenQrisPayment,
isConfirmingEzpay,
qrisErrorMessage: payment.errorMessage,
qrisPayment,
qrisStatus: payment.orderStatus,
regionalQrErrorMessage: payment.errorMessage,
regionalQrPayment,
regionalQrStatus: payment.orderStatus,
resetLaunchState,
shouldShowEzpayConfirmDialog,
shouldShowQrisDialog,
shouldShowRegionalQrDialog,
shouldShowStripeDialog,
stripeClientSecret,
};
@@ -5,7 +5,7 @@ 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", () => {
it("never invents a first-recharge discount and shows the active account offer", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: true,
@@ -23,10 +23,10 @@ describe("deriveChatSupportCta", () => {
nowMs: now,
}),
).toMatchObject({
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
expiresAt: null,
kind: "commercialOffer",
label: "Support me · 30% OFF · 23:59:59",
commercialOfferId: "offer-1",
expiresAt: "2026-07-29T00:00:00.000Z",
});
});
@@ -63,6 +63,14 @@ describe("deriveChatSupportCta", () => {
nowMs: now,
}).label,
).toBe("Support me");
expect(
deriveChatSupportCta({
isFirstRecharge: true,
commercialOffer: null,
nowMs: now,
}).label,
).toBe("Support me");
expect(
deriveChatSupportCta({
isFirstRecharge: false,
@@ -81,4 +89,30 @@ describe("deriveChatSupportCta", () => {
}).label,
).toBe("Support me");
});
it("shows the final 50% account offer with the same countdown", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-2",
characterId: "elio",
planId: "dol_2500_t3",
discountPercent: 50,
pricePercent: 50,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "expired_order",
message: "This is the last offer.",
scope: "account",
stage: "halfDiscount",
},
nowMs: now,
}),
).toMatchObject({
kind: "commercialOffer",
label: "Support me · 50% OFF · 23:59:59",
commercialOfferId: "offer-2",
});
});
});
-11
View File
@@ -2,7 +2,6 @@ import type { CommercialOfferSummary } from "@/data/schemas/payment";
export type ChatSupportCtaKind =
| "support"
| "firstRecharge"
| "commercialOffer";
export interface ChatSupportCtaView {
@@ -18,16 +17,6 @@ export function deriveChatSupportCta(input: {
commercialOffer: CommercialOfferSummary | null;
nowMs: number;
}): ChatSupportCtaView {
if (input.isFirstRecharge) {
return {
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
planId: null,
expiresAt: null,
};
}
const offer = input.commercialOffer;
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
@@ -1,223 +0,0 @@
.banner {
position: relative;
flex: 0 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
margin:
clamp(5px, 1.111vw, 6px)
var(--chat-inline-padding, 16px)
clamp(8px, 1.852vw, 10px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: var(--responsive-card-radius-sm, 22px);
background:
radial-gradient(circle at 14% 0%, rgba(255, 255, 255, 0.72), transparent 34%),
linear-gradient(135deg, #fff0b8 0%, #ffd1df 45%, #ff61a7 100%);
box-shadow: 0 14px 34px rgba(114, 21, 63, 0.22);
color: #2c111d;
animation: firstRechargeBannerIn 260ms ease-out both;
}
.banner::after {
position: absolute;
right: clamp(-34px, -6.296vw, -28px);
bottom: clamp(-52px, -9.63vw, -42px);
width: clamp(104px, 23.333vw, 126px);
height: clamp(104px, 23.333vw, 126px);
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
content: "";
pointer-events: none;
}
.contentButton {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(8px, 1.852vw, 10px);
align-items: center;
min-width: 0;
padding:
clamp(10px, 2.222vw, 12px)
clamp(7px, 1.481vw, 8px)
clamp(10px, 2.222vw, 12px)
clamp(12px, 2.593vw, 14px);
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.iconWrap {
display: inline-flex;
width: clamp(30px, 6.296vw, 34px);
height: clamp(30px, 6.296vw, 34px);
align-items: center;
justify-content: center;
border-radius: clamp(12px, 2.593vw, 14px);
background: rgba(255, 255, 255, 0.52);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.42);
}
.icon {
color: #fb2f89;
}
.copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.eyebrow {
color: rgba(44, 17, 29, 0.74);
font-size: var(--responsive-micro, 11px);
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1;
text-transform: uppercase;
}
.title {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--responsive-inline-gap-xs, 5px);
align-items: baseline;
color: #241019;
font-size: var(--responsive-caption, 13px);
font-weight: 750;
line-height: 1.18;
}
.title strong {
color: #f90073;
font-size: var(--responsive-section-title, 20px);
font-weight: 950;
letter-spacing: -0.04em;
line-height: 1;
}
.arrow {
color: rgba(44, 17, 29, 0.58);
}
.closeButton {
position: relative;
z-index: 1;
display: flex;
width: var(--responsive-icon-button-size, 42px);
min-height: 100%;
align-items: center;
justify-content: center;
border: 0;
border-left: 1px solid rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.16);
color: rgba(44, 17, 29, 0.68);
cursor: pointer;
}
.compactButton {
display: grid;
width: min(100%, 240px);
min-width: 0;
max-width: 240px;
min-height: 50px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: 16px;
background:
radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.68), transparent 38%),
linear-gradient(135deg, #fff0b8 0%, #ffd0df 50%, #ff6cab 100%);
color: #2c111d;
cursor: pointer;
font: inherit;
text-align: left;
box-shadow: 0 9px 24px rgba(114, 21, 63, 0.2);
animation: firstRechargeBannerIn 260ms ease-out both;
}
.compactIcon {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
color: #f90073;
}
.compactCopy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
font-size: 11px;
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
}
.compactCopy span,
.compactCopy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactCopy span {
color: rgba(44, 17, 29, 0.68);
letter-spacing: 0.03em;
}
.compactCopy strong {
color: #ec006d;
font-size: 17px;
font-weight: 950;
letter-spacing: -0.02em;
}
.contentButton:focus-visible,
.closeButton:focus-visible,
.compactButton:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.94);
outline-offset: -3px;
}
@keyframes firstRechargeBannerIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 360px) {
.title {
flex-direction: column;
gap: 2px;
}
.compactButton {
gap: 5px;
padding-inline: 6px;
}
.compactIcon {
width: 26px;
height: 26px;
}
}
@@ -1,78 +0,0 @@
"use client";
import { ChevronRight, Sparkles, X } from "lucide-react";
import styles from "./first-recharge-offer-banner.module.css";
export interface FirstRechargeOfferBannerProps {
visible: boolean;
discountPercent: number;
onClick: () => void;
onClose: () => void;
variant?: "banner" | "compact";
}
export function FirstRechargeOfferBanner({
visible,
discountPercent,
onClick,
onClose,
variant = "banner",
}: FirstRechargeOfferBannerProps) {
if (!visible) return null;
if (variant === "compact") {
return (
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.compactButton}
aria-label={`First recharge offer, ${discountPercent}% off`}
onClick={onClick}
>
<span className={styles.compactIcon} aria-hidden="true">
<Sparkles size={14} strokeWidth={2.4} />
</span>
<span className={styles.compactCopy}>
<span>First recharge</span>
<strong>{discountPercent}% OFF</strong>
</span>
<ChevronRight size={14} strokeWidth={2.4} aria-hidden="true" />
</button>
);
}
return (
<section className={styles.banner} aria-label="First recharge offer">
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.contentButton}
onClick={onClick}
>
<span className={styles.iconWrap} aria-hidden="true">
<Sparkles className={styles.icon} size={18} />
</span>
<span className={styles.copy}>
<span className={styles.eyebrow}>First Recharge Offer</span>
<span className={styles.title}>
<strong>{discountPercent}% OFF</strong>
<span>Claim your discount now</span>
</span>
</span>
<ChevronRight className={styles.arrow} size={18} aria-hidden="true" />
</button>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Hide first recharge offer"
>
<X size={16} aria-hidden="true" />
</button>
</section>
);
}
-1
View File
@@ -15,7 +15,6 @@ export * from "./chat-send-button";
export * from "./chat-support-button";
export * from "./chat-unlock-dialogs";
export * from "./date-header";
export * from "./first-recharge-offer-banner";
export * from "./fullscreen-image-viewer";
export * from "./history-unlock-dialog";
export * from "./image-bubble";
@@ -1,60 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldShowFirstRechargeOfferBanner } from "../use-first-recharge-offer-banner";
describe("shouldShowFirstRechargeOfferBanner", () => {
it("shows for authenticated users when first recharge offer is available", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "facebook",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(true);
});
it("does not show for guest users", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "guest",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
it("does not show unless isFirstRecharge is true", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "google",
isFirstRecharge: false,
dismissed: false,
}),
).toBe(false);
});
it("does not show after the user dismisses it", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "email",
isFirstRecharge: true,
dismissed: true,
}),
).toBe(false);
});
it("waits until chat history has loaded", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: false,
loginStatus: "email",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
});
@@ -6,12 +6,14 @@ import { createChatWebSocket } from "@/core/net/chat-websocket";
import type { LoginStatus } from "@/data/schemas/auth";
import { getSessionAccessToken } from "@/lib/auth/auth_session";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { usePaymentDispatch } from "@/stores/payment/payment-context";
export function useChatCommercialMessages(input: {
characterId: string;
loginStatus: LoginStatus;
}) {
const chatDispatch = useChatDispatch();
const paymentDispatch = usePaymentDispatch();
useEffect(() => {
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
@@ -24,6 +26,10 @@ export function useChatCommercialMessages(input: {
if (disposed || !token) return;
socket = createChatWebSocket(token);
socket.onCommercialMessage = (message) => {
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
});
if (message.characterId !== input.characterId) return;
chatDispatch({ type: "ChatCommercialMessageReceived", message });
};
@@ -34,5 +40,5 @@ export function useChatCommercialMessages(input: {
disposed = true;
socket?.disconnect();
};
}, [chatDispatch, input.characterId, input.loginStatus]);
}, [chatDispatch, input.characterId, input.loginStatus, paymentDispatch]);
}
+34 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
@@ -36,17 +36,20 @@ export function useChatSupportCta(input: {
);
const isAuthenticated =
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
const refreshCatalog = useCallback(() => {
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
payChannel: getDefaultPayChannelForCountryCode(countryCode),
});
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
}, [countryCode, input.characterId, paymentDispatch]);
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
refreshCatalog();
}, [input.characterId, isAuthenticated, refreshCatalog]);
useEffect(() => {
if (!payment.commercialOffer) return;
@@ -54,6 +57,30 @@ export function useChatSupportCta(input: {
return () => window.clearInterval(timer);
}, [payment.commercialOffer]);
useEffect(() => {
if (!isAuthenticated) return;
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") refreshCatalog();
};
window.addEventListener("focus", refreshCatalog);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", refreshCatalog);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isAuthenticated, refreshCatalog]);
useEffect(() => {
if (!isAuthenticated || !payment.commercialOffer) return;
const expiresAtMs = Date.parse(payment.commercialOffer.expiresAt);
if (!Number.isFinite(expiresAtMs)) return;
const timer = window.setTimeout(() => {
setNowMs(Date.now());
refreshCatalog();
}, Math.max(0, expiresAtMs - Date.now()) + 250);
return () => window.clearTimeout(timer);
}, [isAuthenticated, payment.commercialOffer, refreshCatalog]);
const cta = useMemo(
() =>
deriveChatSupportCta({
@@ -1,161 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { useUserSelector } from "@/stores/user/user-context";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import {
getFirstRechargeOfferBannerDismissed,
recordFirstRechargeOfferBannerDismissed,
} from "@/lib/chat/first_recharge_offer_banner";
export interface FirstRechargeOfferBannerEligibility {
historyLoaded: boolean;
loginStatus: LoginStatus;
isFirstRecharge: boolean;
dismissed: boolean;
}
export interface UseFirstRechargeOfferBannerInput {
historyLoaded: boolean;
loginStatus: LoginStatus;
}
export interface UseFirstRechargeOfferBannerOutput {
visible: boolean;
discountPercent: number;
close: () => void;
claim: () => void;
}
interface DismissalState {
userId: string | null;
loaded: boolean;
dismissed: boolean;
}
const FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT = 50;
export function shouldShowFirstRechargeOfferBanner(
input: FirstRechargeOfferBannerEligibility,
): boolean {
if (!input.historyLoaded) return false;
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return false;
}
if (!input.isFirstRecharge) return false;
return !input.dismissed;
}
export function useFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
const navigator = useAppNavigator();
const payment = usePaymentSelector(
(state) => ({
isFirstRecharge: state.context.isFirstRecharge,
status: String(state.value),
}),
shallowEqual,
);
const paymentDispatch = usePaymentDispatch();
const user = useUserSelector(
(state) => ({
countryCode: state.context.currentUser?.countryCode,
id: state.context.currentUser?.id ?? null,
}),
shallowEqual,
);
const isAuthenticatedUser =
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
const userId = user.id;
const hasUserIdentity = !isAuthenticatedUser || Boolean(userId);
const [dismissalState, setDismissalState] = useState<DismissalState>(() => ({
userId,
loaded: false,
dismissed: false,
}));
const discountPercent = FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT;
useEffect(() => {
if (!isAuthenticatedUser) return;
if (payment.status !== "idle") return;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
user.countryCode,
);
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
}, [
payment.status,
paymentDispatch,
isAuthenticatedUser,
user.countryCode,
]);
useEffect(() => {
let cancelled = false;
const loadDismissal = async () => {
const dismissed = await getFirstRechargeOfferBannerDismissed(userId);
if (cancelled) return;
setDismissalState({
userId,
loaded: true,
dismissed,
});
};
void loadDismissal();
return () => {
cancelled = true;
};
}, [userId]);
const dismissalLoaded =
dismissalState.userId === userId && dismissalState.loaded;
const visible =
hasUserIdentity &&
dismissalLoaded &&
shouldShowFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
isFirstRecharge: payment.isFirstRecharge,
dismissed: dismissalState.dismissed,
});
function close(): void {
setDismissalState({
userId,
loaded: true,
dismissed: true,
});
void recordFirstRechargeOfferBannerDismissed(userId);
}
function claim(): void {
navigator.openSubscription({
type: "vip",
returnTo: "chat",
analytics: {
entryPoint: "chat_offer_banner",
triggerReason: "vip_cta",
},
});
}
return {
visible,
discountPercent,
close,
claim,
};
}
@@ -1,6 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommercialOfferSummary } from "@/data/schemas/payment";
const mocks = vi.hoisted(() => {
const characters = [
@@ -48,7 +49,7 @@ const mocks = vi.hoisted(() => {
},
],
isFirstRecharge: false,
commercialOffer: null,
commercialOffer: null as CommercialOfferSummary | null,
selectedPlanId: "vip_monthly",
payChannel: "stripe" as const,
agreed: true,
@@ -216,6 +217,7 @@ describe("SubscriptionScreen payment selection flow", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it("confirms VIP selection before enabling the separate checkout button", () => {
@@ -325,6 +327,74 @@ describe("SubscriptionScreen payment selection flow", () => {
expect(container.textContent).not.toContain(
"Your first recharge price is already applied",
);
expect(container.textContent).not.toContain("50% OFF");
});
it("refreshes the catalog without a stale offer id when the page regains focus", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
mocks.paymentDispatch.mockClear();
act(() => window.dispatchEvent(new Event("focus")));
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentInit",
characterId: "elio",
payChannel: "stripe",
planId: "vip_monthly",
commercialOfferId: null,
});
});
it("describes an account offer without attributing it to the current role", () => {
mocks.payment.commercialOffer = {
enabled: true,
commercialOfferId: "offer-account-1",
characterId: "elio",
planId: "vip_monthly",
discountPercent: 50,
pricePercent: 50,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
triggerReason: "expired_order",
message: "Everything in top-up and VIP is 50% OFF.",
scope: "account",
stage: "halfDiscount",
};
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="maya" />));
expect(container.textContent).toContain("Private offer · 50% OFF");
expect(container.textContent).toContain("Your private price is active");
expect(container.textContent).not.toContain("Maya got this price for you");
});
it("refreshes the catalog when the active offer expires", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-30T08:00:00Z"));
mocks.payment.commercialOffer = {
enabled: true,
commercialOfferId: "offer-expiring-1",
characterId: "elio",
planId: "vip_monthly",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-30T08:00:01Z",
triggerReason: "expired_order",
message: "30% OFF",
scope: "account",
stage: "sevenDiscount",
};
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
mocks.paymentDispatch.mockClear();
act(() => vi.advanceTimersByTime(1_250));
expect(mocks.paymentDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "PaymentInit",
characterId: "elio",
commercialOfferId: null,
}),
);
});
it.each(["elio", "maya", "nayeli"])(
@@ -10,7 +10,6 @@ import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getDefaultSubscriptionPlanId,
getFirstRechargeOfferView,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
toVipOfferPlanViews,
@@ -101,7 +100,7 @@ describe("subscription screen helpers", () => {
]);
});
it("maps first recharge offer fields to plan views", () => {
it("ignores legacy first-recharge flags while retaining quoted and original prices", () => {
const vipViews = toVipOfferPlanViews([
makePlan({
planId: "vip_monthly",
@@ -126,51 +125,18 @@ describe("subscription screen helpers", () => {
}),
]);
expect(vipViews[0]).toMatchObject({
expect(vipViews[0]).toEqual(expect.objectContaining({
price: "9.95",
originalPrice: "19.9",
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
});
expect(coinViews[0]).toMatchObject({
}));
expect(vipViews[0]).not.toHaveProperty("isFirstRechargeOffer");
expect(vipViews[0]).not.toHaveProperty("firstRechargeDiscountPercent");
expect(coinViews[0]).toEqual(expect.objectContaining({
price: "4.95",
originalPrice: "9.9",
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
});
expect(
getFirstRechargeOfferView({
isFirstRecharge: true,
subscriptionType: "vip",
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toMatchObject({
badgeText: "50% OFF",
title: "First Recharge Offer",
renewalNotice:
"First month 50% off. Renews at the regular price from the second month.",
});
expect(
getFirstRechargeOfferView({
isFirstRecharge: true,
subscriptionType: "topup",
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toMatchObject({
badgeText: "50% OFF",
title: "First Recharge Offer",
renewalNotice: null,
});
expect(
getFirstRechargeOfferView({
isFirstRecharge: false,
subscriptionType: "vip",
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toBeNull();
}));
expect(coinViews[0]).not.toHaveProperty("isFirstRechargeOffer");
expect(coinViews[0]).not.toHaveProperty("firstRechargeDiscountPercent");
});
it("maps the backend most popular flag to VIP and coin views", () => {
@@ -191,7 +157,7 @@ describe("subscription screen helpers", () => {
expect(popularCoins[0]?.mostPopular).toBe(true);
});
it("maps normal VIP original price outside first recharge activity", () => {
it("maps regular VIP original price without exposing legacy first-recharge state", () => {
expect(
toVipOfferPlanViews([
makePlan({
@@ -206,7 +172,6 @@ describe("subscription screen helpers", () => {
id: "vip_monthly",
price: "19.9",
originalPrice: "24.99",
isFirstRechargeOffer: false,
},
]);
});
@@ -71,8 +71,6 @@ describe("subscription Tailwind components", () => {
currency: "usd",
originalPrice: "19.99",
mostPopular: true,
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
},
]}
selectedPlanId="vip_monthly"
@@ -96,7 +94,7 @@ describe("subscription Tailwind components", () => {
);
expect(vipHtml).toContain("Most Popular");
expect(vipHtml).toContain("50% OFF");
expect(vipHtml).not.toContain("First Recharge");
expect(coinsHtml).toContain("Most Popular");
});
});
@@ -24,6 +24,7 @@ export interface SubscriptionCheckoutButtonProps {
subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo;
sourceCharacterSlug?: string;
countryCode?: string | null;
}
export function SubscriptionCheckoutButton({
@@ -31,6 +32,7 @@ export function SubscriptionCheckoutButton({
subscriptionType,
returnTo = null,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
countryCode = null,
}: SubscriptionCheckoutButtonProps) {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
@@ -42,25 +44,18 @@ export function SubscriptionCheckoutButton({
returnTo: returnTo ?? undefined,
characterSlug: sourceCharacterSlug,
subscriptionType,
countryCode,
});
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const readyLabel = "Pay and Top Up";
const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: readyLabel;
const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: readyLabel;
const handleClick = () => {
if (isResumingQris) {
paymentLaunch.handleQrisResume();
return;
}
if (disabled || isLoading) return;
paymentLaunch.resetLaunchState();
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
@@ -71,7 +66,7 @@ export function SubscriptionCheckoutButton({
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled && !isResumingQris}
disabled={disabled}
isLoading={isLoading}
onClick={handleClick}
>
@@ -10,9 +10,7 @@ export interface CoinsOfferPlanView {
price: string;
currency: string;
originalPrice?: string;
isFirstRechargeOffer?: boolean;
mostPopular?: boolean;
firstRechargeDiscountPercent?: number | null;
promotionType?: string | null;
}
@@ -56,11 +54,6 @@ export function SubscriptionCoinsOfferSection({
{plan.mostPopular ? (
<span className={styles.popularBadge}>Most Popular</span>
) : null}
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
</span>
<span className={styles.priceGroup}>
<span className={styles.price}>
@@ -8,9 +8,7 @@ export interface VipOfferPlanView {
price: string;
currency: string;
originalPrice: string;
isFirstRechargeOffer?: boolean;
mostPopular?: boolean;
firstRechargeDiscountPercent?: number | null;
promotionType?: string | null;
}
@@ -43,9 +41,7 @@ export function SubscriptionVipOfferSection({
<div className={styles.planGrid}>
{plans.map((plan) => {
const selected = selectedPlanId === plan.id;
const badgeCount =
Number(plan.mostPopular === true) +
Number(plan.isFirstRechargeOffer === true);
const badgeCount = Number(plan.mostPopular === true);
return (
<button
key={plan.id}
@@ -62,11 +58,6 @@ export function SubscriptionVipOfferSection({
{plan.mostPopular ? (
<span className={styles.popularBadge}>Most Popular</span>
) : null}
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
</span>
) : null}
<span className={styles.planTitle}>{plan.title}</span>
@@ -5,17 +5,9 @@ import type { VipOfferPlanView } from "./components/subscription-vip-offer-secti
export type SubscriptionType = "vip" | "topup";
export interface FirstRechargeOfferView {
badgeText: string;
title: string;
subtitle: string;
renewalNotice: string | null;
}
export function isVipPlan(plan: PaymentPlan): boolean {
return plan.vipDays !== null;
}
export function isCreditPlan(plan: PaymentPlan): boolean {
return plan.dolAmount !== null;
}
@@ -60,9 +52,7 @@ export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
price: formatOfferAmount(plan.amountCents),
currency: formatOfferCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
isFirstRechargeOffer: plan.isFirstRechargeOffer,
mostPopular: plan.mostPopular,
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
promotionType: plan.promotionType,
};
}
@@ -74,9 +64,7 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView {
price: formatOfferAmount(plan.amountCents),
currency: formatCoinCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
isFirstRechargeOffer: plan.isFirstRechargeOffer,
mostPopular: plan.mostPopular,
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
promotionType: plan.promotionType,
};
}
@@ -162,30 +150,3 @@ export function getDefaultSubscriptionPlanId(input: {
if (input.selectedPlan !== null) return null;
return input.coinPlans[0]?.id ?? null;
}
export function getFirstRechargeOfferView(input: {
isFirstRecharge: boolean;
subscriptionType: SubscriptionType;
vipPlans: readonly VipOfferPlanView[];
coinPlans: readonly CoinsOfferPlanView[];
}): FirstRechargeOfferView | null {
if (!input.isFirstRecharge) return null;
const promotedPlan = [...input.vipPlans, ...input.coinPlans].find(
(plan) => plan.isFirstRechargeOffer,
);
const discountPercent =
promotedPlan?.firstRechargeDiscountPercent ?? 50;
const badgeText = `${discountPercent}% OFF`;
return {
badgeText,
title: "First Recharge Offer",
subtitle:
"Your first recharge price is already applied. No code needed.",
renewalNotice:
input.subscriptionType === "vip"
? "First month 50% off. Renews at the regular price from the second month."
: null,
};
}
+46 -20
View File
@@ -31,7 +31,6 @@ import styles from "./components/subscription-screen.module.css";
import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getFirstRechargeOfferView,
getDefaultSubscriptionPlanId,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
@@ -104,6 +103,44 @@ export function SubscriptionScreen({
resumeOrderId,
chatActionId,
});
useEffect(() => {
if (!sourceCharacter) return;
const refreshCatalog = () => {
paymentDispatch({
type: "PaymentInit",
characterId: sourceCharacter.id,
payChannel: payment.payChannel,
planId: payment.selectedPlanId || null,
commercialOfferId: null,
});
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") refreshCatalog();
};
window.addEventListener("focus", refreshCatalog);
document.addEventListener("visibilitychange", handleVisibilityChange);
const expiresAtMs = payment.commercialOffer
? Date.parse(payment.commercialOffer.expiresAt)
: Number.NaN;
const expiryTimer = Number.isFinite(expiresAtMs)
? window.setTimeout(
refreshCatalog,
Math.max(0, expiresAtMs - Date.now()) + 250,
)
: null;
return () => {
window.removeEventListener("focus", refreshCatalog);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (expiryTimer !== null) window.clearTimeout(expiryTimer);
};
}, [
payment.commercialOffer,
payment.payChannel,
payment.selectedPlanId,
paymentDispatch,
sourceCharacter,
]);
const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
providedAnalyticsContext ??
@@ -128,22 +165,6 @@ export function SubscriptionScreen({
});
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
const hasFirstRechargeOffer = useMemo(
() =>
getFirstRechargeOfferView({
isFirstRecharge: payment.isFirstRecharge,
subscriptionType,
vipPlans: vipOfferPlans,
coinPlans: directCoinsPlans,
}) !== null,
[
directCoinsPlans,
payment.isFirstRecharge,
subscriptionType,
vipOfferPlans,
],
);
const selectedPlan = findSelectedSubscriptionPlan({
canSubscribeVip,
selectedPlanId: payment.selectedPlanId,
@@ -283,15 +304,19 @@ export function SubscriptionScreen({
</section>
) : null}
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
{payment.commercialOffer && sourceCharacter ? (
<section
className={styles.firstRechargeBanner}
aria-label={`${sourceCharacter.shortName} private offer`}
>
<span className={styles.firstRechargeBadge}>Private offer</span>
<span className={styles.firstRechargeBadge}>
Private offer · {payment.commercialOffer.discountPercent}% OFF
</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>
{sourceCharacter.shortName} got this price for you
{payment.commercialOffer.scope === "account"
? "Your private price is active"
: `${sourceCharacter.shortName} got this price for you`}
</h2>
<p className={styles.firstRechargeSubtitle}>
{payment.commercialOffer.message ||
@@ -337,6 +362,7 @@ export function SubscriptionScreen({
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
countryCode={countryCode}
/>
</div>
+7 -8
View File
@@ -19,6 +19,7 @@ export interface TipCheckoutButtonProps {
disabled?: boolean;
onOrder: () => void;
returnPath: string;
countryCode?: string | null;
}
export function TipCheckoutButton({
@@ -27,6 +28,7 @@ export function TipCheckoutButton({
disabled = false,
onOrder,
returnPath,
countryCode = null,
}: TipCheckoutButtonProps) {
const character = useActiveCharacter();
const payment = usePaymentState();
@@ -40,14 +42,11 @@ export function TipCheckoutButton({
giftCategory,
giftPlanId,
characterSlug: character.slug,
countryCode,
});
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
@@ -62,8 +61,8 @@ export function TipCheckoutButton({
data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip"
className={styles.checkoutButton}
disabled={(disabled && !isResumingQris) || isLoading}
onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
disabled={disabled || isLoading}
onClick={onOrder}
>
{label}
</button>
+1
View File
@@ -305,6 +305,7 @@ export function TipScreen({
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/>
</div>
</>
@@ -206,6 +206,8 @@ describe("PaymentPlansResponse", () => {
expiresAt: "2026-07-24T08:00:00+00:00",
triggerReason: "price_objection",
message: "I got this private price for you.",
scope: "account",
stage: "sevenDiscount",
},
plans: [
{
@@ -229,6 +231,8 @@ describe("PaymentPlansResponse", () => {
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
expect(response.supportCharacterId).toBe("maya-tan");
expect(response.commercialOffer?.characterId).toBe("maya-tan");
expect(response.commercialOffer?.scope).toBe("account");
expect(response.commercialOffer?.stage).toBe("sevenDiscount");
expect(response.plans[0]).toMatchObject({
planId: "vip_annual",
commercialOfferId: "offer-1",
@@ -31,6 +31,8 @@ export const CommercialOfferSummarySchema = z
expiresAt: z.string().min(1),
triggerReason: stringOrEmpty,
message: stringOrEmpty,
scope: z.enum(["account", "character"]).optional(),
stage: z.enum(["sevenDiscount", "halfDiscount"]).optional(),
})
.readonly();
-28
View File
@@ -50,27 +50,6 @@ export class AppStorage {
return SpAsyncUtil.setString(StorageKeys.lastAppInfoReported, todayString);
}
// ---- first recharge offer banner ----
static async getFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<ResultT<boolean>> {
const r = await SpAsyncUtil.getBool(
AppStorage.firstRechargeOfferBannerDismissedKey(userId),
);
if (!r.success) return r;
return Result.ok(r.data === true);
}
static recordFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<ResultT<void>> {
return SpAsyncUtil.setBool(
AppStorage.firstRechargeOfferBannerDismissedKey(userId),
true,
);
}
// ---- internal helpers ----
/**
@@ -89,11 +68,4 @@ export class AppStorage {
return Result.ok(r.data === null || r.data !== todayString);
}
private static firstRechargeOfferBannerDismissedKey(
userId: string | null | undefined,
): string {
return `${StorageKeys.firstRechargeOfferBannerDismissed}:${
userId || "anonymous"
}`;
}
}
-1
View File
@@ -33,7 +33,6 @@ export const StorageKeys = {
pwaDialogShown: "pwa_dialog_shown",
lastPwaEventReported: "last_pwa_event_reported",
lastAppInfoReported: "last_app_info_reported",
firstRechargeOfferBannerDismissed: "first_recharge_offer_banner_dismissed",
// payment
pendingPaymentOrder: "pending_payment_order",
@@ -1,16 +0,0 @@
"use client";
import { AppStorage } from "@/data/storage/app/app_storage";
export async function getFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<boolean> {
const result = await AppStorage.getFirstRechargeOfferBannerDismissed(userId);
return result.success ? result.data : false;
}
export function recordFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<unknown> {
return AppStorage.recordFirstRechargeOfferBannerDismissed(userId);
}
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
getPaymentUrl,
getPaymentUrlHostname,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
@@ -43,36 +44,103 @@ describe("payment launch helpers", () => {
).toBeNull();
});
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
it("prioritizes a hosted GCash URL for Philippine checkout", () => {
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "URL",
payData: "https://pay.example/qris",
}),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
payData: "https://pay.example/gcash",
}, { countryCode: "PH", currency: "PHP" }),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
payData: "00020101021226670016COM.NOBUBANK.WWW",
}),
payData: "000201010212ph-qr-payload",
cashierUrl: "https://pay.example/gcash-hosted",
}, { countryCode: "PH" }),
).toEqual({
kind: "url",
paymentUrl: "https://pay.example/gcash-hosted",
});
});
it("keeps a usable QR Ph fallback without calling it QRIS", () => {
expect(
resolveEzpayLaunchTarget(
{
provider: "ezpay",
channelType: "QR",
payData: "000201010212ph-qr-payload",
},
{ currency: "PHP" },
),
).toEqual({
kind: "qr",
experience: "gcashQrPh",
qrData: "000201010212ph-qr-payload",
});
expect(
resolveEzpayLaunchTarget(
{
provider: "ezpay",
payData: "000201010212ph-qr-payload",
},
{ countryCode: "PH" },
),
).toEqual({
kind: "qr",
experience: "gcashQrPh",
qrData: "000201010212ph-qr-payload",
});
});
it("keeps Indonesian QRIS ahead of an optional hosted URL", () => {
expect(
resolveEzpayLaunchTarget(
{
provider: "ezpay",
channelType: "QR",
payData: "00020101021226670016COM.NOBUBANK.WWW",
cashierUrl: "https://pay.example/indonesia",
},
{ countryCode: "ID", currency: "IDR" },
),
).toEqual({
kind: "qr",
experience: "qris",
qrData: "00020101021226670016COM.NOBUBANK.WWW",
});
});
it("reports a regional error only when both URL and QR data are absent", () => {
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
payData: "",
}),
resolveEzpayLaunchTarget(
{
provider: "ezpay",
channelType: "QR",
payData: "",
},
{ countryCode: "PH" },
),
).toEqual({
kind: "error",
errorMessage: "QRIS payment data is missing. Please try again.",
errorMessage:
"GCash / QR Ph payment data is missing. Please try again.",
});
});
it("keeps diagnostics to the payment URL hostname", () => {
expect(
getPaymentUrlHostname(
"https://pay.example/gcash?token=must-not-appear-in-logs",
),
).toBe("pay.example");
expect(getPaymentUrlHostname("not-a-url")).toBeNull();
});
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
expect(
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
+105 -16
View File
@@ -13,9 +13,20 @@ const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string }
| { kind: "qr"; qrData: string }
| {
kind: "qr";
experience: EzpayQrExperience;
qrData: string;
}
| { kind: "error"; errorMessage: string };
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
export interface ResolveEzpayLaunchContext {
countryCode?: string | null;
currency?: string | null;
}
function getNonEmptyString(
payParams: Record<string, unknown>,
...keys: string[]
@@ -41,6 +52,12 @@ function getHttpPaymentUrl(value: string | null): string | null {
}
}
export function getPaymentUrlHostname(value: string | null): string | null {
const paymentUrl = getHttpPaymentUrl(value);
if (!paymentUrl) return null;
return new URL(paymentUrl).hostname;
}
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [
"cashierUrl",
@@ -71,6 +88,7 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
export function resolveEzpayLaunchTarget(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext = {},
): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString(
payParams,
@@ -79,13 +97,33 @@ export function resolveEzpayLaunchTarget(
);
const channelType = rawChannelType?.toUpperCase() ?? null;
const payData = getNonEmptyString(payParams, "payData", "pay_data");
const payDataUrl = getHttpPaymentUrl(payData);
const namedPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
const paymentUrl = payDataUrl ?? namedPaymentUrl;
const qrData = payData && !payDataUrl ? payData : null;
const region = resolveEzpayRegion(payParams, context);
if (
region === "ph" &&
paymentUrl &&
(channelType === "QR" || channelType === "URL" || channelType === null)
) {
return { kind: "url", paymentUrl };
}
if (channelType === "QR") {
return payData
? { kind: "qr", qrData: payData }
if (qrData) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return paymentUrl
? { kind: "url", paymentUrl }
: {
kind: "error",
errorMessage: "QRIS payment data is missing. Please try again.",
errorMessage: regionalQrMissingMessage(region),
};
}
@@ -97,8 +135,6 @@ export function resolveEzpayLaunchTarget(
}
if (channelType === "URL") {
const paymentUrl =
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
return paymentUrl
? { kind: "url", paymentUrl }
: {
@@ -114,14 +150,66 @@ export function resolveEzpayLaunchTarget(
};
}
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
return legacyPaymentUrl
? { kind: "url", paymentUrl: legacyPaymentUrl }
: {
kind: "error",
errorMessage:
"Ezpay payment parameters did not include a supported URL or QRIS code.",
};
if (paymentUrl) return { kind: "url", paymentUrl };
if (qrData && region !== null) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return {
kind: "error",
errorMessage:
"Ezpay payment parameters did not include a supported URL or payment QR code.",
};
}
function resolveEzpayRegion(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext,
): "ph" | "id" | null {
const currency = (
context.currency ?? getNonEmptyString(payParams, "currency")
)
?.trim()
.toUpperCase();
if (currency === "PHP") return "ph";
if (currency === "IDR") return "id";
const countryCode = (
context.countryCode ??
getNonEmptyString(
payParams,
"purchaseCountryCode",
"purchase_country_code",
"countryCode",
"country_code",
)
)
?.trim()
.toUpperCase();
if (countryCode === "PH") return "ph";
if (countryCode === "ID") return "id";
return null;
}
function resolveEzpayQrExperience(
region: "ph" | "id" | null,
): EzpayQrExperience {
if (region === "ph") return "gcashQrPh";
if (region === "id") return "qris";
return "paymentQr";
}
function regionalQrMissingMessage(region: "ph" | "id" | null): string {
if (region === "ph") {
return "GCash / QR Ph payment data is missing. Please try again.";
}
if (region === "id") {
return "QRIS payment data is missing. Please try again.";
}
return "Payment QR data is missing. Please try again.";
}
export function getStripeClientSecret(
@@ -166,18 +254,19 @@ export async function launchEzpayRedirect({
onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrl,
paymentUrlHost,
});
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType,
paymentUrl,
paymentUrlHost,
errorMessage,
});
onFailed(errorMessage);