Skip to content

Commit

Permalink
feat(payment): integrate new adyen payment flow (incl 3DS)
Browse files Browse the repository at this point in the history
  • Loading branch information
RCVZ authored and ChristiaanScheermeijer committed May 30, 2023
1 parent 9e1b8da commit bb0f745
Show file tree
Hide file tree
Showing 30 changed files with 591 additions and 125 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"deploy:github": "node ./scripts/deploy-github.js"
},
"dependencies": {
"@adyen/adyen-web": "^5.42.1",
"@codeceptjs/allure-legacy": "^1.0.2",
"@inplayer-org/inplayer.js": "^3.13.12",
"classnames": "^2.3.1",
Expand Down
80 changes: 36 additions & 44 deletions src/components/Adyen/Adyen.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,62 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import AdyenCheckout from '@adyen/adyen-web';
import type { CoreOptions } from '@adyen/adyen-web/dist/types/core/types';
import type { PaymentMethods } from '@adyen/adyen-web/dist/types/types';

import styles from './Adyen.module.scss';

import { addScript, addStyleSheet } from '#src/utils/dom';
import Button from '#components/Button/Button';
import FormFeedback from '#components/FormFeedback/FormFeedback';
import { ADYEN_LIVE_CLIENT_KEY, ADYEN_TEST_CLIENT_KEY } from '#src/config';
import useOpaqueId from '#src/hooks/useOpaqueId';
import '@adyen/adyen-web/dist/adyen.css';
import './AdyenForm.scss';

type Props = {
onChange?: (data: AdyenEventData) => void;
onSubmit: (data: AdyenEventData) => void;
configuration: CoreOptions;
error?: string;
environment?: 'test' | 'live';
type: AdyenPaymentMethodType;
};

const Adyen: React.FC<Props> = ({ onChange, onSubmit, error, environment = 'test' }) => {
const Adyen: React.FC<Props> = ({ configuration, error, type }) => {
const { t } = useTranslation('account');
const id = useOpaqueId('adyen', 'checkout');
const adyenRef = useRef<AdyenCheckout>(null) as React.MutableRefObject<AdyenCheckout>;
const [scriptsLoaded, setScriptsLoaded] = useState(!!window.AdyenCheckout);
const checkoutElementRef = useRef(null);
const checkoutRef = useRef<InstanceType<PaymentMethods[typeof type]>>();

useEffect(() => {
const loadExternalScripts = async () => {
await Promise.all([
addScript(`https://checkoutshopper-${environment}.adyen.com/checkoutshopper/sdk/3.10.1/adyen.js`),
addStyleSheet(`https://checkoutshopper-${environment}.adyen.com/checkoutshopper/sdk/3.11.4/adyen.css`),
]);

setScriptsLoaded(true);
};
if (!checkoutElementRef.current || !configuration.session) {
return;
}

// noinspection JSIgnoredPromiseFromCall
loadExternalScripts();
}, [environment]);
const createCheckout = async () => {
const checkout = await AdyenCheckout(configuration);

useEffect(() => {
if (scriptsLoaded) {
const configuration = {
showPayButton: false,
clientKey: environment === 'test' ? ADYEN_TEST_CLIENT_KEY : ADYEN_LIVE_CLIENT_KEY,
environment,
onSubmit,
onChange,
};
if (checkoutElementRef.current) {
checkoutRef.current = checkout.create(type);
checkoutRef.current?.mount(checkoutElementRef.current);
}
};

// @ts-ignore
adyenRef.current = new window.AdyenCheckout(configuration).create('card').mount(`#${id}`);
createCheckout();

return () => {
if (adyenRef.current) {
adyenRef.current.unmount();
}
};
}
}, [environment, id, onChange, onSubmit, scriptsLoaded]);
return () => {
checkoutRef.current?.unmount();
};
}, [configuration, type]);

return (
<div className={styles.adyen}>
{error ? <FormFeedback variant="error">{error}</FormFeedback> : null}
<div className={styles.container}>
<div id={id} />
</div>
<Button label={t('checkout.continue')} variant="contained" color="primary" size="large" onClick={() => adyenRef.current?.submit()} fullWidth />
<div className={styles.container} ref={checkoutElementRef} />
<Button
label={t('checkout.continue')}
variant="contained"
color="primary"
size="large"
onClick={() => {
checkoutRef.current && checkoutRef.current.submit();
}}
fullWidth
/>
</div>
);
};
Expand Down
9 changes: 9 additions & 0 deletions src/components/Adyen/AdyenForm.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@
font-size: 14px;
}
}

.adyen-checkout__label {
.adyen-checkout__label__text {
color: variables.$white;
font-family: var(--body-font-family);
font-size: 16px;
line-height: 18px;
}
}
6 changes: 4 additions & 2 deletions src/components/CreditCardCVCField/CreditCardCVCField.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

import TextField from '../TextField/TextField';

Expand All @@ -10,6 +11,7 @@ type Props = {
};

