fix(payment): keep character and mobile actions visible
Docker Image / Build and Push Docker Image (push) Successful in 2m6s
Docker Image / Build and Push Docker Image (push) Successful in 2m6s
This commit is contained in:
@@ -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,7 +208,8 @@ export function SubscriptionScreen({
|
|||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
<header className={styles.header}>
|
<div className={styles.scrollArea}>
|
||||||
|
<header className={styles.header}>
|
||||||
<BackButton
|
<BackButton
|
||||||
className={styles.backSlot}
|
className={styles.backSlot}
|
||||||
onClick={handleBackClick}
|
onClick={handleBackClick}
|
||||||
@@ -225,7 +226,7 @@ export function SubscriptionScreen({
|
|||||||
>
|
>
|
||||||
Payment issue?
|
Payment issue?
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{paymentIssueNotice ? (
|
{paymentIssueNotice ? (
|
||||||
<p className={styles.paymentIssueNotice} role="status">
|
<p className={styles.paymentIssueNotice} role="status">
|
||||||
@@ -282,19 +283,20 @@ export function SubscriptionScreen({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaymentMethodSelector
|
<PaymentMethodSelector
|
||||||
config={renderedPaymentMethodConfig}
|
config={renderedPaymentMethodConfig}
|
||||||
value={payment.payChannel}
|
value={payment.payChannel}
|
||||||
disabled={isPaymentBusy}
|
disabled={isPaymentBusy}
|
||||||
caption={
|
caption={
|
||||||
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
||||||
? "QRIS by default in Indonesia"
|
? "QRIS by default in Indonesia"
|
||||||
: "GCash by default in the Philippines"
|
: "GCash by default in the Philippines"
|
||||||
}
|
}
|
||||||
className={styles.paymentMethodSlot}
|
className={styles.paymentMethodSlot}
|
||||||
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",
|
{
|
||||||
autoRenew: true,
|
planId: "vip_monthly",
|
||||||
commercialOfferId: "offer-1",
|
autoRenew: true,
|
||||||
chatActionId: "00000000-0000-0000-0000-000000000123",
|
commercialOfferId: "offer-1",
|
||||||
}),
|
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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user