Change Stripe `<CardElement />` to `<PaymentElement />` (#8268)

* Use Stripe PaymentElement

* Only show needs confirmation, if the booking needs it

* Clean up

* Type fix

* More type fixes
pull/8035/head
Joe Au-Yeung 2023-04-14 17:56:16 -04:00 committed by GitHub
parent 5339a489f5
commit f00b112792
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 72 additions and 64 deletions

View File

@ -177,7 +177,7 @@ export default function Success(props: SuccessProps) {
// - Event Type has require confirmation option enabled always
// - EventType has conditionally enabled confirmation option based on how far the booking is scheduled.
// - It's a paid event and payment is pending.
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING;
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation;
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
const isCancelled =

View File

@ -1,37 +1,20 @@
import type { Payment } from "@prisma/client";
import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js";
import type { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js";
import { useElements, useStripe, PaymentElement, Elements } from "@stripe/react-stripe-js";
import type stripejs from "@stripe/stripe-js";
import type { StripeElementLocale } from "@stripe/stripe-js";
import { useRouter } from "next/router";
import type { SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store/stripepayment/lib/server";
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Checkbox } from "@calcom/ui";
import type { EventType } from ".prisma/client";
const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
iconStyle: "solid" as const,
classes: {
base: "block p-2 w-full border-solid border-2 border-default rounded-md dark:bg-black dark:text-inverted dark:border-black focus-within:ring-black focus-within:border-black text-sm",
},
style: {
base: {
color: "#666",
iconColor: "#666",
fontFamily: "ui-sans-serif, system-ui",
fontSmoothing: "antialiased",
fontSize: "16px",
"::placeholder": {
color: "#888888",
},
},
},
} as const;
type Props = {
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
data: StripePaymentData | StripeSetupIntentData;
@ -49,7 +32,7 @@ type States =
| { status: "error"; error: Error }
| { status: "ok" };
export default function PaymentComponent(props: Props) {
const PaymentForm = (props: Props) => {
const { t, i18n } = useLocale();
const router = useRouter();
const [state, setState] = useState<States>({ status: "idle" });
@ -62,20 +45,10 @@ export default function PaymentComponent(props: Props) {
elements?.update({ locale: i18n.language as StripeElementLocale });
}, [elements, i18n.language]);
const handleChange = async (event: StripeCardElementChangeEvent) => {
// Listen for changes in the CardElement
// and display any errors as the customer types their card details
setState({ status: "idle" });
if (event.error)
setState({ status: "error", error: new Error(event.error?.message || t("missing_card_fields")) });
};
const handleSubmit = async (ev: SyntheticEvent) => {
ev.preventDefault();
if (!stripe || !elements || !router.isReady) return;
const card = elements.getElement(CardElement);
if (!card) return;
setState({ status: "processing" });
let payload;
@ -85,16 +58,18 @@ export default function PaymentComponent(props: Props) {
};
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
const setupIntentData = props.payment.data as unknown as StripeSetupIntentData;
payload = await stripe.confirmCardSetup(setupIntentData.setupIntent.client_secret!, {
payment_method: {
card,
payload = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${CAL_URL}/booking/${props.bookingUid}`,
},
});
} else if (paymentOption === "ON_BOOKING") {
const paymentData = props.payment.data as unknown as StripePaymentData;
payload = await stripe.confirmCardPayment(paymentData.client_secret!, {
payment_method: {
card,
payload = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${CAL_URL}/booking/${props.bookingUid}`,
},
});
}
@ -120,15 +95,12 @@ export default function PaymentComponent(props: Props) {
});
}
};
return (
<form id="payment-form" className="bg-subtle mt-4 rounded-md p-6" onSubmit={handleSubmit}>
<p className="font-semibold">{t("card_details")}</p>
<CardElement
className="my-5 bg-white p-2"
id="card-element"
options={CARD_OPTIONS}
onChange={handleChange}
/>
<div>
<PaymentElement onChange={() => setState({ status: "idle" })} />
</div>
{paymentOption === "HOLD" && (
<div className="bg-info mt-2 mb-5 rounded-md p-3">
<Checkbox
@ -149,12 +121,11 @@ export default function PaymentComponent(props: Props) {
<span id="button-text">{t("cancel")}</span>
</Button>
<Button
color="primary"
type="submit"
disabled={!holdAcknowledged || ["processing", "error"].includes(state.status)}
loading={state.status === "processing"}
id="submit"
className="border-subtle border">
color="secondary">
<span id="button-text">
{state.status === "processing" ? (
<div className="spinner" id="spinner" />
@ -173,4 +144,47 @@ export default function PaymentComponent(props: Props) {
)}
</form>
);
};
const ELEMENT_STYLES: stripejs.Appearance = {
theme: "none",
};
const ELEMENT_STYLES_DARK: stripejs.Appearance = {
theme: "night",
variables: {
colorText: "#d6d6d6",
fontWeightNormal: "600",
borderRadius: "6px",
colorBackground: "#101010",
colorPrimary: "#d6d6d6",
},
};
export default function PaymentComponent(props: Props) {
const stripePromise = getStripe((props.payment.data as StripePaymentData).stripe_publishable_key);
const paymentOption = props.payment.paymentOption;
const [darkMode, setDarkMode] = useState<boolean>(false);
let clientSecret: string | null;
useEffect(() => {
setDarkMode(window.matchMedia("(prefers-color-scheme: dark)").matches);
}, []);
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
clientSecret = props.payment.data.setupIntent.client_secret;
} else if (!("setupIntent" in props.payment.data)) {
clientSecret = props.payment.data.client_secret;
}
return (
<Elements
stripe={stripePromise}
options={{
clientSecret: clientSecret!,
appearance: darkMode ? ELEMENT_STYLES_DARK : ELEMENT_STYLES,
}}>
<PaymentForm {...props} />
</Elements>
);
}

View File

@ -1,4 +1,3 @@
import { Elements } from "@stripe/react-stripe-js";
import classNames from "classnames";
import Head from "next/head";
import type { FC } from "react";
@ -6,8 +5,6 @@ import { useEffect, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import type { StripePaymentData } from "@calcom/app-store/stripepayment/lib/server";
import dayjs from "@calcom/dayjs";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
@ -65,9 +62,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 z-50 overflow-y-auto scroll-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<div className="inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
@ -126,17 +123,14 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="text-default mt-4 text-center dark:text-gray-300">{t("paid")}</div>
)}
{props.payment.appId === "stripe" && !props.payment.success && (
<Elements
stripe={getStripe((props.payment.data as StripePaymentData).stripe_publishable_key)}>
<PaymentComponent
payment={props.payment}
eventType={props.eventType}
user={props.user}
location={props.booking.location}
bookingId={props.booking.id}
bookingUid={props.booking.uid}
/>
</Elements>
<PaymentComponent
payment={props.payment}
eventType={props.eventType}
user={props.user}
location={props.booking.location}
bookingId={props.booking.id}
bookingUid={props.booking.uid}
/>
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>