From 9f829bbc0e089115524d6b007c12dc963c6c733b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 18:50:39 +0800 Subject: [PATCH] fix(payment): portal checkout dialogs to viewport (cherry picked from commit 67f292353e9a30fd1e819dcdfd80990748dab223) --- e2e/specs/mock/payment-dialog-layout.spec.ts | 119 +++++++++++++++++ .../core/__tests__/modal-portal.test.tsx | 119 +++++++++++++++++ src/app/_components/core/modal-portal.tsx | 10 +- .../__tests__/payment-launch-dialogs.test.tsx | 82 ++++++++---- .../__tests__/stripe-payment-dialog.test.tsx | 63 +++++++++ .../payment/lazy-stripe-payment-dialog.tsx | 26 ++-- .../payment/payment-launch-dialogs.tsx | 69 +++++----- .../payment/stripe-payment-dialog.styles.ts | 2 +- .../payment/stripe-payment-dialog.tsx | 126 ++++++++++-------- .../__tests__/tailwind-components.test.tsx | 23 ++-- 10 files changed, 502 insertions(+), 137 deletions(-) create mode 100644 e2e/specs/mock/payment-dialog-layout.spec.ts create mode 100644 src/app/_components/core/__tests__/modal-portal.test.tsx create mode 100644 src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx diff --git a/e2e/specs/mock/payment-dialog-layout.spec.ts b/e2e/specs/mock/payment-dialog-layout.spec.ts new file mode 100644 index 00000000..c7a50feb --- /dev/null +++ b/e2e/specs/mock/payment-dialog-layout.spec.ts @@ -0,0 +1,119 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles"; +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; + +const viewports = [ + { width: 390, height: 844 }, + { width: 405, height: 797 }, + { width: 540, height: 900 }, +] as const; + +test.beforeEach(async ({ page }) => { + await mockCoreApis(page); +}); + +for (const viewport of viewports) { + test(`payment portal stays viewport-bound at ${viewport.width}x${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.goto("/subscription?type=topup", { + waitUntil: "domcontentloaded", + }); + + await mountPaymentPanel(page, 180); + await expectViewportScrim(page, viewport); + await expectCenteredShortPanel(page, viewport); + + await mountPaymentPanel(page, 1_200); + await expectViewportScrim(page, viewport); + await expectScrollableTallPanel(page, viewport); + }); +} + +async function mountPaymentPanel(page: Page, contentHeight: number) { + await page.evaluate( + ({ dialogClassName, overlayClassName, requestedContentHeight }) => { + document.querySelector('[data-testid="payment-layout-overlay"]')?.remove(); + document.querySelector('[data-testid="transformed-checkout-host"]')?.remove(); + + const transformedCheckoutHost = document.createElement("div"); + transformedCheckoutHost.dataset.testid = "transformed-checkout-host"; + transformedCheckoutHost.style.transform = "translateX(50%)"; + document.body.appendChild(transformedCheckoutHost); + + const overlay = document.createElement("div"); + overlay.dataset.testid = "payment-layout-overlay"; + overlay.className = overlayClassName; + + const panel = document.createElement("div"); + panel.dataset.testid = "payment-layout-panel"; + panel.className = dialogClassName; + + const content = document.createElement("div"); + content.style.height = `${requestedContentHeight}px`; + content.textContent = "Complete payment"; + panel.appendChild(content); + overlay.appendChild(panel); + + // Mirrors ModalPortal: the overlay must be a body child, never a child of + // the transformed checkout host that triggered it. + document.body.appendChild(overlay); + }, + { + dialogClassName: stripePaymentDialogStyles.dialog, + overlayClassName: stripePaymentDialogStyles.overlay, + requestedContentHeight: contentHeight, + }, + ); +} + +async function expectViewportScrim( + page: Page, + viewport: { width: number; height: number }, +) { + const overlay = page.getByTestId("payment-layout-overlay"); + await expect(overlay).toBeVisible(); + const box = await overlay.boundingBox(); + + expect(box).not.toBeNull(); + expect(box?.x).toBeCloseTo(0, 0); + expect(box?.y).toBeCloseTo(0, 0); + expect(box?.width).toBeCloseTo(viewport.width, 0); + expect(box?.height).toBeCloseTo(viewport.height, 0); + await expect(overlay.evaluate((node) => node.parentElement === document.body)) + .resolves.toBe(true); +} + +async function expectCenteredShortPanel( + page: Page, + viewport: { width: number; height: number }, +) { + const panel = page.getByTestId("payment-layout-panel"); + const box = await panel.boundingBox(); + + expect(box).not.toBeNull(); + expect(box?.y ?? 0).toBeGreaterThan(100); + expect(Math.abs((box?.y ?? 0) * 2 + (box?.height ?? 0) - viewport.height)) + .toBeLessThanOrEqual(2); +} + +async function expectScrollableTallPanel( + page: Page, + viewport: { width: number; height: number }, +) { + const panel = page.getByTestId("payment-layout-panel"); + const box = await panel.boundingBox(); + const scrollMetrics = await panel.evaluate((node) => ({ + clientHeight: node.clientHeight, + scrollHeight: node.scrollHeight, + })); + + expect(box).not.toBeNull(); + expect(box?.y ?? 0).toBeGreaterThanOrEqual(15); + expect(box?.y ?? 100).toBeLessThanOrEqual(25); + expect((box?.y ?? 0) + (box?.height ?? 0)) + .toBeLessThanOrEqual(viewport.height - 15); + expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight); +} diff --git a/src/app/_components/core/__tests__/modal-portal.test.tsx b/src/app/_components/core/__tests__/modal-portal.test.tsx new file mode 100644 index 00000000..d88350d1 --- /dev/null +++ b/src/app/_components/core/__tests__/modal-portal.test.tsx @@ -0,0 +1,119 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ModalPortal } from "../modal-portal"; + +describe("ModalPortal", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + container.style.transform = "translateX(50%)"; + document.body.appendChild(container); + document.body.style.overflow = "auto"; + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.style.overflow = ""; + }); + + it("portals outside transformed ancestors and exposes dialog semantics", () => { + const onClose = vi.fn(); + + act(() => + root.render( + +

