Compare commits
38 Commits
2e04260ebd
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3e256a8c | |||
| 3547f49bd9 | |||
| ee6ab77f4d | |||
| ce3f0af82d | |||
| 638fc054b1 | |||
| 897bce11db | |||
| 1466cb01d2 | |||
| f6c75dcb86 | |||
| 187de82eaf | |||
| d372541925 | |||
| ef9b79bc83 | |||
| aee35353a3 | |||
| fcb2492118 | |||
| cab2ba868f | |||
| dab9e2e45a | |||
| 54ce485f41 | |||
| e36f94add3 | |||
| ca9cac21f1 | |||
| 662e5e67ea | |||
| f403c47052 | |||
| ac36837add | |||
| fe9d31146b | |||
| 01c55f5630 | |||
| b21ef2f6ec | |||
| a44fc4860e | |||
| ff2759cf0f | |||
| d746d04503 | |||
| 2a90b4d9ec | |||
| 9094bcbab0 | |||
| 29abd746da | |||
| 556bfd2919 | |||
| e3ef289b53 | |||
| 46588bd98c | |||
| 05f625dd0b | |||
| 3790fb813f | |||
| a55a59bff4 | |||
| d2a46ff6b9 | |||
| a4c4ca6666 |
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- pre
|
||||
- test
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
DEPLOY_ENV="prod"
|
||||
NEXT_ENV_FILE=".env.production"
|
||||
;;
|
||||
pre)
|
||||
test)
|
||||
DEPLOY_ENV="test"
|
||||
NEXT_ENV_FILE=".env.local"
|
||||
;;
|
||||
|
||||
@@ -72,12 +72,12 @@ copy_env_by_branch() {
|
||||
START_PORT=9185
|
||||
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
||||
;;
|
||||
pre)
|
||||
test)
|
||||
cp -f env-example/.env.local.example .env.local
|
||||
ENV_FILE=".env.local"
|
||||
DEPLOY_ENV="test"
|
||||
START_PORT=9135
|
||||
echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
|
||||
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
||||
;;
|
||||
dev)
|
||||
cp -f env-example/.env.development.example .env.local
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
||||
|
||||
当前 remote 配置示例:
|
||||
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
||||
|
||||
## 本地发布流程
|
||||
|
||||
旧预发环境发布流程(脚本迁移前):
|
||||
旧测试环境发布流程(脚本迁移前):
|
||||
|
||||
1. 进入 pre worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
|
||||
1. 进入 test worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test`
|
||||
2. 确认当前分支。
|
||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
|
||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。
|
||||
4. 复制测试环境图标到 `public/`。
|
||||
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
||||
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
|
||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。
|
||||
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
||||
|
||||
旧生产环境发布流程(脚本迁移前):
|
||||
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
||||
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
||||
| `pre` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||
| `test` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
||||
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
||||
|
||||
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
|
||||
|
||||
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
||||
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
||||
3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
|
||||
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
||||
4. 生产推送当前使用 `git push --force production main`。
|
||||
5. 构建失败时不会重启容器,旧容器继续运行。
|
||||
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
||||
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
||||
8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
| Workflow | 触发时机 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||
| `.gitea/workflows/docker-image.yml` | `main`、`pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||
| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||
|
||||
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
||||
|
||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
|
||||
分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test`
|
||||
分支为快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||
`ci.yml` 会同时执行完整检查并提供测试结果。
|
||||
|
||||
## 必需 Secrets
|
||||
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
|
||||
|
||||
## 镜像 Tag 规则
|
||||
|
||||
`pre` 分支发布时继续使用现有 `test-*` 运行镜像标签:
|
||||
`test` 分支发布:
|
||||
|
||||
```bash
|
||||
REGISTRY_IMAGE:test-<short_sha>
|
||||
@@ -81,7 +81,7 @@ volumes:
|
||||
|
||||
## 验证方式
|
||||
|
||||
推送到 `pre` 分支后,检查 Actions 日志中是否出现:
|
||||
推送到 `test` 分支后,检查 Actions 日志中是否出现:
|
||||
|
||||
```text
|
||||
Build Docker image
|
||||
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
|
||||
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
||||
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
||||
|
||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
当前推荐部署链路:
|
||||
|
||||
```text
|
||||
push pre/main
|
||||
push test/main
|
||||
→ Gitea Actions 构建并推送 Docker 镜像
|
||||
→ Actions 通过 SSH 登录目标服务器
|
||||
→ 目标服务器 docker pull 指定镜像 tag
|
||||
@@ -16,7 +16,7 @@ push pre/main
|
||||
|
||||
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
||||
|
||||
## Gitea Secrets
|
||||
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
|
||||
|
||||
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
||||
|
||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||
|
||||
## 本地发布入口
|
||||
|
||||
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
||||
|
||||
```bash
|
||||
# 预发环境
|
||||
# 测试环境
|
||||
./scripts/release/pre_release_web.sh
|
||||
|
||||
# 生产环境
|
||||
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
|
||||
也可以直接推送:
|
||||
|
||||
```bash
|
||||
git push gitea pre
|
||||
git push gitea test
|
||||
git push --force gitea main
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
预发环境:
|
||||
测试环境:
|
||||
|
||||
```bash
|
||||
ssh <user>@<pre-host>
|
||||
ssh <user>@<test-host>
|
||||
cd /opt/cozsweet-web-test
|
||||
docker compose ps
|
||||
curl -I http://127.0.0.1:9135/
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
|
||||
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
||||
|
||||
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
|
||||
request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
|
||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier
|
||||
on every pull request and `dev` / `test` / `main` push.
|
||||
|
||||
## Environment overrides
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
# 推送指定分支到指定远端。
|
||||
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
||||
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
||||
# deploy_web_test.sh(pre)→ push_to_remote "gitea" "pre"
|
||||
# deploy_web_test.sh(test)→ push_to_remote "gitea" "test"
|
||||
push_to_remote() {
|
||||
local remote="$1"
|
||||
local branch="$2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ==========================================
|
||||
# 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
||||
# ==========================================
|
||||
|
||||
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
push_to_remote "gitea" "pre"
|
||||
push_to_remote "gitea" "test"
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
@@ -11,9 +11,9 @@ unset_git_hook_env() {
|
||||
|
||||
unset_git_hook_env
|
||||
|
||||
# pre 分支 worktree 路径
|
||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
|
||||
echo "=== 进入 pre 分支 worktree ==="
|
||||
# Test 分支 worktree 路径
|
||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test"
|
||||
echo "=== 进入 test 分支 worktree ==="
|
||||
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
||||
|
||||
echo "=== 检查当前分支 ==="
|
||||
@@ -22,17 +22,17 @@ git branch --show-current
|
||||
echo "=== 变基到 dev 分支 ==="
|
||||
git rebase dev
|
||||
|
||||
# 复制预发环境图标(运行资源目录仍为 icons/test)
|
||||
echo "=== 复制预发环境图标(icons/test → public/) ==="
|
||||
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/)
|
||||
echo "=== 复制测试环境图标(icons/test → public/) ==="
|
||||
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
||||
|
||||
# 准备预发环境变量(运行配置仍使用 .env.local)
|
||||
echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local) ==="
|
||||
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local)
|
||||
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local) ==="
|
||||
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
||||
|
||||
echo "=== 执行构建部署脚本(预发环境)==="
|
||||
echo "=== 执行构建部署脚本(测试环境)==="
|
||||
./scripts/deploy/deploy_web_test.sh
|
||||
|
||||
echo "=== 预发布完成 ==="
|
||||
|
||||
@@ -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", {
|
||||
return new Intl.NumberFormat(
|
||||
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
|
||||
{
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||
}).format(amountCents / 100);
|
||||
},
|
||||
).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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
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}
|
||||
>
|
||||
|
||||
@@ -337,6 +337,7 @@ export function SubscriptionScreen({
|
||||
subscriptionType={subscriptionType}
|
||||
returnTo={returnTo}
|
||||
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -305,6 +305,7 @@ export function TipScreen({
|
||||
disabled={!canCreateOrder}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
countryCode={userState.currentUser?.countryCode}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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: "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({
|
||||
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" }),
|
||||
|
||||
@@ -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,16 +150,68 @@ export function resolveEzpayLaunchTarget(
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return legacyPaymentUrl
|
||||
? { kind: "url", paymentUrl: legacyPaymentUrl }
|
||||
: {
|
||||
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 QRIS code.",
|
||||
"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(
|
||||
payParams: Record<string, unknown>,
|
||||
): string | null {
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user