Files
cozsweet-frontend-nextjs/src/app/_components/payment/stripe-payment-dialog.tsx
T

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>
);
}