Complete payment

+

Choose a payment method.

+
, + ), + ); + + const dialog = document.body.querySelector('[role="alertdialog"]'); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + expect(dialog?.parentElement?.parentElement).toBe(document.body); + expect(dialog?.getAttribute("aria-labelledby")).toBe("payment-title"); + expect(dialog?.getAttribute("aria-describedby")).toBe( + "payment-description", + ); + expect(document.body.style.overflow).toBe("hidden"); + }); + + it("restores the previous body overflow after the final modal closes", () => { + const renderModals = (showFirst: boolean, showSecond: boolean) => ( + <> + undefined} + scrimClassName="first-scrim" + panelClassName="first-panel" + scrimOpacity={0.4} + > + First + + undefined} + scrimClassName="second-scrim" + panelClassName="second-panel" + scrimOpacity={0.4} + > + Second + + + ); + + act(() => root.render(renderModals(true, true))); + expect(document.body.style.overflow).toBe("hidden"); + + act(() => root.render(renderModals(false, true))); + expect(document.body.style.overflow).toBe("hidden"); + + act(() => root.render(renderModals(false, false))); + expect(document.body.style.overflow).toBe("auto"); + }); + + it("keeps persistent payment modals open on scrim click and Escape", () => { + const onClose = vi.fn(); + + act(() => + root.render( + + Payment + , + ), + ); + + const scrim = document.body.querySelector(".fixed.inset-0"); + act(() => scrim?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + act(() => + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })), + ); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/_components/core/modal-portal.tsx b/src/app/_components/core/modal-portal.tsx index f4fd20d6..36d538a9 100644 --- a/src/app/_components/core/modal-portal.tsx +++ b/src/app/_components/core/modal-portal.tsx @@ -20,7 +20,10 @@ export interface ModalPortalProps { scrimOpacity: number; panelStyle?: CSSProperties; persistent?: boolean; + role?: "dialog" | "alertdialog"; ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; } export function ModalPortal({ @@ -32,7 +35,10 @@ export function ModalPortal({ scrimOpacity, panelStyle, persistent = false, + role = "dialog", ariaLabel, + ariaLabelledBy, + ariaDescribedBy, }: ModalPortalProps) { useEffect(() => { if (!open || typeof document === "undefined") return; @@ -63,9 +69,11 @@ export function ModalPortal({ onClick={handleScrimClick} >
event.stopPropagation()} diff --git a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx index 55be9ac1..ec551ac0 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -1,5 +1,6 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PaymentLaunchDialogs } from "../payment-launch-dialogs"; @@ -15,9 +16,27 @@ const hiddenLaunch = { }; describe("PaymentLaunchDialogs", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + container.style.transform = "translateX(50%)"; + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.style.overflow = ""; + }); + it("renders nothing when neither payment dialog is active", () => { - expect( - renderToStaticMarkup( + act(() => + root.render( { launch={hiddenLaunch} />, ), - ).toBe(""); - }); - - it("renders the shared Ezpay confirmation content", () => { - const html = renderToStaticMarkup( - , ); - expect(html).toContain('role="alertdialog"'); - expect(html).toContain("Continue to GCash?"); - expect(html).toContain("Your coffee order is ready."); - expect(html).toContain("Order No. order-123"); - expect(html).toContain('data-analytics-key="tip.external_checkout"'); - expect(html).toContain("Opening..."); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.querySelector('[role="alertdialog"]')).toBeNull(); + }); + + it("portals the shared Ezpay confirmation outside transformed checkout UI", () => { + act(() => + root.render( + , + ), + ); + + const dialog = document.body.querySelector('[role="alertdialog"]'); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + expect(dialog?.parentElement?.parentElement).toBe(document.body); + expect(dialog?.textContent).toContain("Continue to GCash?"); + expect(dialog?.textContent).toContain("Your coffee order is ready."); + expect(dialog?.textContent).toContain("Order No. order-123"); + expect( + dialog?.querySelector("button[data-analytics-key]")?.getAttribute( + "data-analytics-key", + ), + ).toBe("tip.external_checkout"); + expect(dialog?.textContent).toContain("Opening..."); + expect(document.body.style.overflow).toBe("hidden"); }); }); diff --git a/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx b/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx new file mode 100644 index 00000000..6910ce17 --- /dev/null +++ b/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx @@ -0,0 +1,63 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog"; +import { StripePaymentDialog } from "../stripe-payment-dialog"; + +describe("Stripe payment portal states", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + container.style.transform = "translateX(50%)"; + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.style.overflow = ""; + }); + + it("portals the unavailable state and keeps its explicit close action", () => { + const onClose = vi.fn(); + + act(() => + root.render( + , + ), + ); + + const dialog = document.body.querySelector('[role="alertdialog"]'); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + expect(dialog?.textContent).toContain("Payment unavailable"); + expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy(); + expect(dialog?.getAttribute("aria-describedby")).toBeTruthy(); + + const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "OK", + ); + act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("portals the lazy loading state", () => { + act(() => root.render()); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + expect(dialog?.textContent).toContain("Preparing secure payment"); + expect(dialog?.textContent).toContain("Loading payment methods..."); + expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull(); + }); +}); diff --git a/src/app/_components/payment/lazy-stripe-payment-dialog.tsx b/src/app/_components/payment/lazy-stripe-payment-dialog.tsx index c80ad11a..e7ee6768 100644 --- a/src/app/_components/payment/lazy-stripe-payment-dialog.tsx +++ b/src/app/_components/payment/lazy-stripe-payment-dialog.tsx @@ -2,6 +2,8 @@ import dynamic from "next/dynamic"; +import { ModalPortal } from "@/app/_components/core/modal-portal"; + import type { StripePaymentDialogProps } from "./stripe-payment-dialog"; import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles"; @@ -20,22 +22,28 @@ export function LazyStripePaymentDialog(props: StripePaymentDialogProps) { return ; } -function StripePaymentDialogLoading() { +export function StripePaymentDialogLoading() { return ( -
undefined} + scrimClassName={styles.overlay} + panelClassName={styles.dialog} + scrimOpacity={0.5} + ariaLabelledBy="stripe-payment-loading-title" + ariaDescribedBy="stripe-payment-loading-description" > -
+

Preparing secure payment

-

Loading payment methods...

+

+ Loading payment methods... +

-
+ ); } diff --git a/src/app/_components/payment/payment-launch-dialogs.tsx b/src/app/_components/payment/payment-launch-dialogs.tsx index 086f3fa8..ff69d16a 100644 --- a/src/app/_components/payment/payment-launch-dialogs.tsx +++ b/src/app/_components/payment/payment-launch-dialogs.tsx @@ -2,6 +2,7 @@ import { useId, type ReactNode } from "react"; +import { ModalPortal } from "@/app/_components/core/modal-portal"; import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog"; @@ -78,41 +79,43 @@ function EzpayRedirectConfirmDialog({ const titleId = useId(); return ( -
-
-
-

- Continue to GCash? -

-

{description}

-

Order No. {orderId}

-
-
- - -
+
+

+ Continue to GCash? +

+

{description}

+

Order No. {orderId}

-
+
+ + +
+ ); } diff --git a/src/app/_components/payment/stripe-payment-dialog.styles.ts b/src/app/_components/payment/stripe-payment-dialog.styles.ts index 3916803d..af7d338a 100644 --- a/src/app/_components/payment/stripe-payment-dialog.styles.ts +++ b/src/app/_components/payment/stripe-payment-dialog.styles.ts @@ -2,7 +2,7 @@ export const stripePaymentDialogStyles = { overlay: "fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0))]", dialog: - "max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]", + "max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto overscroll-contain rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]", header: "mb-(--page-section-gap,18px) text-left", title: "m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground", diff --git a/src/app/_components/payment/stripe-payment-dialog.tsx b/src/app/_components/payment/stripe-payment-dialog.tsx index 839a24fd..2ea23674 100644 --- a/src/app/_components/payment/stripe-payment-dialog.tsx +++ b/src/app/_components/payment/stripe-payment-dialog.tsx @@ -4,7 +4,7 @@ * * 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。 */ -import { useState, type FormEvent } from "react"; +import { useId, useState, type FormEvent } from "react"; import { Elements, PaymentElement, @@ -13,6 +13,7 @@ import { } from "@stripe/react-stripe-js"; import { loadStripe } from "@stripe/stripe-js"; +import { ModalPortal } from "@/app/_components/core/modal-portal"; import { ExceptionHandler } from "@/core/errors"; import { ROUTES } from "@/router/routes"; import { Logger } from "@/utils/logger"; @@ -38,69 +39,84 @@ export function StripePaymentDialog({ onClose, onConfirmed, }: StripePaymentDialogProps) { + const titleId = useId(); + const descriptionId = useId(); + if (!stripePromise) { return ( -
-
-
-

Payment unavailable

-

- Stripe publishable key is not configured for this build. -

-
-
- -
+ +
+

+ Payment unavailable +

+

+ Stripe publishable key is not configured for this build. +

-
+
+ +
+ ); } return ( -
-
-
-

- Complete payment -

-

- Enter your payment details securely through Stripe. -

-
- - - +
+

+ Complete payment +

+

+ Enter your payment details securely through Stripe. +

-
+ + + + ); } diff --git a/src/app/subscription/components/__tests__/tailwind-components.test.tsx b/src/app/subscription/components/__tests__/tailwind-components.test.tsx index acc770e9..c70976d1 100644 --- a/src/app/subscription/components/__tests__/tailwind-components.test.tsx +++ b/src/app/subscription/components/__tests__/tailwind-components.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-dialog"; +import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles"; import { SubscriptionCtaButton } from "../subscription-cta-button"; import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog"; @@ -48,19 +48,16 @@ describe("subscription Tailwind components", () => { expect(html).toContain("OK"); }); - it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => { - const html = renderToStaticMarkup( - undefined} - />, + it("keeps Stripe dialog viewport and panel utilities", () => { + expect(stripePaymentDialogStyles.overlay).toContain("fixed inset-0"); + expect(stripePaymentDialogStyles.dialog).toContain( + "max-w-(--dialog-wide-max-width,420px)", + ); + expect(stripePaymentDialogStyles.dialog).toContain("overflow-y-auto"); + expect(stripePaymentDialogStyles.dialog).toContain("overscroll-contain"); + expect(stripePaymentDialogStyles.primary).toContain( + "bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)", ); - - expect(html).toContain('role="alertdialog"'); - expect(html).toContain("fixed inset-0"); - expect(html).toContain("max-w-(--dialog-wide-max-width,420px)"); - expect(html).toContain("Payment unavailable"); - expect(html).toContain("bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)"); }); it("renders most popular badges for VIP and coin plans", () => {