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;]"; "[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters"; 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 }) => { test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL); 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( 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 }); expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
await expect(page).toHaveURL( 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"); expect(page.url()).not.toContain("handoffToken");
await expect.poll(() => createOrderRequests).toBe(0); 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 seedEmailSession(page);
await page.goto( await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
"/subscription?type=vip&payChannel=stripe&character=elio",
);
await page.getByRole("button", { name: /Monthly,/i }).click(); await page.getByRole("button", { name: /Monthly,/i }).click();
const externalButton = page.getByRole("button", { const externalButton = page.getByRole("button", {
name: "Open in browser for more payment methods", 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(externalButton).toBeEnabled();
await expect(paymentButton).toBeVisible();
await expect( await expect(
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }), page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
).toHaveCount(0); ).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"); const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
await externalButton.click(); await externalButton.click();
expect((await handoffRequest).postDataJSON()).toMatchObject({ expect((await handoffRequest).postDataJSON()).toMatchObject({
@@ -49,6 +49,7 @@ describe("ExternalBrowserCheckoutButton", () => {
await act(async () => { await act(async () => {
root.render( root.render(
<ExternalBrowserCheckoutButton <ExternalBrowserCheckoutButton
characterSlug="maya"
checkoutIntent={{ checkoutIntent={{
planId: "vip_monthly", planId: "vip_monthly",
autoRenew: true, autoRenew: true,
@@ -70,7 +71,7 @@ describe("ExternalBrowserCheckoutButton", () => {
commercialOfferId: "offer-1", commercialOfferId: "offer-1",
}); });
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith( 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 { useState, useSyncExternalStore } from "react";
import type { CheckoutIntent } from "@/data/schemas/auth"; import type { CheckoutIntent } from "@/data/schemas/auth";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff"; import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
import { BrowserDetector } from "@/utils/browser-detect"; import { BrowserDetector } from "@/utils/browser-detect";
import { ExceptionHandler } from "@/core/errors"; import { ExceptionHandler } from "@/core/errors";
@@ -12,11 +16,13 @@ import { UrlLauncherUtil } from "@/utils/url-launcher-util";
export interface ExternalBrowserCheckoutButtonProps { export interface ExternalBrowserCheckoutButtonProps {
checkoutIntent: CheckoutIntent; checkoutIntent: CheckoutIntent;
disabled?: boolean; disabled?: boolean;
characterSlug?: string;
} }
export function ExternalBrowserCheckoutButton({ export function ExternalBrowserCheckoutButton({
checkoutIntent, checkoutIntent,
disabled = false, disabled = false,
characterSlug = DEFAULT_CHARACTER_SLUG,
}: ExternalBrowserCheckoutButtonProps) { }: ExternalBrowserCheckoutButtonProps) {
const isFacebookBrowser = useSyncExternalStore( const isFacebookBrowser = useSyncExternalStore(
() => () => undefined, () => () => undefined,
@@ -43,11 +49,28 @@ export function ExternalBrowserCheckoutButton({
setIsOpening(false); setIsOpening(false);
return; 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 ( return (
<div className="mt-2 w-full text-center"> <div className="mb-2 w-full text-center">
<button <button
type="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" 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", "/external-entry?target=checkout",
); );
setCheckoutDestination( setCheckoutDestination(
resolveCheckoutIntentDestination(result.data), resolveCheckoutIntentDestination(result.data, character),
); );
} else { } else {
const result = await consumeTopUpHandoff(handoffToken); const result = await consumeTopUpHandoff(handoffToken);
@@ -232,6 +232,7 @@ export default function ExternalEntryPersist({
authState.hasInitialized, authState.hasInitialized,
authState.isLoading, authState.isLoading,
authState.loginStatus, authState.loginStatus,
character,
destination, destination,
checkoutDestination, checkoutDestination,
hasPersisted, hasPersisted,
@@ -103,19 +103,10 @@ export function SubscriptionCheckoutButton({
return ( 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 ? ( {payment.payChannel === "stripe" && payment.selectedPlanId ? (
<ExternalBrowserCheckoutButton <ExternalBrowserCheckoutButton
disabled={disabled || isLoading} disabled={disabled || isLoading}
characterSlug={sourceCharacterSlug}
checkoutIntent={{ checkoutIntent={{
planId: payment.selectedPlanId, planId: payment.selectedPlanId,
autoRenew: payment.autoRenew, autoRenew: payment.autoRenew,
@@ -128,6 +119,16 @@ export function SubscriptionCheckoutButton({
}} }}
/> />
) : null} ) : 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 ? ( {payment.errorMessage ? (
<p <p
role="alert" role="alert"
@@ -1,14 +1,24 @@
.shell { .shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: var(--app-viewport-height, 100dvh);
min-height: var(--app-viewport-height, 100dvh); min-height: var(--app-viewport-height, 100dvh);
overflow: hidden;
background: background:
radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px), 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%); 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: padding:
calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px)) calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px))
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 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)); calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
} }
@@ -191,11 +201,12 @@
} }
.ctaSlot { .ctaSlot {
position: fixed; position: relative;
z-index: 40; z-index: 40;
right: 50%; flex: 0 0 auto;
bottom: 0; width: 100%;
width: min(100%, var(--app-max-width, 540px)); max-height: min(50dvh, 360px);
overflow-y: auto;
padding: padding:
12px 12px
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)) 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); box-shadow: 0 -12px 30px rgba(87, 35, 59, 0.1);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
transform: translateX(50%);
} }
@@ -208,6 +208,7 @@ export function SubscriptionScreen({
return ( return (
<MobileShell> <MobileShell>
<div className={styles.shell}> <div className={styles.shell}>
<div className={styles.scrollArea}>
<header className={styles.header}> <header className={styles.header}>
<BackButton <BackButton
className={styles.backSlot} className={styles.backSlot}
@@ -295,6 +296,7 @@ export function SubscriptionScreen({
analyticsKey="subscription.payment_method" analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange} onChange={handlePaymentMethodChange}
/> />
</div>
<div className={styles.ctaSlot}> <div className={styles.ctaSlot}>
<SubscriptionCheckoutButton <SubscriptionCheckoutButton
@@ -13,14 +13,17 @@ import {
describe("checkout external browser destination", () => { describe("checkout external browser destination", () => {
it("restores subscription intent without carrying the handoff token", () => { it("restores subscription intent without carrying the handoff token", () => {
expect( expect(
resolveCheckoutIntentDestination({ resolveCheckoutIntentDestination(
{
planId: "vip_monthly", planId: "vip_monthly",
autoRenew: true, autoRenew: true,
commercialOfferId: "offer-1", commercialOfferId: "offer-1",
chatActionId: "00000000-0000-0000-0000-000000000123", chatActionId: "00000000-0000-0000-0000-000000000123",
}), },
"nayeli",
),
).toBe( ).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( export function resolveCheckoutIntentDestination(
intent: CheckoutIntent, intent: CheckoutIntent,
sourceCharacterSlug?: string | null,
): string { ): string {
const params = new URLSearchParams({ planId: intent.planId }); const params = new URLSearchParams({ planId: intent.planId });
@@ -118,6 +119,9 @@ export function resolveCheckoutIntentDestination(
} }
params.set("autoRenew", intent.autoRenew ? "1" : "0"); params.set("autoRenew", intent.autoRenew ? "1" : "0");
const characterSlug =
getCharacterBySlug(sourceCharacterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
params.set("character", characterSlug);
if (intent.commercialOfferId) { if (intent.commercialOfferId) {
params.set("commercialOfferId", intent.commercialOfferId); params.set("commercialOfferId", intent.commercialOfferId);
} }