refactor(payment): share checkout dialogs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user