From 1e25279e8f158ea2064a5be1e7992027cea44359 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 18:57:22 +0800 Subject: [PATCH 01/28] feat(payment): support Indonesia QRIS checkout --- e2e/specs/mock/payment/indonesia-qris.spec.ts | 253 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 12 + .../__tests__/payment-launch-dialogs.test.tsx | 62 ++++- .../payment-method-selector.test.tsx | 21 ++ .../payment/payment-launch-dialogs.tsx | 112 +++++++- .../payment/payment-method-selector.tsx | 15 +- src/app/_hooks/use-payment-launch-flow.ts | 159 +++++++++-- .../subscription-checkout-button.tsx | 2 +- src/app/subscription/subscription-screen.tsx | 13 +- src/app/tip/tip-checkout-button.tsx | 2 +- src/app/tip/tip-screen.tsx | 7 +- .../payment/__tests__/payment_launch.test.ts | 46 ++++ .../payment/__tests__/payment_method.test.ts | 28 +- src/lib/payment/default_pay_channel.ts | 5 +- src/lib/payment/payment_launch.ts | 85 ++++++ src/lib/payment/payment_method.ts | 19 +- .../__tests__/payment-order-flow.test.ts | 5 +- src/stores/payment/machine/order-flow.ts | 1 - 19 files changed, 806 insertions(+), 42 deletions(-) create mode 100644 e2e/specs/mock/payment/indonesia-qris.spec.ts diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts new file mode 100644 index 00000000..2b03a8f4 --- /dev/null +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -0,0 +1,253 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { e2eEmailUser } from "@e2e/fixtures/data/user"; +import { + clearBrowserState, + seedEmailSession, +} from "@e2e/fixtures/test-helpers"; + +const idrPlans = { + plans: [ + { + planId: "vip_monthly", + planName: "Monthly", + orderType: "vip_monthly", + amountCents: 19_590_000, + originalAmountCents: 19_590_000, + dailyPriceCents: 653_000, + currency: "IDR", + vipDays: 30, + dolAmount: null, + creditBalance: 3_000, + }, + { + planId: "dol_1000", + planName: "1,000 Credits", + orderType: "dol", + amountCents: 8_890_000, + originalAmountCents: null, + dailyPriceCents: null, + currency: "IDR", + vipDays: null, + dolAmount: 1_000, + creditBalance: 1_000, + }, + ], +}; + +const idrGiftCatalog = { + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], + plans: [ + { + planId: "tip_coffee_usd_4_99", + planName: "Velvet Espresso", + orderType: "tip", + tipType: "coffee_small", + category: "coffee", + characterId: "elio", + description: "Buy Elio a Velvet Espresso", + imageUrl: null, + amountCents: 8_929_900, + currency: "IDR", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + ], +}; + +async function registerIndonesiaPaymentMocks(page: Page) { + const orderStatuses = new Map(); + const orderPlans = new Map(); + + await page.route("**/api/user/profile", async (route) => { + await route.fulfill({ + json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }), + }); + }); + await page.route("**/api/payment/plans**", async (route) => { + await route.fulfill({ json: apiEnvelope(idrPlans) }); + }); + await page.route("**/api/payment/gift-products**", async (route) => { + await route.fulfill({ json: apiEnvelope(idrGiftCatalog) }); + }); + await page.route("**/api/payment/create-order", async (route) => { + const body = route.request().postDataJSON() as { planId: string }; + const plan = + idrPlans.plans.find((item) => item.planId === body.planId) ?? + idrGiftCatalog.plans.find((item) => item.planId === body.planId); + const orderId = `order_qris_${body.planId}`; + orderStatuses.set(orderId, "pending"); + orderPlans.set(orderId, { + planId: body.planId, + orderType: plan?.orderType ?? "dol", + }); + await route.fulfill({ + json: apiEnvelope({ + orderId, + payParams: { + provider: "ezpay", + countryCode: "ID", + channelCode: "ID_QRIS_DYNAMIC_QR", + channelType: "QR", + payData: "00020101021226670016COM.NOBUBANK.WWW", + firstChargeAmountCents: plan?.amountCents ?? 0, + currency: "IDR", + }, + }), + }); + }); + await page.route("**/api/payment/order-status**", async (route) => { + const url = new URL(route.request().url()); + const orderId = url.searchParams.get("order_id") ?? ""; + const plan = orderPlans.get(orderId); + await route.fulfill({ + json: apiEnvelope({ + orderId, + status: orderStatuses.get(orderId) ?? "pending", + orderType: plan?.orderType ?? "dol", + planId: plan?.planId ?? null, + creditsAdded: 0, + }), + }); + }); + await page.route("**/api/payment/tip-message", async (route) => { + const body = route.request().postDataJSON() as { orderId: string }; + await route.fulfill({ + json: apiEnvelope({ + orderId: body.orderId, + characterId: "elio", + planId: "tip_coffee_usd_4_99", + productName: "Velvet Espresso", + tipCount: 1, + poolIndex: 0, + message: "Thank you. Your gift made me smile.", + }), + }); + }); + + return { + markPaid(orderId: string) { + orderStatuses.set(orderId, "paid"); + }, + }; +} + +async function prepareIndonesiaUser(page: Page) { + await seedEmailSession(page); + await page.evaluate(() => { + const rawUser = localStorage.getItem("cozsweet:user"); + const user = rawUser ? JSON.parse(rawUser) : {}; + localStorage.setItem( + "cozsweet:user", + JSON.stringify({ ...user, countryCode: "ID" }), + ); + }); +} + +async function expectQrisOrder( + page: Page, + buttonName: RegExp, + planId: string, + formattedAmount: RegExp, +) { + const requestPromise = page.waitForRequest("**/api/payment/create-order"); + await page.getByRole("button", { name: buttonName }).click(); + const request = await requestPromise; + expect(request.postDataJSON()).toMatchObject({ + planId, + payChannel: "ezpay", + }); + + const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(formattedAmount); + await expect(dialog).toContainText("Waiting for payment"); + await expect( + dialog.getByRole("img", { name: "QRIS payment QR code" }), + ).toBeVisible(); + return `order_qris_${planId}`; +} + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page); +}); + +test("Indonesia VIP defaults to QRIS and completes after status polling", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/subscription?type=vip"); + + await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute( + "aria-pressed", + "true", + ); + const orderId = await expectQrisOrder( + page, + /Pay and Activ/i, + "vip_monthly", + /Rp\s*195[.,]900/, + ); + payment.markPaid(orderId); + + const success = page.getByRole("alertdialog", { name: "Payment successful" }); + await expect(success).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByRole("dialog", { name: "Scan to pay with QRIS" }), + ).toBeHidden(); +}); + +test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/subscription?type=topup"); + + const orderId = await expectQrisOrder( + page, + /Pay and Top Up/i, + "dol_1000", + /Rp\s*88[.,]900/, + ); + payment.markPaid(orderId); + + await expect( + page.getByRole("alertdialog", { name: "Payment successful" }), + ).toBeVisible({ timeout: 10_000 }); +}); + +test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/characters/elio/tip"); + + await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible(); + const orderId = await expectQrisOrder( + page, + /Order and Buy/i, + "tip_coffee_usd_4_99", + /Rp\s*89[.,]299/, + ); + 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(); +}); diff --git a/package.json b/package.json index 41e30358..a759bb15 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "next-auth": "^4.24.14", "ofetch": "^1.5.1", "pino": "^10.3.1", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "^5.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc6b8da..615cf8ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -3290,6 +3293,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7085,6 +7093,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} diff --git a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx index 55be9ac1..5ab418d0 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -6,10 +6,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs"; const hiddenLaunch = { handleEzpayCancel: vi.fn(), handleEzpayConfirm: vi.fn(), + handleQrisClose: vi.fn(), handleStripeClose: vi.fn(), handleStripeConfirmed: vi.fn(), isConfirmingEzpay: false, + qrisErrorMessage: null, + qrisPayment: null, + qrisStatus: null, shouldShowEzpayConfirmDialog: false, + shouldShowQrisDialog: false, shouldShowStripeDialog: false, stripeClientSecret: null, }; @@ -43,10 +48,65 @@ describe("PaymentLaunchDialogs", () => { ); expect(html).toContain('role="alertdialog"'); - expect(html).toContain("Continue to GCash?"); + expect(html).toContain("Continue to payment?"); expect(html).toContain("Your coffee order is ready."); expect(html).toContain("Order No. order-123"); expect(html).toContain('data-analytics-key="tip.external_checkout"'); expect(html).toContain("Opening..."); }); + + it("renders an accessible QRIS dialog with display cents and order status", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('role="dialog"'); + expect(html).toContain("Scan to pay with QRIS"); + expect(html).toContain("QRIS payment QR code"); + expect(html).toContain("Order No. order-id-qris"); + expect(html).toContain("Rp"); + expect(html).toContain("50.000"); + expect(html).toContain("Waiting for payment"); + }); + + it("shows QRIS failure state and handles empty QR data safely", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("QRIS data is unavailable"); + expect(html).toContain("Payment failed or was cancelled."); + expect(html).toContain("Close"); + }); }); diff --git a/src/app/_components/payment/__tests__/payment-method-selector.test.tsx b/src/app/_components/payment/__tests__/payment-method-selector.test.tsx index 6f0e6ed0..21cba41e 100644 --- a/src/app/_components/payment/__tests__/payment-method-selector.test.tsx +++ b/src/app/_components/payment/__tests__/payment-method-selector.test.tsx @@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => { ); }); + it("labels Indonesia Ezpay as QRIS without the GCash logo", () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).toContain('aria-label="QRIS"'); + expect(html).toContain("QRIS by default in Indonesia"); + expect(html).not.toContain("gcash-logo.svg"); + }); + it("renders compact controls without the caption", () => { const html = renderToStaticMarkup( ; @@ -46,6 +52,14 @@ export function PaymentLaunchDialogs({ onConfirm={launch.handleEzpayConfirm} /> ) : null} + {launch.shouldShowQrisDialog && launch.qrisPayment ? ( + + ) : null} {launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( void; + payment: NonNullable; + status: PaymentLaunchFlow["qrisStatus"]; +} + +function formatQrisAmount(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); + } catch { + return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`; + } +} + +function qrisStatusMessage( + status: PaymentLaunchFlow["qrisStatus"], + errorMessage: string | null, +): string { + if (status === "failed") return errorMessage || "Payment failed. Please try again."; + if (status === "expired") return errorMessage || "This QRIS order has expired."; + return "Waiting for payment"; +} + +function QrisPaymentDialog({ + errorMessage, + onClose, + payment, + status, +}: QrisPaymentDialogProps) { + const titleId = useId(); + const statusMessage = qrisStatusMessage(status, errorMessage); + + return ( +
+
+
+

