Compare commits
2 Commits
d01441b8c7
...
d5b7a1f36c
| Author | SHA1 | Date | |
|---|---|---|---|
| d5b7a1f36c | |||
| 9f829bbc0e |
@@ -107,13 +107,14 @@ for (const character of characters) {
|
|||||||
await page.goto(`/characters/${character.slug}/private-zone`);
|
await page.goto(`/characters/${character.slug}/private-zone`);
|
||||||
|
|
||||||
await Promise.all([momentsRequest, albumRequest]);
|
await Promise.all([momentsRequest, albumRequest]);
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "Private moments" }),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole("tab", { name: "Albums" }).click();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Private albums" }),
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab")).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
await expect(page.getByText(character.displayName).last()).toBeVisible();
|
||||||
const albumButton = page.getByRole("button", {
|
const albumButton = page.getByRole("button", {
|
||||||
name: `View locked collection with 8 images from ${character.displayName}`,
|
name: `View locked collection with 8 images from ${character.displayName}`,
|
||||||
@@ -163,7 +164,6 @@ test("a real Private Zone request failure still offers Retry", async ({
|
|||||||
|
|
||||||
await page.goto("/characters/maya/private-zone");
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Albums" }).click();
|
|
||||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ test.beforeEach(async ({ baseURL, context, page }) => {
|
|||||||
await registerPrivateZoneVideoMocks(page);
|
await registerPrivateZoneVideoMocks(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Private Zone defaults to Moments and reveals video only after one credit unlock", async ({
|
test("Private Zone defaults to Albums and reveals video after switching to Moments", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await seedEmailSession(page);
|
await seedEmailSession(page);
|
||||||
@@ -32,6 +32,11 @@ test("Private Zone defaults to Moments and reveals video only after one credit u
|
|||||||
await page.goto("/characters/maya/private-zone");
|
await page.goto("/characters/maya/private-zone");
|
||||||
await listRequest;
|
await listRequest;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Maya archive")).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "Moments" }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Private moments" }),
|
page.getByRole("heading", { name: "Private moments" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -53,16 +58,29 @@ test("Private Zone defaults to Moments and reveals video only after one credit u
|
|||||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Albums remain available behind the second tab", async ({ page }) => {
|
test("Albums render first and Moments remain available behind the second tab", async ({ page }) => {
|
||||||
await seedEmailSession(page);
|
await seedEmailSession(page);
|
||||||
await page.goto("/characters/maya/private-zone");
|
await page.goto("/characters/maya/private-zone");
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Albums" }).click();
|
const tabs = page.getByRole("tab");
|
||||||
|
await expect(tabs).toHaveText(["Albums", "Moments"]);
|
||||||
|
await expect(page.getByRole("tab", { name: "Albums" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Private albums" }),
|
page.getByRole("heading", { name: "Private albums" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("Maya archive")).toBeVisible();
|
await expect(page.getByText("Maya archive")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Moments" }).click();
|
||||||
|
await expect(page.getByRole("tab", { name: "Moments" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Private moments" }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function registerPrivateZoneVideoMocks(page: Page): Promise<void> {
|
async function registerPrivateZoneVideoMocks(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName="fixed inset-0"
|
||||||
|
panelClassName="payment-panel"
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
role="alertdialog"
|
||||||
|
ariaLabelledBy="payment-title"
|
||||||
|
ariaDescribedBy="payment-description"
|
||||||
|
>
|
||||||
|
<h2 id="payment-title">Complete payment</h2>
|
||||||
|
<p id="payment-description">Choose a payment method.</p>
|
||||||
|
</ModalPortal>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<>
|
||||||
|
<ModalPortal
|
||||||
|
open={showFirst}
|
||||||
|
onClose={() => undefined}
|
||||||
|
scrimClassName="first-scrim"
|
||||||
|
panelClassName="first-panel"
|
||||||
|
scrimOpacity={0.4}
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</ModalPortal>
|
||||||
|
<ModalPortal
|
||||||
|
open={showSecond}
|
||||||
|
onClose={() => undefined}
|
||||||
|
scrimClassName="second-scrim"
|
||||||
|
panelClassName="second-panel"
|
||||||
|
scrimOpacity={0.4}
|
||||||
|
>
|
||||||
|
Second
|
||||||
|
</ModalPortal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ModalPortal
|
||||||
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onClose}
|
||||||
|
scrimClassName="fixed inset-0"
|
||||||
|
panelClassName="payment-panel"
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
>
|
||||||
|
Payment
|
||||||
|
</ModalPortal>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,10 @@ export interface ModalPortalProps {
|
|||||||
scrimOpacity: number;
|
scrimOpacity: number;
|
||||||
panelStyle?: CSSProperties;
|
panelStyle?: CSSProperties;
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
|
role?: "dialog" | "alertdialog";
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
ariaLabelledBy?: string;
|
||||||
|
ariaDescribedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalPortal({
|
export function ModalPortal({
|
||||||
@@ -32,7 +35,10 @@ export function ModalPortal({
|
|||||||
scrimOpacity,
|
scrimOpacity,
|
||||||
panelStyle,
|
panelStyle,
|
||||||
persistent = false,
|
persistent = false,
|
||||||
|
role = "dialog",
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
ariaLabelledBy,
|
||||||
|
ariaDescribedBy,
|
||||||
}: ModalPortalProps) {
|
}: ModalPortalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof document === "undefined") return;
|
if (!open || typeof document === "undefined") return;
|
||||||
@@ -63,9 +69,11 @@ export function ModalPortal({
|
|||||||
onClick={handleScrimClick}
|
onClick={handleScrimClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role={role}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
className={panelClassName}
|
className={panelClassName}
|
||||||
style={panelStyle}
|
style={panelStyle}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { act } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||||
|
|
||||||
@@ -15,9 +16,27 @@ const hiddenLaunch = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("PaymentLaunchDialogs", () => {
|
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", () => {
|
it("renders nothing when neither payment dialog is active", () => {
|
||||||
expect(
|
act(() =>
|
||||||
renderToStaticMarkup(
|
root.render(
|
||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId={null}
|
currentOrderId={null}
|
||||||
externalCheckoutAnalyticsKey="payment.external_checkout"
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
@@ -25,28 +44,41 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
launch={hiddenLaunch}
|
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(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||||
expect(html).toContain("Continue to GCash?");
|
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull();
|
||||||
expect(html).toContain("Your coffee order is ready.");
|
});
|
||||||
expect(html).toContain("Order No. order-123");
|
|
||||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
|
||||||
expect(html).toContain("Opening...");
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<PaymentLaunchDialogs
|
||||||
|
currentOrderId="order-123"
|
||||||
|
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||||
|
ezpayDescription="Your coffee order is ready."
|
||||||
|
launch={{
|
||||||
|
...hiddenLaunch,
|
||||||
|
isConfirmingEzpay: true,
|
||||||
|
shouldShowEzpayConfirmDialog: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
|
|
||||||
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
|
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -20,22 +22,28 @@ export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
|
|||||||
return <StripePaymentDialog {...props} />;
|
return <StripePaymentDialog {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StripePaymentDialogLoading() {
|
export function StripePaymentDialogLoading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={() => undefined}
|
||||||
aria-labelledby="stripe-payment-loading-title"
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy="stripe-payment-loading-title"
|
||||||
|
ariaDescribedBy="stripe-payment-loading-description"
|
||||||
>
|
>
|
||||||
<div className={styles.dialog} aria-busy="true">
|
<div aria-busy="true">
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 id="stripe-payment-loading-title" className={styles.title}>
|
<h2 id="stripe-payment-loading-title" className={styles.title}>
|
||||||
Preparing secure payment
|
Preparing secure payment
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>Loading payment methods...</p>
|
<p id="stripe-payment-loading-description" className={styles.content}>
|
||||||
|
Loading payment methods...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useId, type ReactNode } from "react";
|
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 type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
|
|
||||||
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
||||||
@@ -78,41 +79,43 @@ function EzpayRedirectConfirmDialog({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
|
persistent
|
||||||
|
onClose={onCancel}
|
||||||
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
ariaLabelledBy={titleId}
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
<div className={styles.header}>
|
||||||
<div className={styles.header}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
Continue to GCash?
|
||||||
Continue to GCash?
|
</h2>
|
||||||
</h2>
|
<p className={styles.content}>{description}</p>
|
||||||
<p className={styles.content}>{description}</p>
|
<p className={styles.content}>Order No. {orderId}</p>
|
||||||
<p className={styles.content}>Order No. {orderId}</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.button} ${styles.secondary}`}
|
|
||||||
disabled={isConfirming}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-analytics-key={externalCheckoutAnalyticsKey}
|
|
||||||
data-analytics-label="Continue to external checkout"
|
|
||||||
className={`${styles.button} ${styles.primary}`}
|
|
||||||
disabled={isConfirming}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{isConfirming ? "Opening..." : "Continue"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${styles.secondary}`}
|
||||||
|
disabled={isConfirming}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key={externalCheckoutAnalyticsKey}
|
||||||
|
data-analytics-label="Continue to external checkout"
|
||||||
|
className={`${styles.button} ${styles.primary}`}
|
||||||
|
disabled={isConfirming}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isConfirming ? "Opening..." : "Continue"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const stripePaymentDialogStyles = {
|
|||||||
overlay:
|
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))]",
|
"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:
|
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",
|
header: "mb-(--page-section-gap,18px) text-left",
|
||||||
title:
|
title:
|
||||||
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
|
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||||
*/
|
*/
|
||||||
import { useState, type FormEvent } from "react";
|
import { useId, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Elements,
|
Elements,
|
||||||
PaymentElement,
|
PaymentElement,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
|
|
||||||
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
@@ -38,69 +39,84 @@ export function StripePaymentDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: StripePaymentDialogProps) {
|
}: StripePaymentDialogProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
|
||||||
if (!stripePromise) {
|
if (!stripePromise) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} role="alertdialog" aria-modal="true">
|
<ModalPortal
|
||||||
<div className={styles.dialog}>
|
open
|
||||||
<div className={styles.header}>
|
persistent
|
||||||
<h2 className={styles.title}>Payment unavailable</h2>
|
onClose={onClose}
|
||||||
<p className={styles.content}>
|
scrimClassName={styles.overlay}
|
||||||
Stripe publishable key is not configured for this build.
|
panelClassName={styles.dialog}
|
||||||
</p>
|
scrimOpacity={0.5}
|
||||||
</div>
|
role="alertdialog"
|
||||||
<div className={styles.actions}>
|
ariaLabelledBy={titleId}
|
||||||
<button
|
ariaDescribedBy={descriptionId}
|
||||||
type="button"
|
>
|
||||||
className={`${styles.button} ${styles.primary}`}
|
<div className={styles.header}>
|
||||||
onClick={onClose}
|
<h2 id={titleId} className={styles.title}>
|
||||||
>
|
Payment unavailable
|
||||||
OK
|
</h2>
|
||||||
</button>
|
<p id={descriptionId} className={styles.content}>
|
||||||
</div>
|
Stripe publishable key is not configured for this build.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${styles.primary}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalPortal
|
||||||
className={styles.overlay}
|
open
|
||||||
role="dialog"
|
persistent
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="stripe-payment-title"
|
scrimClassName={styles.overlay}
|
||||||
|
panelClassName={styles.dialog}
|
||||||
|
scrimOpacity={0.5}
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
|
ariaDescribedBy={descriptionId}
|
||||||
>
|
>
|
||||||
<div className={styles.dialog}>
|
<div className={styles.header}>
|
||||||
<div className={styles.header}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
<h2 id="stripe-payment-title" className={styles.title}>
|
Complete payment
|
||||||
Complete payment
|
</h2>
|
||||||
</h2>
|
<p id={descriptionId} className={styles.content}>
|
||||||
<p className={styles.content}>
|
Enter your payment details securely through Stripe.
|
||||||
Enter your payment details securely through Stripe.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Elements
|
|
||||||
key={clientSecret}
|
|
||||||
stripe={stripePromise}
|
|
||||||
options={{
|
|
||||||
clientSecret,
|
|
||||||
appearance: {
|
|
||||||
theme: "stripe",
|
|
||||||
variables: {
|
|
||||||
borderRadius: "14px",
|
|
||||||
colorPrimary: "#ff52a2",
|
|
||||||
colorText: "#171717",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StripePaymentForm
|
|
||||||
returnPath={returnPath}
|
|
||||||
onClose={onClose}
|
|
||||||
onConfirmed={onConfirmed}
|
|
||||||
/>
|
|
||||||
</Elements>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Elements
|
||||||
|
key={clientSecret}
|
||||||
|
stripe={stripePromise}
|
||||||
|
options={{
|
||||||
|
clientSecret,
|
||||||
|
appearance: {
|
||||||
|
theme: "stripe",
|
||||||
|
variables: {
|
||||||
|
borderRadius: "14px",
|
||||||
|
colorPrimary: "#ff52a2",
|
||||||
|
colorText: "#171717",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StripePaymentForm
|
||||||
|
returnPath={returnPath}
|
||||||
|
onClose={onClose}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
</ModalPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function PrivateZoneScreen() {
|
|||||||
const dispatch = usePrivateZoneDispatch();
|
const dispatch = usePrivateZoneDispatch();
|
||||||
const postState = usePrivateZonePostState();
|
const postState = usePrivateZonePostState();
|
||||||
const postDispatch = usePrivateZonePostDispatch();
|
const postDispatch = usePrivateZonePostDispatch();
|
||||||
const [activeTab, setActiveTab] = useState<"moments" | "albums">("moments");
|
const [activeTab, setActiveTab] = useState<"moments" | "albums">("albums");
|
||||||
const openedGalleryInPageRef = useRef(false);
|
const openedGalleryInPageRef = useRef(false);
|
||||||
const previousPostLoginStatusRef = useRef(authState.loginStatus);
|
const previousPostLoginStatusRef = useRef(authState.loginStatus);
|
||||||
const galleryState = useMemo(
|
const galleryState = useMemo(
|
||||||
@@ -313,15 +313,6 @@ export function PrivateZoneScreen() {
|
|||||||
|
|
||||||
<section className={styles.postsSection} aria-labelledby="posts-title">
|
<section className={styles.postsSection} aria-labelledby="posts-title">
|
||||||
<div className={styles.contentTabs} role="tablist" aria-label="Private Zone content">
|
<div className={styles.contentTabs} role="tablist" aria-label="Private Zone content">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={visibleTab === "moments"}
|
|
||||||
className={`${styles.contentTab} ${visibleTab === "moments" ? styles.contentTabActive : ""}`}
|
|
||||||
onClick={() => setActiveTab("moments")}
|
|
||||||
>
|
|
||||||
Moments
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -331,6 +322,15 @@ export function PrivateZoneScreen() {
|
|||||||
>
|
>
|
||||||
Albums
|
Albums
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={visibleTab === "moments"}
|
||||||
|
className={`${styles.contentTab} ${visibleTab === "moments" ? styles.contentTabActive : ""}`}
|
||||||
|
onClick={() => setActiveTab("moments")}
|
||||||
|
>
|
||||||
|
Moments
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
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 { SubscriptionCtaButton } from "../subscription-cta-button";
|
||||||
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
|
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
|
||||||
@@ -48,19 +48,16 @@ describe("subscription Tailwind components", () => {
|
|||||||
expect(html).toContain("OK");
|
expect(html).toContain("OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => {
|
it("keeps Stripe dialog viewport and panel utilities", () => {
|
||||||
const html = renderToStaticMarkup(
|
expect(stripePaymentDialogStyles.overlay).toContain("fixed inset-0");
|
||||||
<StripePaymentDialog
|
expect(stripePaymentDialogStyles.dialog).toContain(
|
||||||
clientSecret="client-secret"
|
"max-w-(--dialog-wide-max-width,420px)",
|
||||||
onClose={() => undefined}
|
);
|
||||||
/>,
|
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", () => {
|
it("renders most popular badges for VIP and coin plans", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user