fix(payment): portal checkout dialogs to viewport

This commit is contained in:
Codex
2026-07-27 18:50:39 +08:00
parent ca9cac21f1
commit e36f94add3
10 changed files with 610 additions and 235 deletions
@@ -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(
<PaymentLaunchDialogs
currentOrderId={null}
externalCheckoutAnalyticsKey="payment.external_checkout"
@@ -30,83 +49,105 @@ describe("PaymentLaunchDialogs", () => {
launch={hiddenLaunch}
/>,
),
).toBe("");
});
it("renders the shared Ezpay confirmation content", () => {
const html = renderToStaticMarkup(
<PaymentLaunchDialogs
currentOrderId="order-123"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Your coffee order is ready."
launch={{
...hiddenLaunch,
isConfirmingEzpay: true,
shouldShowEzpayConfirmDialog: true,
}}
/>,
);
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(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
},
qrisStatus: "pending",
shouldShowQrisDialog: true,
}}
/>,
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-123"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Your coffee order is ready."
launch={{
...hiddenLaunch,
isConfirmingEzpay: true,
shouldShowEzpayConfirmDialog: true,
}}
/>,
),
);
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(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
},
qrisStatus: "pending",
shouldShowQrisDialog: true,
}}
/>,
),
);
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(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisErrorMessage: "Payment failed or was cancelled.",
qrisPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
},
qrisStatus: "failed",
shouldShowQrisDialog: true,
}}
/>,
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisErrorMessage: "Payment failed or was cancelled.",
qrisPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
},
qrisStatus: "failed",
shouldShowQrisDialog: true,
}}
/>,
),
);
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");
});
});
@@ -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(
<StripePaymentDialog
clientSecret="client-secret"
onClose={onClose}
/>,
),
);
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(<StripePaymentDialogLoading />));
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();
});
});