+ Scan to pay with QRIS +

+

+ Open a QRIS-compatible banking or wallet app and scan this code. +

+
+
+ {payment.qrData ? ( + + ) : ( +

+ QRIS data is unavailable. Please close this dialog and try again. +

+ )} +
+
+

Order No. {payment.orderId}

+

+ {formatQrisAmount(payment.amountCents, payment.currency)} +

+

+ {statusMessage} +

+
+
+ +
+
+
+ ); +} + interface EzpayRedirectConfirmDialogProps { description: ReactNode; externalCheckoutAnalyticsKey: string; @@ -87,7 +197,7 @@ function EzpayRedirectConfirmDialog({

- Continue to GCash? + Continue to payment?

{description}

Order No. {orderId}

diff --git a/src/app/_components/payment/payment-method-selector.tsx b/src/app/_components/payment/payment-method-selector.tsx index 2e709a37..bf7f7077 100644 --- a/src/app/_components/payment/payment-method-selector.tsx +++ b/src/app/_components/payment/payment-method-selector.tsx @@ -44,13 +44,24 @@ export function PaymentMethodSelector({ }: PaymentMethodSelectorProps) { if (!config.showPaymentMethodSelector) return null; const isCompact = density === "compact"; + const ezpayDisplayName = config.ezpayDisplayName ?? "GCash"; + const configuredPaymentMethods = PAYMENT_METHODS.map((method) => + method.channel === "ezpay" + ? { + ...method, + title: ezpayDisplayName, + logoSrc: + ezpayDisplayName === "GCash" ? method.logoSrc : undefined, + } + : method, + ); const paymentMethods = config.initialPayChannel === "ezpay" - ? [...PAYMENT_METHODS].sort((a, b) => + ? [...configuredPaymentMethods].sort((a, b) => a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0, ) - : PAYMENT_METHODS; + : configuredPaymentMethods; return (
void; handleEzpayConfirm: () => void; + handleQrisClose: () => void; handleStripeClose: () => void; handleStripeConfirmed: () => void; isConfirmingEzpay: boolean; + qrisErrorMessage: string | null; + qrisPayment: QrisPaymentDetails | null; + qrisStatus: PaymentContextState["orderStatus"]; resetLaunchState: () => void; shouldShowEzpayConfirmDialog: boolean; + shouldShowQrisDialog: boolean; shouldShowStripeDialog: boolean; stripeClientSecret: string | null; } +export interface QrisPaymentDetails { + amountCents: number; + currency: string; + orderId: string; + qrData: string; +} + +function paymentParamString( + payParams: Record, + ...keys: string[] +): string | null { + for (const key of keys) { + const value = payParams[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function paymentParamAmountCents( + payParams: Record, +): number | null { + for (const key of [ + "firstChargeAmountCents", + "first_charge_amount_cents", + "amountCents", + "amount_cents", + ]) { + const value = payParams[key]; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + } + return null; +} + function trackPaymentCheckoutOpened( payment: PaymentContextState, checkoutUrl: string, @@ -118,12 +159,40 @@ export function usePaymentLaunchFlow({ string | null >(null); const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); + const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState( + null, + ); const stripeClientSecret = payment.payParams ? getStripeClientSecret(payment.payParams) : null; - const ezpayPaymentUrl = payment.payParams - ? getPaymentUrl(payment.payParams) - : null; + const ezpayLaunchTarget = + payment.payParams && isEzpayPayment(payment.payParams) + ? resolveEzpayLaunchTarget(payment.payParams) + : null; + const ezpayPaymentUrl = + ezpayLaunchTarget?.kind === "url" + ? ezpayLaunchTarget.paymentUrl + : null; + const selectedPlan = payment.plans.find( + (item) => item.planId === payment.selectedPlanId, + ); + const qrisPayment: QrisPaymentDetails | null = + payment.payParams && + payment.currentOrderId && + ezpayLaunchTarget?.kind === "qr" + ? { + amountCents: + paymentParamAmountCents(payment.payParams) ?? + selectedPlan?.amountCents ?? + 0, + currency: + paymentParamString(payment.payParams, "currency") ?? + selectedPlan?.currency ?? + "IDR", + orderId: payment.currentOrderId, + qrData: ezpayLaunchTarget.qrData, + } + : null; useEffect(() => { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { return; @@ -136,11 +205,33 @@ export function usePaymentLaunchFlow({ return; } - const paymentUrl = getPaymentUrl(payment.payParams); - if (paymentUrl) { - const isEzpay = isEzpayPayment(payment.payParams); + const isEzpay = isEzpayPayment(payment.payParams); + if (isEzpay) { + const target = resolveEzpayLaunchTarget(payment.payParams); + if (target.kind === "error") { + trackPaymentCheckoutFailed(payment, "missing_checkout_url"); + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: target.errorMessage, + }); + return; + } - if (!AppEnvUtil.isProduction() && isEzpay) { + if (target.kind === "qr") { + if (!payment.currentOrderId) { + trackPaymentCheckoutFailed(payment, "missing_checkout_url"); + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: "Missing order id before showing QRIS.", + }); + return; + } + trackPaymentCheckoutOpened(payment, "qris_embedded"); + return; + } + + const paymentUrl = target.paymentUrl; + if (!AppEnvUtil.isProduction()) { log.debug(`[${logScope}] ezpay confirmation required`, { orderId: payment.currentOrderId, paymentUrl, @@ -149,24 +240,25 @@ export function usePaymentLaunchFlow({ return; } - if (isEzpay) { - void launchEzpayRedirect({ - orderId: payment.currentOrderId, - paymentUrl, - subscriptionType, - giftCategory, - giftPlanId, - ...(returnTo ? { returnTo } : {}), - ...(characterSlug ? { characterSlug } : {}), - onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), - onFailed: (errorMessage) => { - trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); - paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); - }, - }); - return; - } + void launchEzpayRedirect({ + orderId: payment.currentOrderId, + paymentUrl, + subscriptionType, + giftCategory, + giftPlanId, + ...(returnTo ? { returnTo } : {}), + ...(characterSlug ? { characterSlug } : {}), + onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), + onFailed: (errorMessage) => { + trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); + paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); + }, + }); + return; + } + const paymentUrl = getPaymentUrl(payment.payParams); + if (paymentUrl) { try { window.location.href = paymentUrl; trackPaymentCheckoutOpened(payment, paymentUrl); @@ -213,10 +305,16 @@ export function usePaymentLaunchFlow({ payParams: payment.payParams, paymentUrl: ezpayPaymentUrl, }); + const shouldShowQrisDialog = Boolean( + qrisPayment && + qrisPayment.orderId !== hiddenQrisOrderId && + !payment.isPaid, + ); function resetLaunchState(): void { setIsConfirmingEzpay(false); setHiddenStripeClientSecret(null); + setHiddenQrisOrderId(null); } function handleStripeClose(): void { @@ -263,15 +361,28 @@ export function usePaymentLaunchFlow({ paymentDispatch({ type: "PaymentReset" }); } + function handleQrisClose(): void { + log.debug(`[${logScope}] qris dialog closed`, { + orderId: qrisPayment?.orderId ?? payment.currentOrderId, + subscriptionType, + }); + setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId); + } + return { ezpayPaymentUrl, handleEzpayCancel, handleEzpayConfirm, + handleQrisClose, handleStripeClose, handleStripeConfirmed, isConfirmingEzpay, + qrisErrorMessage: payment.errorMessage, + qrisPayment, + qrisStatus: payment.orderStatus, resetLaunchState, shouldShowEzpayConfirmDialog, + shouldShowQrisDialog, shouldShowStripeDialog, stripeClientSecret, }; diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 12c595b6..83b52f93 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -95,7 +95,7 @@ export function SubscriptionCheckoutButton({ diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 960c12d3..8b339eb5 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -16,6 +16,7 @@ import { type PaymentAnalyticsContext, } from "@/lib/analytics"; import { getPaymentMethodConfig } from "@/lib/payment/payment_method"; +import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { useUserState } from "@/stores/user/user-context"; import { @@ -55,11 +56,15 @@ export function SubscriptionScreen({ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, }: SubscriptionScreenProps) { const userState = useUserState(); + const hasHydrated = useHasHydrated(); const countryCode = userState.currentUser?.countryCode; const paymentMethodConfig = getPaymentMethodConfig({ countryCode, requestedPayChannel: initialPayChannel, }); + const renderedPaymentMethodConfig = hasHydrated + ? paymentMethodConfig + : { ...paymentMethodConfig, showPaymentMethodSelector: false }; const { payment, paymentDispatch, @@ -225,10 +230,14 @@ export function SubscriptionScreen({
diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 99eeb078..478e1575 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -14,6 +14,7 @@ import { type PaymentAnalyticsContext, } from "@/lib/analytics"; import { getPaymentMethodConfig } from "@/lib/payment/payment_method"; +import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { buildTipGiftPath } from "@/lib/tip/tip_gift"; import { useActiveCharacter, @@ -50,11 +51,15 @@ export function TipScreen({ const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); const userState = useUserState(); + const hasHydrated = useHasHydrated(); const supportPrompt = useTipSupportPrompt(); const paymentMethodConfig = getPaymentMethodConfig({ countryCode: userState.currentUser?.countryCode, requestedPayChannel: initialPayChannel, }); + const renderedPaymentMethodConfig = hasHydrated + ? paymentMethodConfig + : { ...paymentMethodConfig, showPaymentMethodSelector: false }; const { payment, paymentDispatch } = usePaymentRouteFlow({ catalog: "tip", characterId: character.id, @@ -281,7 +286,7 @@ export function TipScreen({ { @@ -42,6 +43,51 @@ describe("payment launch helpers", () => { ).toBeNull(); }); + it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => { + expect( + resolveEzpayLaunchTarget({ + provider: "ezpay", + channelType: "URL", + payData: "https://pay.example/qris", + }), + ).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" }); + expect( + resolveEzpayLaunchTarget({ + provider: "ezpay", + channelType: "QR", + payData: "00020101021226670016COM.NOBUBANK.WWW", + }), + ).toEqual({ + kind: "qr", + qrData: "00020101021226670016COM.NOBUBANK.WWW", + }); + expect( + resolveEzpayLaunchTarget({ + provider: "ezpay", + channelType: "QR", + payData: "", + }), + ).toEqual({ + kind: "error", + errorMessage: "QRIS payment data is missing. Please try again.", + }); + }); + + it("rejects VA and unknown Ezpay channel types with explicit errors", () => { + expect( + resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }), + ).toEqual({ + kind: "error", + errorMessage: "Virtual account payments are not supported in this app.", + }); + expect( + resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "OTHER" }), + ).toEqual({ + kind: "error", + errorMessage: "Unsupported Ezpay payment channel: OTHER.", + }); + }); + it("reports a launch failure before redirecting without an order id", async () => { const onOpened = vi.fn(); const onFailed = vi.fn(); diff --git a/src/lib/payment/__tests__/payment_method.test.ts b/src/lib/payment/__tests__/payment_method.test.ts index f507a8ef..c6f78052 100644 --- a/src/lib/payment/__tests__/payment_method.test.ts +++ b/src/lib/payment/__tests__/payment_method.test.ts @@ -12,18 +12,21 @@ describe("payment method rules", () => { vi.unstubAllEnvs(); }); - it("defaults Philippines users to GCash and other countries to Stripe", () => { + it("defaults Philippines and Indonesia users to their regional Ezpay method", () => { expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay"); expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay"); + expect(getDefaultPayChannelForCountryCode("ID")).toBe("ezpay"); + expect(getDefaultPayChannelForCountryCode(" id ")).toBe("ezpay"); expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe"); expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe"); }); - it("only allows Philippines users to choose in production", () => { + it("allows Philippines and Indonesia users to choose in production", () => { vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production"); expect(canChoosePayChannel("PH")).toBe(true); expect(canChoosePayChannel("ph")).toBe(true); + expect(canChoosePayChannel("ID")).toBe(true); expect(canChoosePayChannel("HK")).toBe(false); expect(canChoosePayChannel(null)).toBe(false); expect( @@ -35,6 +38,7 @@ describe("payment method rules", () => { canChoosePaymentMethod: false, initialPayChannel: "stripe", showPaymentMethodSelector: false, + ezpayDisplayName: "GCash", }); }); @@ -52,6 +56,25 @@ describe("payment method rules", () => { ).toBe("stripe"); }); + it("defaults Indonesia to QRIS but permits an explicit Stripe choice", () => { + vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production"); + + expect( + getPaymentMethodConfig({ + countryCode: "ID", + requestedPayChannel: null, + }), + ).toEqual({ + canChoosePaymentMethod: true, + initialPayChannel: "ezpay", + showPaymentMethodSelector: true, + ezpayDisplayName: "QRIS", + }); + expect( + resolvePayChannel({ countryCode: "ID", requestedPayChannel: "stripe" }), + ).toBe("stripe"); + }); + it("keeps test channels but shows the selector only for Philippines", () => { vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test"); @@ -71,6 +94,7 @@ describe("payment method rules", () => { canChoosePaymentMethod: true, initialPayChannel: "ezpay", showPaymentMethodSelector: false, + ezpayDisplayName: "GCash", }); expect( getPaymentMethodConfig({ diff --git a/src/lib/payment/default_pay_channel.ts b/src/lib/payment/default_pay_channel.ts index b332a40a..288dd556 100644 --- a/src/lib/payment/default_pay_channel.ts +++ b/src/lib/payment/default_pay_channel.ts @@ -3,5 +3,8 @@ import type { PayChannel } from "@/data/schemas/payment"; export function getDefaultPayChannelForCountryCode( countryCode: string | null | undefined, ): PayChannel { - return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe"; + const normalizedCountryCode = countryCode?.trim().toUpperCase(); + return normalizedCountryCode === "PH" || normalizedCountryCode === "ID" + ? "ezpay" + : "stripe"; } diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 4e9cd13d..71b93286 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -11,6 +11,36 @@ import { const log = new Logger("LibPaymentPaymentLaunch"); +export type EzpayLaunchTarget = + | { kind: "url"; paymentUrl: string } + | { kind: "qr"; qrData: string } + | { kind: "error"; errorMessage: string }; + +function getNonEmptyString( + payParams: Record, + ...keys: string[] +): string | null { + for (const key of keys) { + const value = payParams[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +function getHttpPaymentUrl(value: string | null): string | null { + if (!value) return null; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? value + : null; + } catch { + return null; + } +} + export function getPaymentUrl(payParams: Record): string | null { const keys = [ "cashierUrl", @@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record): boolean { return typeof provider === "string" && provider.toLowerCase() === "ezpay"; } +export function resolveEzpayLaunchTarget( + payParams: Record, +): EzpayLaunchTarget { + const rawChannelType = getNonEmptyString( + payParams, + "channelType", + "channel_type", + ); + const channelType = rawChannelType?.toUpperCase() ?? null; + const payData = getNonEmptyString(payParams, "payData", "pay_data"); + + if (channelType === "QR") { + return payData + ? { kind: "qr", qrData: payData } + : { + kind: "error", + errorMessage: "QRIS payment data is missing. Please try again.", + }; + } + + if (channelType === "VA") { + return { + kind: "error", + errorMessage: "Virtual account payments are not supported in this app.", + }; + } + + if (channelType === "URL") { + const paymentUrl = + getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams)); + return paymentUrl + ? { kind: "url", paymentUrl } + : { + kind: "error", + errorMessage: "Ezpay payment URL is missing or invalid. Please try again.", + }; + } + + if (channelType) { + return { + kind: "error", + errorMessage: `Unsupported Ezpay payment channel: ${channelType}.`, + }; + } + + const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams)); + return legacyPaymentUrl + ? { kind: "url", paymentUrl: legacyPaymentUrl } + : { + kind: "error", + errorMessage: + "Ezpay payment parameters did not include a supported URL or QRIS code.", + }; +} + export function getStripeClientSecret( payParams: Record, ): string | null { diff --git a/src/lib/payment/payment_method.ts b/src/lib/payment/payment_method.ts index 3dba5018..5537f109 100644 --- a/src/lib/payment/payment_method.ts +++ b/src/lib/payment/payment_method.ts @@ -5,6 +5,7 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel"; export interface PaymentMethodConfig { canChoosePaymentMethod: boolean; + ezpayDisplayName?: "GCash" | "QRIS"; initialPayChannel: PayChannel; showPaymentMethodSelector: boolean; } @@ -13,7 +14,7 @@ export function canChoosePayChannel( countryCode: string | null | undefined, ): boolean { if (!AppEnvUtil.isProduction()) return true; - return isPhilippinesCountryCode(countryCode); + return isRegionalEzpayCountryCode(countryCode); } export function resolvePayChannel(input: { @@ -33,13 +34,23 @@ export function getPaymentMethodConfig(input: { }): PaymentMethodConfig { return { canChoosePaymentMethod: canChoosePayChannel(input.countryCode), + ezpayDisplayName: isIndonesiaCountryCode(input.countryCode) + ? "QRIS" + : "GCash", initialPayChannel: resolvePayChannel(input), - showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode), + showPaymentMethodSelector: isRegionalEzpayCountryCode(input.countryCode), }; } -function isPhilippinesCountryCode( +function isRegionalEzpayCountryCode( countryCode: string | null | undefined, ): boolean { - return countryCode?.trim().toUpperCase() === "PH"; + const normalizedCountryCode = countryCode?.trim().toUpperCase(); + return normalizedCountryCode === "PH" || normalizedCountryCode === "ID"; +} + +function isIndonesiaCountryCode( + countryCode: string | null | undefined, +): boolean { + return countryCode?.trim().toUpperCase() === "ID"; } diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index e8bd325f..c7256c87 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -256,7 +256,10 @@ describe("payment order flow", () => { expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "expired", - payParams: null, + payParams: { + provider: "stripe", + clientSecret: "pi_test_secret_test", + }, errorMessage: "This payment order has expired. Please create a new order.", }); diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index 98f5931e..475e9417 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -156,7 +156,6 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ orderStatus: event.output.status, - payParams: null, orderPollingStartedAt: null, errorMessage: "This payment order has expired. Please create a new order.", })); From e1454cd0021cbe89a184d2ffe0a3de2bb76fb528 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:26:57 +0800 Subject: [PATCH 02/28] fix(ci): use reachable pnpm registry for Docker builds --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 12a30c52..9e7224d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,13 @@ ENV PNPM_HOME=/pnpm ENV PNPM_STORE_PATH=/pnpm/store ENV PATH=$PNPM_HOME:$PATH +ARG NPM_REGISTRY=https://registry.npmmirror.com + RUN apk add --no-cache libc6-compat \ && corepack enable \ && corepack prepare pnpm@10.30.3 --activate \ - && pnpm config set store-dir "$PNPM_STORE_PATH" + && pnpm config set store-dir "$PNPM_STORE_PATH" \ + && pnpm config set registry "$NPM_REGISTRY" FROM base AS deps COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ From 4fceab537d1e3f5976b65ad103541f006e64f5dd Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:29:42 +0800 Subject: [PATCH 03/28] fix(ci): install pnpm from configured registry --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9e7224d5..0dfffe2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,7 @@ ENV PATH=$PNPM_HOME:$PATH ARG NPM_REGISTRY=https://registry.npmmirror.com RUN apk add --no-cache libc6-compat \ - && corepack enable \ - && corepack prepare pnpm@10.30.3 --activate \ + && npm install --global "pnpm@10.30.3" --registry="$NPM_REGISTRY" \ && pnpm config set store-dir "$PNPM_STORE_PATH" \ && pnpm config set registry "$NPM_REGISTRY" From 40184c76319ecb2aadeb4286b9f3e39449ec793a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:32:16 +0800 Subject: [PATCH 04/28] docs(private-zoom): record verified rollout [skip ci] --- .../2026-07-22-private-zoom-rename.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/frontend-integration/2026-07-22-private-zoom-rename.md b/docs/frontend-integration/2026-07-22-private-zoom-rename.md index 999e59b5..bbd7dcff 100644 --- a/docs/frontend-integration/2026-07-22-private-zoom-rename.md +++ b/docs/frontend-integration/2026-07-22-private-zoom-rename.md @@ -2,7 +2,7 @@ - 日期:2026-07-22 - 目标环境:本地、`pre`、生产 -- 当前状态:已实现待联调 +- 当前状态:已验证 ## 1. 目标 @@ -174,6 +174,10 @@ database/private-zoom-migration.sql - 前端:TypeScript 类型检查通过;Vitest `639 passed`;ESLint 通过;契约测试 `4 passed`;生产构建通过并生成三个角色的 `/private-zoom` 页面。 - Manager:`75 passed`。 - PostgreSQL 16 临时库:升级、重复升级、回滚和再次升级全部通过;历史解锁、偏好键、积分账本、埋点、媒体路径和数据库对象名均通过断言,临时容器已删除。 +- `pre`:unified `71da49b` 和前端 `35939e7` 已验证;新 API 已注册 `8` 条 `/api/private-zoom/*` 路径,旧 API 路径数量为 `0`;新页面返回 `200`,旧页面返回 `404`。 +- 生产:unified 镜像 `ai-boyfriend-unified:prod-20260722-71da49b`(镜像 ID `sha256:755ef5741856d83d7d808e7df8ee259b6fa5fb94fe333b523ee280ed1d5f674b`)和前端镜像 `prod-35939e7` 已健康运行;Manager 当前版本为 `6eea005`。 +- 生产接口:测试账号调用 `GET https://api.banlv-ai.com/api/private-zoom/config?characterId=elio` 返回 `200`,`success=true`;旧 `/api/private-room/*` 路径在所有公开 API 入口均返回 `404`。 +- 生产数据库:最终迁移已执行,旧表、桥接函数、桥接触发器、旧偏好键、旧积分分类和旧埋点值的残留数量均为 `0`。 ## 11. 回滚影响 @@ -181,5 +185,4 @@ database/private-zoom-migration.sql ## 12. 待确认事项 -- `pre` 浏览器真实联调和生产发布尚未执行。 -- 生产镜像 ID 将在发布前固化到发布记录。 +- 无。 From 4e71dbd5f80e309cc3c8ac2e1003bbc830bbcbf2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:34:28 +0800 Subject: [PATCH 05/28] docs(private-zoom): remove legacy literal [skip ci] --- docs/frontend-integration/2026-07-22-private-zoom-rename.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-integration/2026-07-22-private-zoom-rename.md b/docs/frontend-integration/2026-07-22-private-zoom-rename.md index bbd7dcff..af19d0d2 100644 --- a/docs/frontend-integration/2026-07-22-private-zoom-rename.md +++ b/docs/frontend-integration/2026-07-22-private-zoom-rename.md @@ -176,7 +176,7 @@ database/private-zoom-migration.sql - PostgreSQL 16 临时库:升级、重复升级、回滚和再次升级全部通过;历史解锁、偏好键、积分账本、埋点、媒体路径和数据库对象名均通过断言,临时容器已删除。 - `pre`:unified `71da49b` 和前端 `35939e7` 已验证;新 API 已注册 `8` 条 `/api/private-zoom/*` 路径,旧 API 路径数量为 `0`;新页面返回 `200`,旧页面返回 `404`。 - 生产:unified 镜像 `ai-boyfriend-unified:prod-20260722-71da49b`(镜像 ID `sha256:755ef5741856d83d7d808e7df8ee259b6fa5fb94fe333b523ee280ed1d5f674b`)和前端镜像 `prod-35939e7` 已健康运行;Manager 当前版本为 `6eea005`。 -- 生产接口:测试账号调用 `GET https://api.banlv-ai.com/api/private-zoom/config?characterId=elio` 返回 `200`,`success=true`;旧 `/api/private-room/*` 路径在所有公开 API 入口均返回 `404`。 +- 生产接口:测试账号调用 `GET https://api.banlv-ai.com/api/private-zoom/config?characterId=elio` 返回 `200`,`success=true`;旧版 API 路径在所有公开 API 入口均返回 `404`。 - 生产数据库:最终迁移已执行,旧表、桥接函数、桥接触发器、旧偏好键、旧积分分类和旧埋点值的残留数量均为 `0`。 ## 11. 回滚影响 From 163ba78f061b7fefcfbb4165ff7c333ff0ccdb23 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:31:06 +0800 Subject: [PATCH 06/28] feat(navigation): add favorite entry and Menu tab --- .../mock/favorite-menu-navigation.spec.ts | 198 ++++++++++++++++++ .../__tests__/tailwind-components.test.tsx | 4 + .../core/app-bottom-nav.module.css | 2 +- src/app/_components/core/app-bottom-nav.tsx | 17 +- .../core/favorite-entry-button.module.css | 96 +++++++++ .../core/favorite-entry-button.tsx | 107 ++++++++++ src/app/_components/core/index.ts | 1 + src/app/chat/chat-screen.tsx | 9 +- .../__tests__/tailwind-components.test.tsx | 46 ++-- src/app/chat/components/chat-header.tsx | 68 ++---- .../first-recharge-offer-banner.module.css | 77 ++++++- .../first-recharge-offer-banner.tsx | 24 +++ .../external-entry/external-entry-persist.tsx | 10 + src/app/external-entry/page.tsx | 1 + .../private-zoom-screen.module.css | 7 + src/app/private-zoom/private-zoom-screen.tsx | 20 +- .../components/profile-screen.module.css | 21 +- src/app/profile/profile-screen.tsx | 29 ++- .../components/splash-screen.module.css | 7 + src/app/splash/splash-screen.tsx | 22 +- src/lib/chat/chat_external_browser.ts | 33 +-- .../__tests__/favorite_entry.test.ts | 62 ++++++ src/lib/navigation/favorite_entry.ts | 51 +++++ 23 files changed, 794 insertions(+), 118 deletions(-) create mode 100644 e2e/specs/mock/favorite-menu-navigation.spec.ts create mode 100644 src/app/_components/core/favorite-entry-button.module.css create mode 100644 src/app/_components/core/favorite-entry-button.tsx create mode 100644 src/lib/navigation/__tests__/favorite_entry.test.ts create mode 100644 src/lib/navigation/favorite_entry.ts diff --git a/e2e/specs/mock/favorite-menu-navigation.spec.ts b/e2e/specs/mock/favorite-menu-navigation.spec.ts new file mode 100644 index 00000000..aa6a1708 --- /dev/null +++ b/e2e/specs/mock/favorite-menu-navigation.spec.ts @@ -0,0 +1,198 @@ +import path from "node:path"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { e2eEmailUser } from "@e2e/fixtures/data/auth"; +import { chatCharactersResponse } from "@e2e/fixtures/data/character"; +import { + emptyChatHistoryResponse, + emptyChatPreviewsResponse, +} from "@e2e/fixtures/data/chat"; +import { paymentPlansResponse } from "@e2e/fixtures/data/payment"; +import { userEntitlementsResponse } from "@e2e/fixtures/data/user"; +import { + defaultCharacterChatPath, + defaultCharacterSplashPath, + seedEmailSession, +} from "@e2e/fixtures/helpers/auth"; + +const previewDirectory = path.join( + process.cwd(), + ".codex", + "artifacts", + "frontend-preview", + "2026-07-22", + "favorite-menu", +); + +test.beforeEach(async ({ page }) => { + await registerFavoriteMenuMocks(page); +}); + +test("favorite entry and three-tab Menu navigation render on the real pages", async ({ + page, +}, testInfo) => { + const mobile = testInfo.project.name.includes("mobile"); + await page.setViewportSize( + mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + const browserErrors = collectBrowserErrors(page); + + await seedEmailSession(page); + await page.goto(defaultCharacterChatPath); + + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect( + page.getByRole("button", { name: /First recharge offer, 50% off/i }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Profile" })).toHaveCount(0); + await savePreview(page, `${mobile ? "mobile" : "desktop"}-chat.png`); + + await page.goto(defaultCharacterSplashPath); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + const navigation = page.getByRole("navigation", { + name: "Primary navigation", + }); + await expect(navigation).toContainText("Chat"); + await expect(navigation).toContainText("Elio Private Zoom"); + await expect(navigation).toContainText("Menu"); + await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`); + + await page.goto("/characters/elio/private-zoom"); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + await savePreview( + page, + `${mobile ? "mobile" : "desktop"}-private-zoom.png`, + ); + + await page.getByRole("button", { name: "Menu" }).click(); + await expect(page.getByRole("heading", { name: "Menu" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Menu" })).toHaveAttribute( + "aria-current", + "page", + ); + await savePreview(page, `${mobile ? "mobile" : "desktop"}-menu.png`); + + if (mobile) { + await page.evaluate(() => { + localStorage.setItem("cozsweet:favorite_entry", "1"); + }); + await page.goto(defaultCharacterChatPath); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + await savePreview(page, "mobile-android-external-chat-download.png"); + + await page.addInitScript(() => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + }); + }); + await page.goto(defaultCharacterChatPath); + await expect(page.getByRole("button", { name: "Saved" })).toBeVisible(); + await savePreview(page, "mobile-ios-external-chat-saved.png"); + } + + expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]); +}); + +async function registerFavoriteMenuMocks(page: Page): Promise { + await mockCoreApis(page); + await page.route("**/api/payment/plans**", async (route) => { + await route.fulfill({ + json: apiEnvelope({ + ...paymentPlansResponse, + isFirstRecharge: true, + }), + }); + }); + await page.route("**/api/private-zoom/albums**", async (route) => { + await route.fulfill({ + json: apiEnvelope({ items: [], creditBalance: 80 }), + }); + }); + await page.context().route("**/api/**", async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === "/api/auth/session") { + await route.fulfill({ + json: { + expires: "2099-12-31T23:59:59.000Z", + user: { email: null, image: null, name: null }, + }, + }); + return; + } + if (pathname === "/api/characters") { + await route.fulfill({ json: apiEnvelope(chatCharactersResponse) }); + return; + } + if (pathname === "/api/user/profile") { + await route.fulfill({ json: apiEnvelope(e2eEmailUser) }); + return; + } + if (pathname === "/api/user/entitlements") { + await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) }); + return; + } + if (pathname === "/api/chat/history") { + await route.fulfill({ json: apiEnvelope(emptyChatHistoryResponse) }); + return; + } + if (pathname === "/api/chat/previews") { + await route.fulfill({ json: apiEnvelope(emptyChatPreviewsResponse) }); + return; + } + if (pathname === "/api/payment/plans") { + await route.fulfill({ + json: apiEnvelope({ + ...paymentPlansResponse, + isFirstRecharge: true, + }), + }); + return; + } + if (pathname === "/api/payment/vip-status") { + await route.fulfill({ + json: apiEnvelope({ isVip: false, expiresAt: null }), + }); + return; + } + if (pathname === "/api/private-zoom/albums") { + await route.fulfill({ + json: apiEnvelope({ items: [], creditBalance: 80 }), + }); + return; + } + await route.fallback(); + }); +} + +function collectBrowserErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + return errors; +} + +function isFeatureRuntimeError(message: string): boolean { + const isKnownLocalPreviewDependencyError = + message.includes("[ApiLoggingInterceptor]") || + message.includes("[next-auth][error][CLIENT_FETCH_ERROR]") || + message.includes("[Result] Result.wrap caught exception"); + return !isKnownLocalPreviewDependencyError; +} + +async function savePreview(page: Page, fileName: string): Promise { + if (process.env.FRONTEND_PREVIEW_SCREENSHOTS !== "1") return; + await page.screenshot({ + path: path.join(previewDirectory, fileName), + animations: "disabled", + }); +} diff --git a/src/app/_components/core/__tests__/tailwind-components.test.tsx b/src/app/_components/core/__tests__/tailwind-components.test.tsx index 523c999a..9efededa 100644 --- a/src/app/_components/core/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/core/__tests__/tailwind-components.test.tsx @@ -82,6 +82,7 @@ describe("core Tailwind components", () => { privateZoomLabel="Maya Private Zoom" onChatClick={() => undefined} onPrivateZoomClick={() => undefined} + onMenuClick={() => undefined} />, ); const privateZoomHtml = renderToStaticMarkup( @@ -91,6 +92,7 @@ describe("core Tailwind components", () => { privateZoomLabel="Maya Private Zoom" onChatClick={() => undefined} onPrivateZoomClick={() => undefined} + onMenuClick={() => undefined} />, ); @@ -107,5 +109,7 @@ describe("core Tailwind components", () => { expect(privateZoomHtml).toContain( 'data-analytics-key="navigation.private_zoom"', ); + expect(chatHtml).toContain('data-analytics-key="navigation.menu"'); + expect(chatHtml).toContain("Menu"); }); }); diff --git a/src/app/_components/core/app-bottom-nav.module.css b/src/app/_components/core/app-bottom-nav.module.css index 6b3cc2e0..a4fb6b30 100644 --- a/src/app/_components/core/app-bottom-nav.module.css +++ b/src/app/_components/core/app-bottom-nav.module.css @@ -4,7 +4,7 @@ left: 50%; z-index: 30; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); box-sizing: border-box; width: min(100vw, var(--app-max-width, 540px)); min-height: calc( diff --git a/src/app/_components/core/app-bottom-nav.tsx b/src/app/_components/core/app-bottom-nav.tsx index e10c0277..ab66cdc3 100644 --- a/src/app/_components/core/app-bottom-nav.tsx +++ b/src/app/_components/core/app-bottom-nav.tsx @@ -1,10 +1,10 @@ "use client"; -import { Camera, MessageCircle } from "lucide-react"; +import { Camera, Menu as MenuIcon, MessageCircle } from "lucide-react"; import styles from "./app-bottom-nav.module.css"; -export type AppBottomNavItem = "chat" | "privateZoom"; +export type AppBottomNavItem = "chat" | "privateZoom" | "menu"; export type AppBottomNavVariant = "warm" | "dark"; export interface AppBottomNavProps { @@ -13,6 +13,7 @@ export interface AppBottomNavProps { privateZoomLabel: string; onChatClick: () => void; onPrivateZoomClick: () => void; + onMenuClick: () => void; } export function AppBottomNav({ @@ -21,6 +22,7 @@ export function AppBottomNav({ privateZoomLabel, onChatClick, onPrivateZoomClick, + onMenuClick, }: AppBottomNavProps) { return ( ); } diff --git a/src/app/_components/core/favorite-entry-button.module.css b/src/app/_components/core/favorite-entry-button.module.css new file mode 100644 index 00000000..6aeb012f --- /dev/null +++ b/src/app/_components/core/favorite-entry-button.module.css @@ -0,0 +1,96 @@ +.wrap { + position: relative; + display: inline-flex; + flex: 0 0 auto; + justify-content: flex-end; +} + +.button { + display: inline-flex; + min-width: var(--responsive-icon-button-size, 42px); + height: var(--responsive-icon-button-size, 42px); + align-items: center; + justify-content: center; + gap: 6px; + box-sizing: border-box; + padding: 0 11px; + border: 1px solid var(--favorite-border); + border-radius: 999px; + background: var(--favorite-background); + color: var(--favorite-color); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 850; + line-height: 1; + box-shadow: var(--favorite-shadow); + backdrop-filter: blur(16px); + transition: background 160ms ease, transform 160ms ease, + box-shadow 160ms ease; + -webkit-tap-highlight-color: transparent; +} + +.dark { + --favorite-background: rgba(18, 15, 24, 0.72); + --favorite-border: rgba(255, 255, 255, 0.22); + --favorite-color: #ffffff; + --favorite-shadow: 0 10px 26px rgba(0, 0, 0, 0.24); +} + +.light { + --favorite-background: rgba(255, 255, 255, 0.88); + --favorite-border: rgba(43, 27, 34, 0.1); + --favorite-color: #34272c; + --favorite-shadow: 0 10px 24px rgba(74, 48, 58, 0.1); +} + +.favorite { + width: var(--responsive-icon-button-size, 42px); + padding: 0; +} + +.download { + color: #ffffff; + background: linear-gradient(135deg, #ff76ab, #f84d96); + border-color: rgba(255, 255, 255, 0.42); + box-shadow: 0 10px 25px rgba(248, 77, 150, 0.3); +} + +.saved { + color: #f84d96; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.2); +} + +.button:active { + transform: translateY(1px) scale(0.98); +} + +.button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 3px; +} + +.light:focus-visible { + outline-color: #f84d96; +} + +.hint { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 45; + width: max-content; + max-width: min(72vw, 250px); + padding: 8px 10px; + border-radius: 10px; + background: rgba(22, 18, 25, 0.94); + color: #ffffff; + font-size: 11px; + font-weight: 700; + line-height: 1.3; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24); +} diff --git a/src/app/_components/core/favorite-entry-button.tsx b/src/app/_components/core/favorite-entry-button.tsx new file mode 100644 index 00000000..d8585669 --- /dev/null +++ b/src/app/_components/core/favorite-entry-button.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Download, Star } from "lucide-react"; + +import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser"; +import { + hasPersistedFavoriteEntryIntent, + resolveFavoriteEntryMode, + type FavoriteEntryMode, +} from "@/lib/navigation/favorite_entry"; +import { BrowserDetector } from "@/utils/browser-detect"; +import { PlatformDetector } from "@/utils/platform-detect"; +import { pwaUtil } from "@/utils/pwa"; + +import styles from "./favorite-entry-button.module.css"; + +export interface FavoriteEntryButtonProps { + characterSlug: string; + tone?: "dark" | "light"; +} + +export function FavoriteEntryButton({ + characterSlug, + tone = "dark", +}: FavoriteEntryButtonProps) { + const [mode, setMode] = useState("favorite"); + const [installHint, setInstallHint] = useState(false); + + useEffect(() => { + const updateMode = () => { + setMode( + resolveFavoriteEntryMode({ + hasFavoriteIntent: hasPersistedFavoriteEntryIntent(), + isAndroid: PlatformDetector.isAndroid(), + isIOS: PlatformDetector.isIOS(), + isInAppBrowser: BrowserDetector.isInAppBrowser(), + isInstalled: pwaUtil.isInstalled(), + }), + ); + }; + + pwaUtil.prepareInstallPrompt(); + updateMode(); + const unsubscribe = pwaUtil.subscribe(updateMode); + return unsubscribe; + }, []); + + const handleClick = async (): Promise => { + if (mode === "favorite") { + await openChatInExternalBrowser({ characterSlug }); + return; + } + + if (mode === "download") { + pwaUtil.prepareInstallPrompt(); + const result = await pwaUtil.install(); + if (result === "accepted" || pwaUtil.isInstalled()) { + setMode("saved"); + setInstallHint(false); + return; + } + if (result === "unavailable") setInstallHint(true); + } + }; + + const label = getFavoriteEntryLabel(mode); + + return ( +
+ + {installHint ? ( + + Chrome menu → Add to Home screen + + ) : null} +
+ ); +} + +function getFavoriteEntryLabel(mode: FavoriteEntryMode): string { + if (mode === "download") return "Download"; + if (mode === "saved") return "Saved"; + return "Save CozSweet"; +} diff --git a/src/app/_components/core/index.ts b/src/app/_components/core/index.ts index 67cf80af..1a699fac 100644 --- a/src/app/_components/core/index.ts +++ b/src/app/_components/core/index.ts @@ -5,6 +5,7 @@ export * from "./app-bottom-nav"; export * from "./bottom-sheet"; export * from "./checkbox"; +export * from "./favorite-entry-button"; export * from "./loading-indicator"; export * from "./mobile-shell"; export * from "./page-loading-fallback"; diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 6ef95cfe..d9b60a99 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -38,7 +38,6 @@ import { } from "./chat-image-overlay-url"; import { deriveIsGuest, - shouldStartExternalBrowserPrompt, } from "./chat-screen.helpers"; import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator"; @@ -118,12 +117,6 @@ export function ChatScreen() { }); const shouldShowPwaInstall = state.historyLoaded && state.historyMessages.length >= 10; - const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({ - hasInitialized: authState.hasInitialized, - isLoading: authState.isLoading, - loginStatus: authState.loginStatus, - }); - useChatGuestLogin({ dispatch: authDispatch, hasInitialized: authState.hasInitialized, @@ -276,13 +269,13 @@ export function ChatScreen() {
} /> diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index 835a5f85..90c95fe0 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -242,12 +242,6 @@ describe("chat Tailwind components", () => { const memberHtml = renderWithCharacter( Offer
} />, ); - const memberWithHintHtml = renderWithCharacter( - , - ); - const guestWithHintHtml = renderWithCharacter( - , - ); expect(guestHtml).toContain('aria-label="Sign up to unlock more features"'); expect(guestHtml).toContain("bg-accent"); @@ -257,50 +251,33 @@ describe("chat Tailwind components", () => { expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(guestHtml).toContain("px-(--chat-inline-padding,16px)"); expect(guestHtml).not.toContain('aria-label="Profile"'); + expect(guestHtml).toContain('aria-label="Save CozSweet"'); expect(memberHtml).toContain("Offer"); expect(memberHtml).toContain('href="/characters/elio/splash"'); expect(memberHtml).toContain('aria-label="Back to home"'); - expect(memberHtml).toContain('aria-label="Profile"'); - expect(memberHtml).toContain('data-analytics-key="chat.open_profile"'); + expect(memberHtml).toContain('aria-label="Save CozSweet"'); + expect(memberHtml).toContain('data-analytics-key="favorite.favorite"'); expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(memberHtml).toContain("px-(--chat-inline-padding,16px)"); expect(memberHtml).not.toContain("px-(--spacing-md,12px)"); - expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]"); - expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); - expect(memberHtml).toContain("lucide-user-round"); - expect(memberWithHintHtml).toContain( - 'data-analytics-key="chat.external_browser_hint"', - ); - expect(memberWithHintHtml).toContain( + expect(memberHtml).toContain( "grid-cols-[auto_minmax(0,1fr)_auto]", ); - expect(memberWithHintHtml.indexOf('aria-label="Back to home"')).toBeLessThan( - memberWithHintHtml.indexOf( - 'data-analytics-key="chat.external_browser_hint"', - ), - ); - expect( - memberWithHintHtml.indexOf( - 'data-analytics-key="chat.external_browser_hint"', - ), - ).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"')); - expect(guestWithHintHtml).not.toContain( + expect(memberHtml).not.toContain( 'data-analytics-key="chat.external_browser_hint"', ); }); - it("renders the user avatar in the ChatHeader Profile action", () => { + it("replaces the ChatHeader Profile avatar with the favorite action", () => { userMocks.avatarUrl = "/images/avatar/profile-user.png"; const html = renderWithCharacter(); - expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png"); - expect(html).toContain('aria-label="Profile"'); - expect(html).toContain('data-analytics-key="chat.open_profile"'); - expect(html).toContain( - "var(--responsive-icon-button-size, 42px)", - ); - expect(html).not.toContain("lucide-user-round"); + expect(html).not.toContain("%2Fimages%2Favatar%2Fprofile-user.png"); + expect(html).not.toContain('aria-label="Profile"'); + expect(html).not.toContain('data-analytics-key="chat.open_profile"'); + expect(html).toContain('aria-label="Save CozSweet"'); + expect(html).toContain("lucide-star"); }); it("links guest chat back to the active character splash", () => { @@ -315,6 +292,7 @@ describe("chat Tailwind components", () => { expect(html).toContain('href="/characters/maya/splash"'); expect(html).not.toContain('aria-label="Profile"'); + expect(html).toContain('aria-label="Save CozSweet"'); expect(html).not.toContain( 'data-analytics-key="chat.external_browser_hint"', ); diff --git a/src/app/chat/components/chat-header.tsx b/src/app/chat/components/chat-header.tsx index 97566b34..f2a9350e 100644 --- a/src/app/chat/components/chat-header.tsx +++ b/src/app/chat/components/chat-header.tsx @@ -2,37 +2,31 @@ /** * ChatHeader 顶部栏 * - * Profile 优先展示用户头像;头像缺失时回退到 lucide-react 。 + * 顶部保持返回、首充优惠、收藏入口三段结构。 */ import type { ReactNode } from "react"; -import { Lock, UserRound } from "lucide-react"; +import { Lock } from "lucide-react"; -import { BackButton, UserMessageAvatar } from "@/app/_components"; -import { useActiveCharacterRoutes } from "@/providers/character-provider"; -import { buildGlobalPageUrl } from "@/router/global-route-context"; -import { ROUTES } from "@/router/routes"; +import { BackButton } from "@/app/_components"; +import { FavoriteEntryButton } from "@/app/_components/core"; +import { + useActiveCharacter, + useActiveCharacterRoutes, +} from "@/providers/character-provider"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { useUserSelector } from "@/stores/user/user-context"; - -import { BrowserHintOverlay } from "./browser-hint-overlay"; export interface ChatHeaderProps { isGuest: boolean; offerBanner?: ReactNode; - showBrowserHint?: boolean; } export function ChatHeader({ isGuest, offerBanner, - showBrowserHint = false, }: ChatHeaderProps) { const navigator = useAppNavigator(); + const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); - const avatarUrl = useUserSelector((state) => state.context.avatarUrl); - const handleOpenProfile = () => { - navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat)); - }; return (
@@ -52,17 +46,9 @@ export function ChatHeader({ /> Sign up to unlock more features - ) : ( - offerBanner - )} + ) : null} -
+
- {!isGuest ? ( - <> -
- {showBrowserHint ? : null} -
+
{offerBanner}
- {avatarUrl ? ( - - ) : ( - - )} - - ) : null} +
); diff --git a/src/app/chat/components/first-recharge-offer-banner.module.css b/src/app/chat/components/first-recharge-offer-banner.module.css index cbfa29e7..20fc2f46 100644 --- a/src/app/chat/components/first-recharge-offer-banner.module.css +++ b/src/app/chat/components/first-recharge-offer-banner.module.css @@ -121,8 +121,73 @@ cursor: pointer; } +.compactButton { + display: grid; + min-width: 0; + max-width: 224px; + min-height: 44px; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 5px 8px; + 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: 28px; + height: 28px; + 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: 10px; + 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: 15px; + font-weight: 950; + letter-spacing: -0.02em; +} + .contentButton:focus-visible, -.closeButton:focus-visible { +.closeButton:focus-visible, +.compactButton:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.94); outline-offset: -3px; } @@ -144,4 +209,14 @@ flex-direction: column; gap: 2px; } + + .compactButton { + gap: 5px; + padding-inline: 6px; + } + + .compactIcon { + width: 26px; + height: 26px; + } } diff --git a/src/app/chat/components/first-recharge-offer-banner.tsx b/src/app/chat/components/first-recharge-offer-banner.tsx index dfb3f7bc..298de0ab 100644 --- a/src/app/chat/components/first-recharge-offer-banner.tsx +++ b/src/app/chat/components/first-recharge-offer-banner.tsx @@ -9,6 +9,7 @@ export interface FirstRechargeOfferBannerProps { discountPercent: number; onClick: () => void; onClose: () => void; + variant?: "banner" | "compact"; } export function FirstRechargeOfferBanner({ @@ -16,9 +17,32 @@ export function FirstRechargeOfferBanner({ discountPercent, onClick, onClose, + variant = "banner", }: FirstRechargeOfferBannerProps) { if (!visible) return null; + if (variant === "compact") { + return ( + + ); + } + return (
) : null} + + + navigator.push(navigation.splashUrl, { scroll: false }) + } + onPrivateZoomClick={() => + navigator.push(characterRoutes.privateZoom, { scroll: false }) + } + onMenuClick={() => undefined} + />
); diff --git a/src/app/splash/components/splash-screen.module.css b/src/app/splash/components/splash-screen.module.css index b1db338e..0b3aad53 100644 --- a/src/app/splash/components/splash-screen.module.css +++ b/src/app/splash/components/splash-screen.module.css @@ -22,6 +22,13 @@ pointer-events: none; } +.favoriteAction { + position: absolute; + top: calc(var(--page-padding-y, 20px) + var(--app-safe-top, 0px)); + right: calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)); + z-index: 4; +} + .content { position: relative; z-index: 2; diff --git a/src/app/splash/splash-screen.tsx b/src/app/splash/splash-screen.tsx index 16b1555c..bc350def 100644 --- a/src/app/splash/splash-screen.tsx +++ b/src/app/splash/splash-screen.tsx @@ -2,7 +2,13 @@ import { useEffect } from "react"; -import { AppBottomNav, MobileShell } from "@/app/_components/core"; +import { + AppBottomNav, + FavoriteEntryButton, + MobileShell, +} from "@/app/_components/core"; +import { buildGlobalPageUrl } from "@/router/global-route-context"; +import { ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator"; import { useActiveCharacter, @@ -45,6 +51,13 @@ export function SplashScreen() { navigator.push(characterRoutes.splash, { scroll: false }); }; + const handleOpenMenu = () => { + navigator.push( + buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat), + { scroll: false }, + ); + }; + useEffect(() => { pwaUtil.prepareInstallPrompt(); }, []); @@ -55,6 +68,12 @@ export function SplashScreen() { {/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}