fix(payment): keep character and mobile actions visible
Docker Image / Build and Push Docker Image (push) Successful in 2m3s
Docker Image / Build and Push Docker Image (push) Successful in 2m3s
This commit is contained in:
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user