refactor(payment): share checkout dialogs
This commit is contained in:
@@ -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