From fda824e727ecac6662ea427f7f2c44de15100bd4 Mon Sep 17 00:00:00 2001 From: Michiel Gerritsen Date: Mon, 28 Nov 2022 12:49:26 +0000 Subject: [PATCH] Feature: Add support for Apple Pay --- renderers.js | 5 +- .../applePayValidationRequest.js | 18 ++++ src/api/applePayValidationRequest/index.js | 3 + src/api/applePayValidationRequest/modifier.js | 5 + src/api/applePayValidationRequest/mutation.js | 13 +++ .../setPaymentMethodOnCartRequest/mutation.js | 4 +- src/components/ApplePayRenderer.jsx | 76 +++++++++++++++ .../CreditCardComponentsRenderer.jsx | 33 ++++--- src/components/DefaultRenderer.jsx | 13 ++- src/components/MollieComponent.jsx | 10 +- src/hooks/useApplePayToPlaceOrder.js | 76 +++++++++++++++ src/hooks/useMollieComponentsApi.js | 22 +++-- src/hooks/useMollieIssuers.js | 2 +- src/hooks/useMolliePlaceOrder.js | 94 ++++++++++++------- src/utility/config.js | 29 +++++- 15 files changed, 340 insertions(+), 63 deletions(-) create mode 100644 src/api/applePayValidationRequest/applePayValidationRequest.js create mode 100644 src/api/applePayValidationRequest/index.js create mode 100644 src/api/applePayValidationRequest/modifier.js create mode 100644 src/api/applePayValidationRequest/mutation.js create mode 100644 src/components/ApplePayRenderer.jsx create mode 100644 src/hooks/useApplePayToPlaceOrder.js diff --git a/renderers.js b/renderers.js index 28266ac..1aafb59 100644 --- a/renderers.js +++ b/renderers.js @@ -1,8 +1,11 @@ +import { applePayType } from './src/utility/config'; import DefaultRenderer from './src/components/DefaultRenderer'; +import ApplePayRenderer from './src/components/ApplePayRenderer'; import CreditCardComponentsRenderer from './src/components/CreditCardComponentsRenderer'; export default { - mollie_methods_applepay: DefaultRenderer, + mollie_methods_applepay: + applePayType === 'direct' ? ApplePayRenderer : DefaultRenderer, mollie_methods_bancontact: DefaultRenderer, mollie_methods_banktransfer: DefaultRenderer, mollie_methods_belfius: DefaultRenderer, diff --git a/src/api/applePayValidationRequest/applePayValidationRequest.js b/src/api/applePayValidationRequest/applePayValidationRequest.js new file mode 100644 index 0000000..95ee974 --- /dev/null +++ b/src/api/applePayValidationRequest/applePayValidationRequest.js @@ -0,0 +1,18 @@ +import sendRequest from '../../../../../api/sendRequest'; +import modifier from './modifier'; +import { MOLLIE_APPLE_PAY_VALIDATION } from './mutation'; + +export default async function applePayValidationRequest( + appDispatch, + validationURL +) { + return modifier( + await sendRequest(appDispatch, { + query: MOLLIE_APPLE_PAY_VALIDATION, + variables: { + domain: window.location.hostname, + validationURL, + }, + }) + ); +} diff --git a/src/api/applePayValidationRequest/index.js b/src/api/applePayValidationRequest/index.js new file mode 100644 index 0000000..7d41a15 --- /dev/null +++ b/src/api/applePayValidationRequest/index.js @@ -0,0 +1,3 @@ +import applePayValidationRequest from './applePayValidationRequest'; + +export default applePayValidationRequest; diff --git a/src/api/applePayValidationRequest/modifier.js b/src/api/applePayValidationRequest/modifier.js new file mode 100644 index 0000000..a04eb02 --- /dev/null +++ b/src/api/applePayValidationRequest/modifier.js @@ -0,0 +1,5 @@ +import { get as _get } from 'lodash-es'; + +export default function applePayValidationRequestModifier(response) { + return _get(response, 'data.mollieApplePayValidation.response', {}); +} diff --git a/src/api/applePayValidationRequest/mutation.js b/src/api/applePayValidationRequest/mutation.js new file mode 100644 index 0000000..eecfadd --- /dev/null +++ b/src/api/applePayValidationRequest/mutation.js @@ -0,0 +1,13 @@ +export const MOLLIE_APPLE_PAY_VALIDATION = ` + mutation mollieApplePayValidation( + $domain: String! + $validationURL: String! + ) { + mollieApplePayValidation( + domain: $domain + validationUrl: $validationURL + ) { + response + } + } +`; diff --git a/src/api/setPaymentMethodOnCartRequest/mutation.js b/src/api/setPaymentMethodOnCartRequest/mutation.js index df1d984..788668e 100644 --- a/src/api/setPaymentMethodOnCartRequest/mutation.js +++ b/src/api/setPaymentMethodOnCartRequest/mutation.js @@ -10,6 +10,7 @@ export const SET_PAYMENT_METHOD_ON_CART = ` $paymentCode: String! $issuer: String, $cardToken: String + $applePayToken: String ) { setPaymentMethodOnCart( input: { @@ -17,7 +18,8 @@ export const SET_PAYMENT_METHOD_ON_CART = ` payment_method: { code: $paymentCode mollie_selected_issuer: $issuer, - mollie_card_token: $cardToken + mollie_card_token: $cardToken, + mollie_applepay_payment_token: $applePayToken } } ) { diff --git a/src/components/ApplePayRenderer.jsx b/src/components/ApplePayRenderer.jsx new file mode 100644 index 0000000..2241aa2 --- /dev/null +++ b/src/components/ApplePayRenderer.jsx @@ -0,0 +1,76 @@ +import React, { useEffect, useCallback } from 'react'; +import { shape, func } from 'prop-types'; +import { paymentMethodShape } from '../../../../utils/payment'; +import RadioInput from '../../../../components/common/Form/RadioInput'; +import useCheckoutFormContext from '../../../../hook/useCheckoutFormContext'; +import useApplePayToPlaceOrder from '../hooks/useApplePayToPlaceOrder'; +import usePaymentMethodFormContext from '../../../../components/paymentMethod/hooks/usePaymentMethodFormContext'; + +function ApplePayRenderer({ method, selected, actions }) { + const isSelected = method.code === selected.code; + + const { registerPaymentAction } = useCheckoutFormContext(); + const { applePayPlaceOrder } = useApplePayToPlaceOrder(); + const { submitHandler } = usePaymentMethodFormContext(); + const submitHandlerCallback = useCallback(submitHandler, [submitHandler]); + + useEffect(() => { + if (!isSelected) { + return; + } + + registerPaymentAction(method.code, applePayPlaceOrder); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSelected, method.code]); + + useEffect(() => { + if (!isSelected) { + return; + } + + // Mark this method as selected + submitHandlerCallback(method.code); + }, [method.code, isSelected]); // eslint-disable-line react-hooks/exhaustive-deps + + try { + if (!window.ApplePaySession || !window.ApplePaySession.canMakePayments()) { + return null; + } + } catch (error) { + return null; + } + + if (!isSelected) { + return ( + + ); + } + + return ( +
+
+ +
+
+ ); +} + +ApplePayRenderer.propTypes = { + method: paymentMethodShape.isRequired, + selected: paymentMethodShape.isRequired, + actions: shape({ change: func }).isRequired, +}; + +export default ApplePayRenderer; diff --git a/src/components/CreditCardComponentsRenderer.jsx b/src/components/CreditCardComponentsRenderer.jsx index 36c5468..965fa45 100644 --- a/src/components/CreditCardComponentsRenderer.jsx +++ b/src/components/CreditCardComponentsRenderer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { shape, func } from 'prop-types'; import { __ } from '../../../../i18n'; import { paymentMethodShape } from '../../../../utils/payment'; @@ -7,31 +7,40 @@ import usePaymentMethodFormContext from '../../../../components/paymentMethod/ho import RadioInput from '../../../../components/common/Form/RadioInput'; import { useComponents, profileId } from '../utility/config'; import useMolliePlaceOrder from '../hooks/useMolliePlaceOrder'; -import useMollieComponentsApi from '../hooks/useMollieComponentsApi'; import MollieComponent from './MollieComponent'; function CreditCardComponentsRenderer({ method, selected, actions }) { const isSelected = method.code === selected.code; - const [mollie, setMollie] = useState(); - useMollieComponentsApi(setMollie); - const { registerPaymentAction } = useCheckoutFormContext(); const { submitHandler } = usePaymentMethodFormContext(); - const { placeOrderWithToken } = useMolliePlaceOrder({ + const submitHandlerCallback = useCallback(submitHandler, [submitHandler]); + const { mollie, placeOrderWithToken } = useMolliePlaceOrder({ methodCode: method.code, selectedIssuer: null, }); useEffect(() => { - registerPaymentAction(method.code, (...args) => { - placeOrderWithToken(mollie, ...args); - }); - }, [method.code, mollie]); + if (!isSelected || !mollie) { + return; + } + + registerPaymentAction(method.code, placeOrderWithToken); + }, [ + registerPaymentAction, + placeOrderWithToken, + isSelected, + method.code, + mollie, + ]); useEffect(() => { + if (!isSelected) { + return; + } + // Mark this method as selected - submitHandler(method.code); - }, [isSelected]); + submitHandlerCallback(method.code); + }, [method.code, isSelected]); // eslint-disable-line react-hooks/exhaustive-deps if (!isSelected || !useComponents || !profileId || !mollie) { return ( diff --git a/src/components/DefaultRenderer.jsx b/src/components/DefaultRenderer.jsx index 5e8ff84..1dcb999 100644 --- a/src/components/DefaultRenderer.jsx +++ b/src/components/DefaultRenderer.jsx @@ -10,7 +10,7 @@ import Issuer from './Issuer'; function DefaultRenderer({ method, selected, actions }) { const isSelected = method.code === selected.code; - const [issuers, setIssuers] = useState([]); + const [issuers, setIssuers] = useState(false); const { getMollieIssuers, selectedIssuer, setSelectedIssuer } = useMollieIssuers(); const { registerPaymentAction } = useCheckoutFormContext(); @@ -20,11 +20,16 @@ function DefaultRenderer({ method, selected, actions }) { }); useEffect(() => { + if (!isSelected) { + return; + } + registerPaymentAction(method.code, placeOrder); - }, [method.code, selectedIssuer]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [placeOrder, isSelected, method.code, selectedIssuer]); useEffect(() => { - if (!isSelected) { + if (issuers !== false || !isSelected) { return; } @@ -33,7 +38,7 @@ function DefaultRenderer({ method, selected, actions }) { } getIssuers(); - }, [isSelected, method.code]); + }, [isSelected, issuers]); // eslint-disable-line react-hooks/exhaustive-deps if (!isSelected || !issuers) { return ( diff --git a/src/components/MollieComponent.jsx b/src/components/MollieComponent.jsx index 5099008..89bf502 100644 --- a/src/components/MollieComponent.jsx +++ b/src/components/MollieComponent.jsx @@ -27,15 +27,19 @@ function MollieComponent({ mollie, type, label }) { componentObject.unmount(); } }; - }, [componentObject]); + }, [mollie, componentObject]); // eslint-disable-line react-hooks/exhaustive-deps return (
-
- {errorMessage &&
{errorMessage}
} +
+ {errorMessage && ( +
+ {errorMessage} +
+ )}
); diff --git a/src/hooks/useApplePayToPlaceOrder.js b/src/hooks/useApplePayToPlaceOrder.js new file mode 100644 index 0000000..3b1f979 --- /dev/null +++ b/src/hooks/useApplePayToPlaceOrder.js @@ -0,0 +1,76 @@ +import { useCallback, useContext } from 'react'; +import { get as _get } from 'lodash-es'; +import CartContext from '../../../../context/Cart/CartContext'; +import useMollieAppContext from './useMollieAppContext'; +import applePayValidationRequest from '../api/applePayValidationRequest'; +import useMolliePlaceOrder from './useMolliePlaceOrder'; +import { currencyCode, defaultCountry, storeName } from '../utility/config'; + +export default function useApplePayToPlaceOrder() { + const { appDispatch } = useMollieAppContext(); + const [cartData] = useContext(CartContext); + const cart = _get(cartData, 'cart'); + const prices = _get(cart, 'prices') || {}; + const { placeOrder } = useMolliePlaceOrder({ + methodCode: 'mollie_methods_applepay', + }); + + return { + applePayPlaceOrder: useCallback(() => { + let session; + + const request = { + countryCode: defaultCountry, + currencyCode, + + supportedNetworks: ['amex', 'maestro', 'masterCard', 'visa', 'vPay'], + merchantCapabilities: ['supports3DS'], + total: { + // TODO: Make this configurable + label: storeName, + amount: prices.grandTotalAmount, + }, + }; + + if (!session) { + // eslint-disable-next-line no-undef + session = new ApplePaySession(3, request); + } + + session.onpaymentmethodselected = () => { + session.completePaymentMethodSelection( + { + label: 'Total', + type: 'final', + amount: prices.grandTotalAmount, + }, + [] + ); + }; + + session.onvalidatemerchant = async (event) => { + const response = await applePayValidationRequest( + appDispatch, + event.validationURL + ); + + session.completeMerchantValidation(JSON.parse(response)); + }; + + session.onpaymentauthorized = (event) => { + try { + placeOrder({ applePayToken: JSON.stringify(event.payment.token) }); + } catch { + // eslint-disable-next-line no-undef + session.completePayment(ApplePaySession.STATUS_ERROR); + } + }; + + session.oncancel = () => { + session = null; + }; + + session.begin(); + }, [prices.grandTotalAmount, appDispatch, placeOrder]), + }; +} diff --git a/src/hooks/useMollieComponentsApi.js b/src/hooks/useMollieComponentsApi.js index 9fe9a1e..7c2e444 100644 --- a/src/hooks/useMollieComponentsApi.js +++ b/src/hooks/useMollieComponentsApi.js @@ -1,12 +1,22 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { locale, profileId, testmode } from '../utility/config'; -const useMollieComponentsApi = (callback) => { - let molliePromise; +const useMollieComponentsApi = (methodCode, callback) => { const [mollie, setMollie] = useState(); - const mollieCallback = useCallback(callback, []); + const mollieCallback = useCallback(callback, [callback]); useEffect(() => { + let molliePromise; + + if ( + typeof profileId === 'undefined' || + typeof locale === 'undefined' || + typeof testmode === 'undefined' || + methodCode !== 'mollie_methods_creditcard' + ) { + return; + } + if (!molliePromise) { molliePromise = new Promise((resolve) => { const script = document.createElement('script'); @@ -21,13 +31,13 @@ const useMollieComponentsApi = (callback) => { } molliePromise.then(setMollie); - }, []); + }, [methodCode]); useEffect(() => { if (mollie) { mollieCallback(mollie); } - }, [mollie]); + }, [mollieCallback, mollie]); }; export default useMollieComponentsApi; diff --git a/src/hooks/useMollieIssuers.js b/src/hooks/useMollieIssuers.js index 17a4580..f960de6 100644 --- a/src/hooks/useMollieIssuers.js +++ b/src/hooks/useMollieIssuers.js @@ -5,7 +5,7 @@ import useMollieAppContext from './useMollieAppContext'; import useMollieCartContext from './useMollieCartContext'; export default function useMollieIssuers() { - const { cartId, setCartInfo } = useMollieCartContext(); + const { cartId } = useMollieCartContext(); const { setPageLoader, setErrorMessage, appDispatch } = useMollieAppContext(); const [selectedIssuer, setSelectedIssuer] = useState(null); diff --git a/src/hooks/useMolliePlaceOrder.js b/src/hooks/useMolliePlaceOrder.js index 57c5f26..5d7d86e 100644 --- a/src/hooks/useMolliePlaceOrder.js +++ b/src/hooks/useMolliePlaceOrder.js @@ -1,50 +1,79 @@ +import { useState, useCallback } from 'react'; import { __ } from '../../../../i18n'; import setPaymentMethodOnCartRequest from '../api/setPaymentMethodOnCartRequest'; import useMollieAppContext from './useMollieAppContext'; import useMollieCartContext from './useMollieCartContext'; import placeOrderRequest from '../api/placeOrderRequest'; import restoreCartRequest from '../api/restoreCartRequest'; +import useMollieComponentsApi from './useMollieComponentsApi'; +import LocalStorage from '../../../../utils/localStorage'; +import { config } from '../../../../config'; export default function useMolliePlaceOrder({ methodCode, selectedIssuer }) { const { cartId, setCartInfo } = useMollieCartContext(); const { setPageLoader, setErrorMessage, appDispatch } = useMollieAppContext(); + const [mollie, setMollie] = useState(); + useMollieComponentsApi(methodCode, setMollie); - const placeOrder = async ({ token = null }) => { - try { - setPageLoader(true); - await setPaymentMethodOnCartRequest(appDispatch, { - cartId, - paymentCode: methodCode, - issuer: selectedIssuer, - cardToken: token, - }); - } catch (error) { - setPageLoader(false); - setErrorMessage( - __('Something went wrong while adding the payment method to the quote.') - ); - } + const placeOrder = useCallback( + async ({ token = null, applePayToken = null }) => { + try { + setPageLoader(true); + await setPaymentMethodOnCartRequest(appDispatch, { + cartId, + paymentCode: methodCode, + issuer: selectedIssuer, + cardToken: token, + applePayToken, + }); + } catch (error) { + setPageLoader(false); + setErrorMessage( + __( + 'Something went wrong while adding the payment method to the quote.' + ) + ); + } - try { - const { mollie_redirect_url: mollieRedirectUrl } = - await placeOrderRequest(appDispatch); + try { + const { mollie_redirect_url: mollieRedirectUrl } = + await placeOrderRequest(appDispatch); - if (!mollieRedirectUrl) { - throw Error('No redirect url found'); - } + if ( + !mollieRedirectUrl && + (methodCode === 'mollie_methods_applepay' || + methodCode === 'mollie_methods_creditcard') + ) { + LocalStorage.clearCheckoutStorage(); + window.location.replace(config.successPageRedirectUrl); + } - setPageLoader(false); - window.location.assign(mollieRedirectUrl); - } catch (error) { - const cart = await restoreCartRequest(cartId); - setCartInfo(cart); + if (!mollieRedirectUrl) { + throw Error('No redirect url found'); + } - setPageLoader(false); - setErrorMessage(__('Something went wrong while placing the order.')); - } - }; + setPageLoader(false); + window.location.assign(mollieRedirectUrl); + } catch (error) { + const cart = await restoreCartRequest(cartId); + setCartInfo(cart); - const placeOrderWithToken = async (mollie) => { + setPageLoader(false); + setErrorMessage(__('Something went wrong while placing the order.')); + } + }, + [ + setPageLoader, + setErrorMessage, + appDispatch, + cartId, + methodCode, + selectedIssuer, + setCartInfo, + ] + ); + + const placeOrderWithToken = useCallback(async () => { setPageLoader(true); const { token, error } = await mollie.createToken(); @@ -56,9 +85,10 @@ export default function useMolliePlaceOrder({ methodCode, selectedIssuer }) { } await placeOrder({ token }); - }; + }, [setPageLoader, setErrorMessage, mollie, placeOrder]); return { + mollie, placeOrder, placeOrderWithToken, }; diff --git a/src/utility/config.js b/src/utility/config.js index d291bd2..2433581 100644 --- a/src/utility/config.js +++ b/src/utility/config.js @@ -1,9 +1,23 @@ import RootElement from '../../../../utils/rootElement'; +import env from '../../../../utils/env'; +import { config } from '../../../../config'; const paymentConfig = RootElement.getPaymentConfig(); -const mollieConfig = paymentConfig.mollie; -const useComponents = mollieConfig.creditcard.use_components; +const mollieConfig = paymentConfig.mollie || {}; +const useComponents = mollieConfig?.creditcard?.use_components; +const applePayType = mollieConfig?.applepay?.integration_type; +const storeName = mollieConfig?.store?.name || 'Your Store Name'; + +const { code: rootCurrencyCode } = RootElement.getCurrency(); +const envCurrencyCode = env.currencyCode; + +const currencyCode = envCurrencyCode || rootCurrencyCode; + +const defaultCountry = + env.defaultCountry || + RootElement.getDefaultCountryId() || + config.defaultCountry; const { profile_id, // eslint-disable-line camelcase @@ -11,4 +25,13 @@ const { testmode, } = mollieConfig; -export { profile_id as profileId, useComponents, locale, testmode }; // eslint-disable-line camelcase +export { + profile_id as profileId, // eslint-disable-line camelcase + useComponents, + locale, + testmode, + applePayType, + currencyCode, + defaultCountry, + storeName, +};