Files
cozsweet-frontend-nextjs/src/app/_components/payment/stripe-payment-dialog.tsx
T
Codex 2e04260ebd
Docker Image / Build and Push Docker Image (push) Successful in 2m15s
revert(payment): remove unsupported Stripe and EzPay upgrade
2026-07-29 18:15:20 +08:00

223 lines
5.7 KiB
TypeScript

"use client";
/**
* Stripe Payment Element 弹窗
*
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/
import { useId, useState, type FormEvent } from "react";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ModalPortal } from "@/app/_components/core/modal-portal";
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) {
const titleId = useId();
const descriptionId = useId();
if (!stripePromise) {
return (
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
role="alertdialog"
ariaLabelledBy={titleId}
ariaDescribedBy={descriptionId}
>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Payment unavailable
</h2>
<p id={descriptionId} 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>
</ModalPortal>
);
}
return (
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy={titleId}
ariaDescribedBy={descriptionId}
>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Complete payment
</h2>
<p id={descriptionId} 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>
</ModalPortal>
);
}
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>
);
}