fix(payment): keep character and mobile actions visible
Docker Image / Build and Push Docker Image (push) Successful in 2m3s

This commit is contained in:
Codex
2026-07-29 12:59:25 +08:00
parent 4cbdd0da7c
commit 3c74d30189
9 changed files with 103 additions and 48 deletions
@@ -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({
@@ -49,6 +49,7 @@ describe("ExternalBrowserCheckoutButton", () => {
await act(async () => {
root.render(
<ExternalBrowserCheckoutButton
characterSlug="maya"
checkoutIntent={{
planId: "vip_monthly",
autoRenew: true,
@@ -70,7 +71,7 @@ describe("ExternalBrowserCheckoutButton", () => {
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",
);
});
});
@@ -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 (
<div className="mt-2 w-full text-center">
<div className="mb-2 w-full text-center">
<button
type="button"
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
@@ -176,7 +176,7 @@ export default function ExternalEntryPersist({
"/external-entry?target=checkout",
);
setCheckoutDestination(
resolveCheckoutIntentDestination(result.data),
resolveCheckoutIntentDestination(result.data, character),
);
} else {
const result = await consumeTopUpHandoff(handoffToken);
@@ -232,6 +232,7 @@ export default function ExternalEntryPersist({
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
character,
destination,
checkoutDestination,
hasPersisted,
@@ -103,19 +103,10 @@ export function SubscriptionCheckoutButton({
return (
<>
<SubscriptionCtaButton
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled && !isResumingQris}
isLoading={isLoading}
onClick={handleClick}
>
{label}
</SubscriptionCtaButton>
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
<ExternalBrowserCheckoutButton
disabled={disabled || isLoading}
characterSlug={sourceCharacterSlug}
checkoutIntent={{
planId: payment.selectedPlanId,
autoRenew: payment.autoRenew,
@@ -128,6 +119,16 @@ export function SubscriptionCheckoutButton({
}}
/>
) : null}
<SubscriptionCtaButton
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled && !isResumingQris}
isLoading={isLoading}
onClick={handleClick}
>
{label}
</SubscriptionCtaButton>
{payment.errorMessage ? (
<p
role="alert"
@@ -1,14 +1,24 @@
.shell {
display: flex;
flex-direction: column;
height: var(--app-viewport-height, 100dvh);
min-height: var(--app-viewport-height, 100dvh);
overflow: hidden;
background:
radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px),
linear-gradient(180deg, #fff9fb 0%, #fcf3f4 52%, #fffefe 100%);
}
.scrollArea {
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding:
calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px))
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
calc(104px + var(--app-safe-bottom, 0px))
var(--page-section-gap-lg, 22px)
calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
}
@@ -191,11 +201,12 @@
}
.ctaSlot {
position: fixed;
position: relative;
z-index: 40;
right: 50%;
bottom: 0;
width: min(100%, var(--app-max-width, 540px));
flex: 0 0 auto;
width: 100%;
max-height: min(50dvh, 360px);
overflow-y: auto;
padding:
12px
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
@@ -209,5 +220,4 @@
);
box-shadow: 0 -12px 30px rgba(87, 35, 59, 0.1);
backdrop-filter: blur(16px);
transform: translateX(50%);
}
+17 -15
View File
@@ -208,7 +208,8 @@ export function SubscriptionScreen({
return (
<MobileShell>
<div className={styles.shell}>
<header className={styles.header}>
<div className={styles.scrollArea}>
<header className={styles.header}>
<BackButton
className={styles.backSlot}
onClick={handleBackClick}
@@ -225,7 +226,7 @@ export function SubscriptionScreen({
>
Payment issue?
</button>
</header>
</header>
{paymentIssueNotice ? (
<p className={styles.paymentIssueNotice} role="status">
@@ -282,19 +283,20 @@ export function SubscriptionScreen({
/>
</div>
<PaymentMethodSelector
config={renderedPaymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy}
caption={
paymentMethodConfig.ezpayDisplayName === "QRIS"
? "QRIS by default in Indonesia"
: "GCash by default in the Philippines"
}
className={styles.paymentMethodSlot}
analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange}
/>
<PaymentMethodSelector
config={renderedPaymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy}
caption={
paymentMethodConfig.ezpayDisplayName === "QRIS"
? "QRIS by default in Indonesia"
: "GCash by default in the Philippines"
}
className={styles.paymentMethodSlot}
analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange}
/>
</div>
<div className={styles.ctaSlot}>
<SubscriptionCheckoutButton
@@ -13,14 +13,17 @@ import {
describe("checkout external browser destination", () => {
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",
);
});
+4
View File
@@ -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);
}