const CreditCardCVCField: React.FC<Props> = ({ value, onChange, error, ...props }: Props) => {
const { t } = useTranslation('user');
const formatCVC: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const clearValue = e.target.value.replace(/\D+/g, '');
if (onChange) {
Expand All @@ -19,8 +21,8 @@ const CreditCardCVCField: React.FC<Props> = ({ value, onChange, error, ...props
};
return (
<TextField
label={`CVC / CVV`}
aria-label="Security code"
label={t('payment.security_code')}
aria-label={t('payment.security_code')}
{...props}
error={!!error}
helperText={error ? error : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ exports[`<CreditCardCVCField> > renders and matches snapshot 1`] = `
class="_label_e16c1b"
for="text-field_1235_cardCVC"
>
CVC / CVV
payment.security_code
</label>
<div
class="_container_e16c1b"
>
<input
aria-label="Security code"
aria-label="payment.security_code"
class="_input_e16c1b"
id="text-field_1235_cardCVC"
name="cardCVC"
Expand Down
13 changes: 13 additions & 0 deletions src/components/FinalizePayment/FinalizePayment.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.container {
min-height: 90px;
margin-top: 24px;
text-align: center;
}

.title {
width: 100%;
margin-bottom: 24px;
font-weight: var(--body-font-weight-bold);
font-size: 24px;
}

76 changes: 76 additions & 0 deletions src/components/FinalizePayment/FinalizePayment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import { useSearchParams } from 'react-router-dom';

import styles from './FinalizePayment.module.scss';

import Button from '#components/Button/Button';
import Spinner from '#components/Spinner/Spinner';
import useEventCallback from '#src/hooks/useEventCallback';
import { reloadActiveSubscription } from '#src/stores/AccountController';
import { useConfigStore } from '#src/stores/ConfigStore';
import { replaceQueryParam, removeQueryParam, addQueryParam } from '#src/utils/location';
import { finalizeAdyenPayment } from '#src/stores/CheckoutController';

const FinalizePayment = () => {
const { t } = useTranslation('account');
const navigate = useNavigate();
const location = useLocation();

const { accessModel } = useConfigStore(({ accessModel }) => ({ accessModel }));
const [searchParams] = useSearchParams();
const redirectResult = searchParams.get('redirectResult');
const orderIdQueryParam = searchParams.get('orderId');

const [errorMessage, setErrorMessage] = useState<string>();

const paymentSuccessUrl = useMemo(() => {
return accessModel === 'SVOD' ? replaceQueryParam(location, 'u', 'welcome') : removeQueryParam(location, 'u');
}, [accessModel, location]);

const checkPaymentResult = useEventCallback(async (redirectResult: string) => {
const orderId = orderIdQueryParam ? parseInt(orderIdQueryParam, 10) : undefined;

try {
await finalizeAdyenPayment({ redirectResult: decodeURI(redirectResult) }, orderId);
await reloadActiveSubscription({ delay: 2000 });

navigate(paymentSuccessUrl);
} catch (error: unknown) {
if (error instanceof Error) {
setErrorMessage(error.message);
}
}
});

useEffect(() => {
if (!redirectResult) return;

checkPaymentResult(redirectResult);
}, [checkPaymentResult, redirectResult]);

return (
<div className={styles.container}>
{errorMessage ? (
<>
<h2 className={styles.title}>{errorMessage}</h2>
<Button
label={t('checkout.go_back_to_checkout')}
variant="contained"
color="primary"
size="large"
onClick={() => navigate(addQueryParam(location, 'u', 'checkout'))}
fullWidth
/>
</>
) : (
<div className={styles.loading}>
<Spinner />
</div>
)}
</div>
);
};

export default FinalizePayment;
2 changes: 1 addition & 1 deletion src/components/Payment/Payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const Payment = ({
/>
<div className={styles.cardDetails}>
<TextField label={t('user:payment.expiry_date')} value={activePaymentDetail.paymentMethodSpecificParams.cardExpirationDate} editing={false} />
<TextField label={t('user:payment.cvc_cvv')} value={'******'} editing={false} />
<TextField label={t('user:payment.security_code')} value={'******'} editing={false} />
</div>
</div>
) : (
Expand Down
2 changes: 1 addition & 1 deletion src/components/Payment/__snapshots__/Payment.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ exports[`<Payment> > renders and matches snapshot 1`] = `
class="_label_e16c1b"
for="text-field_1235"
>
user:payment.cvc_cvv
user:payment.security_code
</label>
<p>
******
Expand Down
5 changes: 4 additions & 1 deletion src/containers/AccountModal/AccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import Welcome from '#components/Welcome/Welcome';
import PaymentFailed from '#components/PaymentFailed/PaymentFailed';
import Dialog from '#components/Dialog/Dialog';
import { addQueryParam, removeQueryParam } from '#src/utils/location';
import WaitingForPayment from '#src/components/WaitingForPayment/WaitingForPayment';
import FinalizePayment from '#components/FinalizePayment/FinalizePayment';
import WaitingForPayment from '#components/WaitingForPayment/WaitingForPayment';

const PUBLIC_VIEWS = ['login', 'create-account', 'forgot-password', 'reset-password', 'send-confirmation', 'edit-password'];

Expand Down Expand Up @@ -93,6 +94,8 @@ const AccountModal = () => {
return <RenewSubscription />;
case 'waiting-for-payment':
return <WaitingForPayment />;
case 'finalize-payment':
return <FinalizePayment />;
}
};

Expand Down
Loading

0 comments on commit bb0f745

Please sign in to comment.