refactor(payment): share checkout dialogs

This commit is contained in:
2026-07-14 18:23:08 +08:00
parent 0033625866
commit 5bec04a970
8 changed files with 187 additions and 148 deletions
@@ -0,0 +1,52 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
shouldShowEzpayConfirmDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
};
describe("PaymentLaunchDialogs", () => {
it("renders nothing when neither payment dialog is active", () => {
expect(
renderToStaticMarkup(
<PaymentLaunchDialogs
currentOrderId={null}
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Continue to payment."
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 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...");
});
});
@@ -0,0 +1,41 @@
"use client";
import dynamic from "next/dynamic";
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
const StripePaymentDialog = dynamic(
() =>
import("./stripe-payment-dialog").then(
(module) => module.StripePaymentDialog,
),
{
ssr: false,
loading: StripePaymentDialogLoading,
},
);
export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
return <StripePaymentDialog {...props} />;
}
function StripePaymentDialogLoading() {
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-loading-title"
>
<div className={styles.dialog} aria-busy="true">
<div className={styles.header}>
<h2 id="stripe-payment-loading-title" className={styles.title}>
Preparing secure payment
</h2>
<p className={styles.content}>Loading payment methods...</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useId, type ReactNode } from "react";
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "shouldShowEzpayConfirmDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
>;
export interface PaymentLaunchDialogsProps {
currentOrderId: string | null;
externalCheckoutAnalyticsKey: string;
ezpayDescription: ReactNode;
launch: PaymentLaunchDialogFlow;
stripeReturnPath?: string;
}
export function PaymentLaunchDialogs({
currentOrderId,
externalCheckoutAnalyticsKey,
ezpayDescription,
launch,
stripeReturnPath,
}: PaymentLaunchDialogsProps) {
return (
<>
{launch.shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog
orderId={currentOrderId ?? ""}
description={ezpayDescription}
externalCheckoutAnalyticsKey={externalCheckoutAnalyticsKey}
isConfirming={launch.isConfirmingEzpay}
onCancel={launch.handleEzpayCancel}
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
<LazyStripePaymentDialog
clientSecret={launch.stripeClientSecret}
returnPath={stripeReturnPath}
onClose={launch.handleStripeClose}
onConfirmed={launch.handleStripeConfirmed}
/>
) : null}
</>
);
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
isConfirming: boolean;
onCancel: () => void;
onConfirm: () => void;
orderId: string;
}
function EzpayRedirectConfirmDialog({
description,
externalCheckoutAnalyticsKey,
isConfirming,
onCancel,
onConfirm,
orderId,
}: EzpayRedirectConfirmDialogProps) {
const titleId = useId();
return (
<div
className={styles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Continue to GCash?
</h2>
<p className={styles.content}>{description}</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>
);
}
@@ -0,0 +1,20 @@
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,0px))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0px))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0px))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0px))]",
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)]",
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-(--color-text-foreground,#171717)",
content:
"m-0 text-(length:--responsive-body,14px) leading-[1.5] text-[#393939]",
form: "flex flex-col gap-(--page-section-gap,18px)",
error:
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
actions: "flex w-full gap-(--spacing-md,12px)",
button:
"min-h-(--pwa-button-height,44px) flex-auto cursor-pointer rounded-(--radius-bottom-sheet,28px) border-0 text-(length:--responsive-body,16px) font-semibold disabled:cursor-not-allowed disabled:opacity-55",
secondary: "bg-(--color-text-secondary,#9e9e9e) text-(--color-page-background,#ffffff)",
primary:
"bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-(--color-page-background,#ffffff)",
} as const;
@@ -0,0 +1,206 @@
"use client";
/**
* Stripe Payment Element 弹窗
*
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/
import { useState, type FormEvent } from "react";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ExceptionHandler } from "@/core/errors";
import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils/logger";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
const log = new Logger("SubscriptionStripePaymentDialog");
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = stripePublishableKey
? loadStripe(stripePublishableKey)
: null;
export interface StripePaymentDialogProps {
clientSecret: string;
returnPath?: string;
onClose: () => void;
onConfirmed?: () => void;
}
export function StripePaymentDialog({
clientSecret,
returnPath = ROUTES.subscription + "/success",
onClose,
onConfirmed,
}: StripePaymentDialogProps) {
if (!stripePromise) {
return (
<div className={styles.overlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<div className={styles.header}>
<h2 className={styles.title}>Payment unavailable</h2>
<p className={styles.content}>
Stripe publishable key is not configured for this build.
</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.primary}`}
onClick={onClose}
>
OK
</button>
</div>
</div>
</div>
);
}
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-title"
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id="stripe-payment-title" className={styles.title}>
Complete payment
</h2>
<p className={styles.content}>
Enter your payment details securely through Stripe.
</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>
);
}
function StripePaymentForm({
returnPath,
onClose,
onConfirmed,
}: Pick<
StripePaymentDialogProps,
"returnPath" | "onClose" | "onConfirmed"
>) {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleLoadError = (event: Parameters<
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
>[0]) => {
log.error("[stripe-payment-dialog] PaymentElement load failed", {
type: event.error.type,
code: event.error.code,
message: event.error.message,
declineCode: event.error.decline_code,
});
setErrorMessage(
ExceptionHandler.message(
event.error,
"Payment methods could not be loaded. Please try again later.",
),
);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!stripe || !elements || isSubmitting) return;
setIsSubmitting(true);
setErrorMessage(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(
ExceptionHandler.message(submitError, "Please check your payment info."),
);
setIsSubmitting(false);
return;
}
const returnUrl = new URL(
returnPath ?? ROUTES.subscription + "/success",
window.location.origin,
);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
});
if (error) {
setErrorMessage(
ExceptionHandler.message(error, "Payment confirmation failed."),
);
setIsSubmitting(false);
return;
}
onConfirmed?.();
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<PaymentElement onLoadError={handleLoadError} />
{errorMessage ? (
<p
role="alert"
className={styles.error}
>
{errorMessage}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className={`${styles.button} ${styles.primary}`}
disabled={!stripe || !elements || isSubmitting}
>
{isSubmitting ? "Processing..." : "Pay now"}
</button>
</div>
</form>
);
}