From 089259e5e0e8007175a21ceae375a85a8f517e78 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Jul 2026 12:59:25 +0800 Subject: [PATCH] fix(payment): keep character and mobile actions visible --- .../checkout-handoff-external-browser.spec.ts | 22 +++++++++---- .../external-browser-checkout-button.test.tsx | 3 +- .../external-browser-checkout-button.tsx | 27 ++++++++++++++-- .../external-entry/external-entry-persist.tsx | 3 +- .../subscription-checkout-button.tsx | 21 ++++++------ .../components/subscription-screen.module.css | 22 +++++++++---- src/app/subscription/subscription-screen.tsx | 32 ++++++++++--------- .../__tests__/external_entry.test.ts | 17 ++++++---- src/lib/navigation/external_entry.ts | 4 +++ 9 files changed, 103 insertions(+), 48 deletions(-) diff --git a/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts b/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts index 9fab3307..e1128f9e 100644 --- a/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts +++ b/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts @@ -13,7 +13,10 @@ const facebookAndroidUserAgent = "[FB_IAB/FB4A;FBAV/566.0.0.48.73;]"; const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters"; -test.use({ userAgent: facebookAndroidUserAgent }); +test.use({ + userAgent: facebookAndroidUserAgent, + viewport: { width: 390, height: 844 }, +}); test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); @@ -34,12 +37,12 @@ test("consumes the handoff, removes its token, and does not create an order", as ); await page.goto( - `/external-entry?target=checkout&handoffToken=${encodeURIComponent(handoffToken)}`, + `/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`, ); expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken }); await expect(page).toHaveURL( - /\/subscription\?planId=vip_monthly&autoRenew=1&commercialOfferId=.*&payChannel=stripe/, + /\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/, ); expect(page.url()).not.toContain("handoffToken"); await expect.poll(() => createOrderRequests).toBe(0); @@ -74,17 +77,24 @@ test("offers the Facebook external-browser path before creating a Stripe order", }); await seedEmailSession(page); - await page.goto( - "/subscription?type=vip&payChannel=stripe&character=elio", - ); + await page.goto("/subscription?type=vip&payChannel=stripe&character=maya"); await page.getByRole("button", { name: /Monthly,/i }).click(); const externalButton = page.getByRole("button", { name: "Open in browser for more payment methods", }); + const paymentButton = page.getByRole("button", { name: "Pay and Top Up" }); + await expect(externalButton).toBeVisible(); await expect(externalButton).toBeEnabled(); + await expect(paymentButton).toBeVisible(); await expect( page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }), ).toHaveCount(0); + const externalBox = await externalButton.boundingBox(); + const paymentBox = await paymentButton.boundingBox(); + expect(externalBox).not.toBeNull(); + expect(paymentBox).not.toBeNull(); + expect(externalBox!.y).toBeLessThan(paymentBox!.y); + expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844); const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout"); await externalButton.click(); expect((await handoffRequest).postDataJSON()).toMatchObject({ diff --git a/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx b/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx index bf0e3f2a..d0b5979a 100644 --- a/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx +++ b/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx @@ -49,6 +49,7 @@ describe("ExternalBrowserCheckoutButton", () => { await act(async () => { root.render( { commercialOfferId: "offer-1", }); expect(openUrlWithExternalBrowser).toHaveBeenCalledWith( - "https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque", + "https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque&character=maya", ); }); }); diff --git a/src/app/_components/payment/external-browser-checkout-button.tsx b/src/app/_components/payment/external-browser-checkout-button.tsx index c901acab..fb9a797a 100644 --- a/src/app/_components/payment/external-browser-checkout-button.tsx +++ b/src/app/_components/payment/external-browser-checkout-button.tsx @@ -3,6 +3,10 @@ import { useState, useSyncExternalStore } from "react"; import type { CheckoutIntent } from "@/data/schemas/auth"; +import { + DEFAULT_CHARACTER_SLUG, + getCharacterBySlug, +} from "@/data/constants/character"; import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff"; import { BrowserDetector } from "@/utils/browser-detect"; import { ExceptionHandler } from "@/core/errors"; @@ -12,11 +16,13 @@ import { UrlLauncherUtil } from "@/utils/url-launcher-util"; export interface ExternalBrowserCheckoutButtonProps { checkoutIntent: CheckoutIntent; disabled?: boolean; + characterSlug?: string; } export function ExternalBrowserCheckoutButton({ checkoutIntent, disabled = false, + characterSlug = DEFAULT_CHARACTER_SLUG, }: ExternalBrowserCheckoutButtonProps) { const isFacebookBrowser = useSyncExternalStore( () => () => undefined, @@ -43,11 +49,28 @@ export function ExternalBrowserCheckoutButton({ setIsOpening(false); return; } - UrlLauncherUtil.openUrlWithExternalBrowser(result.data.externalUrl); + try { + const verifiedCharacter = + getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG; + const externalUrl = new URL( + result.data.externalUrl, + window.location.origin, + ); + externalUrl.searchParams.set("character", verifiedCharacter); + UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString()); + } catch (error) { + setErrorMessage( + ExceptionHandler.message( + error, + "Could not open this checkout in your browser. Please try again.", + ), + ); + setIsOpening(false); + } }; return ( -
+
- + {paymentIssueNotice ? (

@@ -282,19 +283,20 @@ export function SubscriptionScreen({ />

- + +
{ it("restores subscription intent without carrying the handoff token", () => { expect( - resolveCheckoutIntentDestination({ - planId: "vip_monthly", - autoRenew: true, - commercialOfferId: "offer-1", - chatActionId: "00000000-0000-0000-0000-000000000123", - }), + resolveCheckoutIntentDestination( + { + planId: "vip_monthly", + autoRenew: true, + commercialOfferId: "offer-1", + chatActionId: "00000000-0000-0000-0000-000000000123", + }, + "nayeli", + ), ).toBe( - "/subscription?planId=vip_monthly&autoRenew=1&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe", + "/subscription?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe", ); }); diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index a67515df..ed7af1a8 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -104,6 +104,7 @@ export function resolveExternalEntryDestination({ export function resolveCheckoutIntentDestination( intent: CheckoutIntent, + sourceCharacterSlug?: string | null, ): string { const params = new URLSearchParams({ planId: intent.planId }); @@ -118,6 +119,9 @@ export function resolveCheckoutIntentDestination( } params.set("autoRenew", intent.autoRenew ? "1" : "0"); + const characterSlug = + getCharacterBySlug(sourceCharacterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG; + params.set("character", characterSlug); if (intent.commercialOfferId) { params.set("commercialOfferId", intent.commercialOfferId); }