409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
"use client";
|
|
/**
|
|
* Stripe Payment Element 弹窗
|
|
*
|
|
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
|
*/
|
|
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
|
import {
|
|
Elements,
|
|
ExpressCheckoutElement,
|
|
PaymentElement,
|
|
useElements,
|
|
useStripe,
|
|
} from "@stripe/react-stripe-js";
|
|
import {
|
|
loadStripe,
|
|
type AvailablePaymentMethods,
|
|
type StripeExpressCheckoutElementConfirmEvent,
|
|
} 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 { BrowserDetector } from "@/utils/browser-detect";
|
|
import { PlatformDetector } from "@/utils/platform-detect";
|
|
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
|
import { behaviorAnalytics } from "@/lib/analytics";
|
|
|
|
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;
|
|
|
|
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
|
|
|
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
|
return value === true || (typeof value === "object" && value.available);
|
|
}
|
|
|
|
export interface StripePaymentDialogProps {
|
|
clientSecret: string;
|
|
customerSessionClientSecret?: string | null;
|
|
savedPaymentMethodsEnabled?: boolean;
|
|
returnPath?: string;
|
|
onClose: () => void;
|
|
onConfirmed?: () => void;
|
|
}
|
|
|
|
export function StripePaymentDialog({
|
|
clientSecret,
|
|
customerSessionClientSecret = null,
|
|
savedPaymentMethodsEnabled = false,
|
|
returnPath = ROUTES.subscription + "/success",
|
|
onClose,
|
|
onConfirmed,
|
|
}: StripePaymentDialogProps) {
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
|
provider: "stripe",
|
|
customerSessionClientSecret,
|
|
savedPaymentMethodsEnabled,
|
|
});
|
|
|
|
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,
|
|
...(savedCardClientSecret
|
|
? { customerSessionClientSecret: savedCardClientSecret }
|
|
: {}),
|
|
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 submittingRef = useRef(false);
|
|
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
|
|
boolean | null
|
|
>(null);
|
|
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
|
|
|
|
useEffect(() => {
|
|
if (typeof window.matchMedia !== "function") return;
|
|
const query = window.matchMedia("(min-width: 640px)");
|
|
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
|
|
updateColumns();
|
|
query.addEventListener("change", updateColumns);
|
|
return () => query.removeEventListener("change", updateColumns);
|
|
}, []);
|
|
|
|
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 updateExpressAvailability = (
|
|
availablePaymentMethods:
|
|
| AvailablePaymentMethods
|
|
| {
|
|
applePay?: { available: boolean };
|
|
googlePay?: { available: boolean };
|
|
link?: { available: boolean };
|
|
paypal?: { available: boolean };
|
|
amazonPay?: { available: boolean };
|
|
klarna?: { available: boolean };
|
|
}
|
|
| undefined,
|
|
) => {
|
|
const availability = {
|
|
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
|
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
|
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
|
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
|
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
|
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
|
};
|
|
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
|
const browser = BrowserDetector.isFacebookInAppBrowser()
|
|
? "facebook"
|
|
: BrowserDetector.getBrowserName() || "unknown";
|
|
const platform = PlatformDetector.getPlatform();
|
|
const metadata = {
|
|
browser,
|
|
platform,
|
|
availablePaymentMethods: availability,
|
|
};
|
|
log.info(
|
|
"[stripe-payment-dialog] Express Checkout availability",
|
|
metadata,
|
|
);
|
|
behaviorAnalytics.elementClick(
|
|
"stripe.express_checkout_availability",
|
|
"Stripe Express Checkout availability",
|
|
metadata,
|
|
);
|
|
};
|
|
|
|
const handleExpressLoadError = (event: Parameters<
|
|
NonNullable<
|
|
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
|
>
|
|
>[0]) => {
|
|
setHasExpressPaymentMethods(false);
|
|
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
|
type: event.error.type,
|
|
code: event.error.code,
|
|
message: event.error.message,
|
|
});
|
|
};
|
|
|
|
const confirmPayment = async (
|
|
options: {
|
|
submitPaymentElement: boolean;
|
|
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
|
},
|
|
) => {
|
|
if (!stripe || !elements || submittingRef.current) return;
|
|
|
|
submittingRef.current = true;
|
|
setIsSubmitting(true);
|
|
setErrorMessage(null);
|
|
|
|
if (options.submitPaymentElement) {
|
|
let submitError: unknown;
|
|
try {
|
|
({ error: submitError } = await elements.submit());
|
|
} catch (error) {
|
|
submitError = error;
|
|
}
|
|
if (submitError) {
|
|
setErrorMessage(
|
|
ExceptionHandler.message(
|
|
submitError,
|
|
"Please check your payment info.",
|
|
),
|
|
);
|
|
submittingRef.current = false;
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const returnUrl = new URL(
|
|
returnPath ?? ROUTES.subscription + "/success",
|
|
window.location.origin,
|
|
);
|
|
let confirmationError: unknown;
|
|
try {
|
|
({ error: confirmationError } = await stripe.confirmPayment({
|
|
elements,
|
|
confirmParams: {
|
|
return_url: returnUrl.toString(),
|
|
},
|
|
redirect: "if_required",
|
|
}));
|
|
} catch (error) {
|
|
confirmationError = error;
|
|
}
|
|
|
|
if (confirmationError) {
|
|
const message = ExceptionHandler.message(
|
|
confirmationError,
|
|
"Payment confirmation failed.",
|
|
);
|
|
setErrorMessage(message);
|
|
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
|
submittingRef.current = false;
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
onConfirmed?.();
|
|
};
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
await confirmPayment({ submitPaymentElement: true });
|
|
};
|
|
|
|
return (
|
|
<form className={styles.form} onSubmit={handleSubmit}>
|
|
<section
|
|
className={styles.expressSection}
|
|
hidden={hasExpressPaymentMethods === false}
|
|
aria-label="Express payment methods"
|
|
>
|
|
<ExpressCheckoutElement
|
|
options={{
|
|
paymentMethodOrder: [
|
|
"apple_pay",
|
|
"google_pay",
|
|
"link",
|
|
"paypal",
|
|
"amazon_pay",
|
|
"klarna",
|
|
],
|
|
paymentMethods: {
|
|
applePay: "always",
|
|
googlePay: "always",
|
|
link: "auto",
|
|
paypal: "auto",
|
|
amazonPay: "auto",
|
|
klarna: "auto",
|
|
},
|
|
layout: {
|
|
maxColumns: expressMaxColumns,
|
|
maxRows: 6,
|
|
overflow: "never",
|
|
},
|
|
}}
|
|
onReady={(event) =>
|
|
updateExpressAvailability(event.availablePaymentMethods)
|
|
}
|
|
onAvailablePaymentMethodsChange={(event) =>
|
|
updateExpressAvailability(event.paymentMethods)
|
|
}
|
|
onLoadError={handleExpressLoadError}
|
|
onConfirm={(event) =>
|
|
void confirmPayment({
|
|
submitPaymentElement: false,
|
|
expressEvent: event,
|
|
})
|
|
}
|
|
/>
|
|
</section>
|
|
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
|
|
<h3 id="pay-by-card-title" className={styles.cardTitle}>
|
|
Pay by card
|
|
</h3>
|
|
<PaymentElement
|
|
options={{
|
|
layout: { type: "accordion", defaultCollapsed: true },
|
|
paymentMethodOrder: ["card"],
|
|
wallets: {
|
|
applePay: "never",
|
|
googlePay: "never",
|
|
link: "never",
|
|
},
|
|
}}
|
|
onLoadError={handleLoadError}
|
|
/>
|
|
</section>
|
|
{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>
|
|
);
|
|
}
|