client/components/mma/paymentUpdate/card/stripeCardInputForm.tsx (270 lines of code) (raw):
import { css } from '@emotion/react';
import { space, until } from '@guardian/source/foundations';
import {
Button,
SvgArrowRightStraight,
} from '@guardian/source/react-components';
import { ErrorSummary } from '@guardian/source-development-kitchen/react-components';
import * as Sentry from '@sentry/browser';
import {
CardNumberElement,
useElements,
useStripe,
} from '@stripe/react-stripe-js';
import type { StripeElementBase } from '@stripe/stripe-js';
import { useState } from 'react';
import type { StripeSetupIntent } from '../../../../../shared/stripeSetupIntent';
import { STRIPE_PUBLIC_KEY_HEADER } from '../../../../../shared/stripeSetupIntent';
import { GenericErrorScreen } from '../../../shared/GenericErrorScreen';
import { LoadingCircleIcon } from '../../shared/assets/LoadingCircleIcon';
import type { CardInputFormProps } from './CardInputForm';
import { FlexCardElement } from './FlexCardElement';
import type { StripePaymentMethod } from './NewCardPaymentMethodDetail';
import { NewCardPaymentMethodDetail } from './NewCardPaymentMethodDetail';
import { Recaptcha } from './Recaptcha';
interface StripeSetupIntentDetails {
stripeSetupIntent?: StripeSetupIntent;
stripeSetupIntentError?: Error;
}
interface StripeCardInputFormProps
extends CardInputFormProps,
StripeSetupIntentDetails {}
interface StripeInputFormError {
code?: string;
message?: string;
type?: string;
}
export const StripeCardInputForm = (props: StripeCardInputFormProps) => {
const [isValidating, setIsValidating] = useState<boolean>(false);
const [stripeSetupIntent, setStripeSetupIntent] =
useState<StripeSetupIntent | null>();
const [stripeSetupIntentError, setStripeSetupIntentError] =
useState<Error>();
const [cardNumberElement, setCardNumberElement] = useState<
undefined | StripeElementBase
>();
const [cardExpiryElement, setCardExpiryElement] = useState<
undefined | StripeElementBase
>();
const [cardCVCElement, setCardCVCElement] = useState<
undefined | StripeElementBase
>();
const [error, setError] = useState<StripeInputFormError>({});
const elements = useElements();
const stripe = useStripe();
const [recaptchaToken, setRecaptchaToken] = useState<string | undefined>(
undefined,
);
const cardFormIsLoaded = () => {
return (
stripe && cardNumberElement && cardExpiryElement && cardCVCElement
);
};
const renderError = () => {
if (error && error.message) {
return error.message
.split('.')
.filter((_) => _.trim().length)
.map((sentence, index) => {
const sentenceEnd = sentence.includes('.') ? '' : '.';
return (
<div
key={index}
css={css`
margin-top: ${space[4]}px;
:first-of-type {
margin-top: 0;
}
`}
>
<ErrorSummary message={sentence + sentenceEnd} />
</div>
);
});
} else {
return null;
}
};
const loadSetupIntent = (): Promise<StripeSetupIntent | null> =>
fetch('/api/payment/card', {
method: 'POST',
credentials: 'include',
headers: {
[STRIPE_PUBLIC_KEY_HEADER]: props.stripeApiKey,
},
body: recaptchaToken,
})
.then(async (response) => {
if (response.ok) {
return await response.json();
}
const locationHeaderValue = response.headers.get('Location');
if (response.status === 401 && locationHeaderValue) {
window.location.replace(locationHeaderValue);
return null;
} else {
throw new Error(
`Failed to load SetupIntent : ${response.status} ${
response.statusText
} : ${await response.text()}`,
);
}
})
.then((setupIntent: StripeSetupIntent) => setupIntent)
.catch((error) => {
Sentry.captureException(error);
setStripeSetupIntentError(error);
return null;
});
async function startCardUpdate() {
setIsValidating(true);
const cardElement = elements?.getElement(CardNumberElement);
if (!cardElement) {
Sentry.captureException('StripeElements returning null');
setError({
message:
'Something went wrong, please check the details and try again.',
});
setIsValidating(false);
return;
}
if (!recaptchaToken) {
setIsValidating(false);
setError({ message: 'Recaptcha has not been completed' });
return;
}
// new recaptcha token needed with each call to create a setup intent
let setupIntent;
if (!stripeSetupIntent) {
setupIntent = await loadSetupIntent();
setStripeSetupIntent(setupIntent);
} else {
setupIntent = stripeSetupIntent;
}
if (stripe && setupIntent) {
const createPaymentMethodResult = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
billing_details: {
name: props.userEmail,
email: props.userEmail,
},
});
if (
!(
createPaymentMethodResult &&
createPaymentMethodResult.paymentMethod &&
createPaymentMethodResult.paymentMethod.id &&
createPaymentMethodResult.paymentMethod.card &&
createPaymentMethodResult.paymentMethod.card.brand &&
createPaymentMethodResult.paymentMethod.card.last4
)
) {
Sentry.captureException(
createPaymentMethodResult.error ||
'something missing from the createPaymentMethod response',
);
setError(
createPaymentMethodResult.error || {
message:
'Something went wrong, please check the details and try again.',
},
);
setIsValidating(false);
return;
}
const intentResult = await stripe.confirmCardSetup(
setupIntent.client_secret,
{ payment_method: createPaymentMethodResult.paymentMethod.id },
);
if (
intentResult.setupIntent &&
intentResult.setupIntent.status &&
intentResult.setupIntent.status === 'succeeded'
) {
setIsValidating(false);
const newPaymentMethodDetail = new NewCardPaymentMethodDetail(
createPaymentMethodResult.paymentMethod as StripePaymentMethod,
props.stripeApiKey,
);
props.newPaymentMethodDetailUpdater(newPaymentMethodDetail);
props.executePaymentUpdate(newPaymentMethodDetail);
} else {
Sentry.captureException(
intentResult.error ||
'something missing from the SetupIntent response',
);
setError(
intentResult.error || {
message:
'Something went wrong, please check the details and try again.',
},
);
setIsValidating(false);
}
}
}
return stripeSetupIntentError ? (
<GenericErrorScreen loggingMessage={'error loading SetupIntent'} />
) : (
<>
<FlexCardElement
setCardNumberElement={setCardNumberElement}
setCardExpiryElement={setCardExpiryElement}
setCardCVCElement={setCardCVCElement}
/>
<Recaptcha
setRecaptchaToken={setRecaptchaToken}
setStripeSetupIntent={setStripeSetupIntent}
/>
<div
css={{
marginBottom: `${space[12]}px`,
width: '500px',
maxWidth: '100%',
}}
>
<div
css={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
}}
>
<div
css={{
[until.mobileLandscape]: {
width: '100%',
},
}}
>
<Button
disabled={isValidating || !cardFormIsLoaded}
priority="primary"
onClick={startCardUpdate}
icon={
isValidating ? (
<LoadingCircleIcon
additionalCss={css`
padding: 3px;
`}
/>
) : (
<SvgArrowRightStraight />
)
}
iconSide="right"
>
Update payment method
</Button>
</div>
</div>
</div>
<div
css={css`
margin-top: ${space[9]}px;
margin-bottom: ${space[9]}px;
`}
>
{renderError()}
</div>
</>
);
};