feat(payment): expand Stripe methods and checkout handoff
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
This commit is contained in:
@@ -4,19 +4,28 @@
|
||||
*
|
||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||
*/
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Elements,
|
||||
ExpressCheckoutElement,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/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";
|
||||
|
||||
@@ -26,8 +35,16 @@ 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;
|
||||
@@ -35,12 +52,19 @@ export interface StripePaymentDialogProps {
|
||||
|
||||
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 (
|
||||
@@ -100,6 +124,9 @@ export function StripePaymentDialog({
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
...(savedCardClientSecret
|
||||
? { customerSessionClientSecret: savedCardClientSecret }
|
||||
: {}),
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
@@ -132,6 +159,20 @@ function StripePaymentForm({
|
||||
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"]>
|
||||
@@ -150,38 +191,118 @@ function StripePaymentForm({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements || isSubmitting) return;
|
||||
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);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
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,
|
||||
);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
let confirmationError: unknown;
|
||||
try {
|
||||
({ error: confirmationError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
}));
|
||||
} catch (error) {
|
||||
confirmationError = error;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
||||
if (confirmationError) {
|
||||
const message = ExceptionHandler.message(
|
||||
confirmationError,
|
||||
"Payment confirmation failed.",
|
||||
);
|
||||
setErrorMessage(message);
|
||||
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||
submittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -189,9 +310,74 @@ function StripePaymentForm({
|
||||
onConfirmed?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await confirmPayment({ submitPaymentElement: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<PaymentElement onLoadError={handleLoadError} />
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user