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 5ab418d0..eb971f8f 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";
@@ -20,9 +21,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 payment?");
- 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("renders an accessible QRIS dialog with display cents and order status", () => {
- const html = renderToStaticMarkup(
- ,
+ it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
+ act(() =>
+ root.render(
+ ,
+ ),
);
- expect(html).toContain('role="dialog"');
- expect(html).toContain("Scan to pay with QRIS");
- expect(html).toContain("QRIS payment QR code");
- expect(html).toContain("Order No. order-id-qris");
- expect(html).toContain("Rp");
- expect(html).toContain("50.000");
- expect(html).toContain("Waiting for payment");
+ 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 payment?");
+ 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");
+ });
+
+ it("portals an accessible QRIS dialog with display cents and order status", () => {
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+
+ const dialog = document.body.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ expect(container.contains(dialog)).toBe(false);
+ expect(dialog?.textContent).toContain("Scan to pay with QRIS");
+ expect(dialog?.querySelector("svg title")?.textContent).toBe(
+ "QRIS payment QR code",
+ );
+ expect(dialog?.textContent).toContain("Order No. order-id-qris");
+ expect(dialog?.textContent).toContain("Rp");
+ expect(dialog?.textContent).toContain("50.000");
+ expect(dialog?.textContent).toContain("Waiting for payment");
});
it("shows QRIS failure state and handles empty QR data safely", () => {
- const html = renderToStaticMarkup(
- ,
+ act(() =>
+ root.render(
+ ,
+ ),
);
- expect(html).toContain("QRIS data is unavailable");
- expect(html).toContain("Payment failed or was cancelled.");
- expect(html).toContain("Close");
+ const dialog = document.body.querySelector('[role="dialog"]');
+ expect(dialog?.textContent).toContain("QRIS data is unavailable");
+ expect(dialog?.textContent).toContain("Payment failed or was cancelled.");
+ expect(dialog?.textContent).toContain("Close");
});
});
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 5a3ca9f7..d30620dc 100644
--- a/src/app/_components/payment/payment-launch-dialogs.tsx
+++ b/src/app/_components/payment/payment-launch-dialogs.tsx
@@ -3,6 +3,7 @@
import { useId, type ReactNode } from "react";
import { QRCodeSVG } from "qrcode.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";
@@ -111,60 +112,61 @@ function QrisPaymentDialog({
const statusMessage = qrisStatusMessage(status, errorMessage);
return (
-
-
-
-
- Scan to pay with QRIS
-
-
- Open a QRIS-compatible banking or wallet app and scan this code.
-
-
-
- {payment.qrData ? (
-
- ) : (
-
- QRIS data is unavailable. Please close this dialog and try again.
-
- )}
-
-
-
Order No. {payment.orderId}
-
- {formatQrisAmount(payment.amountCents, payment.currency)}
-
-
- {statusMessage}
-
-
-
-
-
+
+
+ Scan to pay with QRIS
+
+
+ Open a QRIS-compatible banking or wallet app and scan this code.
+
-
+
+ {payment.qrData ? (
+
+ ) : (
+
+ QRIS data is unavailable. Please close this dialog and try again.
+
+ )}
+
+
+
Order No. {payment.orderId}
+
+ {formatQrisAmount(payment.amountCents, payment.currency)}
+
+
+ {statusMessage}
+
+
+
+
+
+
);
}
@@ -188,41 +190,43 @@ function EzpayRedirectConfirmDialog({
const titleId = useId();
return (
-
-
-
-
- Continue to payment?
-
-
{description}
-
Order No. {orderId}
-
-
-
-
-
+
+
+ Continue to payment?
+
+
{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", () => {