207 lines
5.4 KiB
TypeScript
207 lines
5.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|