diff --git a/components/StripeCard/.gitignore b/components/StripeCard/.gitignore new file mode 100644 index 0000000000..a27970ab0d --- /dev/null +++ b/components/StripeCard/.gitignore @@ -0,0 +1,41 @@ +.DS_Store +.fileStorage/ +.vscode +.idea +.c9 +.env* +!.env.example* +!.env.prod* +*.csv +*.dat +*.gz +*.log +*.out +*.pid +*.seed +*.sublime-project +*.sublime-workspace +browser.config.js + +lib-cov +logs +node_modules +npm-debug.log +pids +results +allure-results +package-lock.json + +.reaction/config.json + +.next/* +src/.next/* +build +/reports + +docker-compose.override.yml + +# Yalc +.yalc/ +yalc.lock +yalc-packages diff --git a/components/StripeCard/StripeCard.js b/components/StripeCard/StripeCard.js new file mode 100644 index 0000000000..abc2a7b9ad --- /dev/null +++ b/components/StripeCard/StripeCard.js @@ -0,0 +1,216 @@ +import React, { forwardRef, Fragment, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { CardCvcElement, CardExpiryElement, CardNumberElement, useElements, useStripe } from "@stripe/react-stripe-js"; +import { Box, Grid, TextField } from "@material-ui/core"; +import { makeStyles } from "@material-ui/core/styles"; +import Alert from "@material-ui/lab/Alert"; +import useStripePaymentIntent from "./hooks/useStripePaymentIntent"; +import StripeInput from "./StripeInput"; + +const useStyles = makeStyles(() => ({ + stripeForm: { + display: "flex", + flexDirection: "column" + } +})); + +function SplitForm( + { + isSaving, + onSubmit, + onReadyForSaveChange, + stripeCardNumberInputLabel = "Card Number", + stripeCardExpirationDateInputLabel = "Exp.", + stripeCardCVCInputLabel = "CVC" + }, + ref +) { + const classes = useStyles(); + const stripe = useStripe(); + const elements = useElements(); + const options = useMemo( + () => ({ + showIcon: true, + style: { + base: { + fontSize: "18px" + } + } + }), + [] + ); + const [error, setError] = useState(); + + const [formCompletionState, setFormCompletionState] = useState({ + cardNumber: false, + cardExpiry: false, + cardCvc: false + }); + + const [isConfirmationInFlight, setIsConfirmationInFlight] = useState(false); + + const [createStripePaymentIntent] = useStripePaymentIntent(); + + const isReady = useMemo(() => { + const { cardNumber, cardExpiry, cardCvc } = formCompletionState; + + if (!isSaving && !isConfirmationInFlight && cardNumber && cardExpiry && cardCvc) return true; + + return false; + }, [formCompletionState, isSaving, isConfirmationInFlight]); + + useEffect(() => { + onReadyForSaveChange(isReady); + }, [onReadyForSaveChange, isReady]); + + const onInputChange = useCallback( + ({ elementType, complete }) => { + if (formCompletionState[elementType] !== complete) { + setFormCompletionState({ + ...formCompletionState, + [elementType]: complete + }); + } + }, + [formCompletionState, setFormCompletionState] + ); + + const handleSubmit = useCallback( + async (event) => { + if (event) { + event.preventDefault(); + } + + if (!stripe || !elements || isSaving || isConfirmationInFlight) { + // Stripe.js has not loaded yet, saving is in progress or card payment confirmation is in-flight. + return; + } + + setError(); + setIsConfirmationInFlight(true); + + // Await the server secret here + const { paymentIntentClientSecret } = await createStripePaymentIntent(); + + const result = await stripe.confirmCardPayment(paymentIntentClientSecret, { + // eslint-disable-next-line camelcase + payment_method: { + card: elements.getElement(CardNumberElement) + } + }); + + setIsConfirmationInFlight(false); + + if (result.error) { + // Show error to your customer (e.g., insufficient funds) + console.error(result.error.message); // eslint-disable-line + setError(result.error.message); + } else if (result.paymentIntent.status === "succeeded" || result.paymentIntent.status === "requires_capture") { + // Show a success message to your customer + // There's a risk of the customer closing the window before callback + // execution. Set up a webhook or plugin to listen for the + // payment_intent.succeeded event that handles any business critical + // post-payment actions. + const { amount, id } = result.paymentIntent; + onSubmit({ + amount: amount ? parseFloat(amount / 100) : null, + data: { stripePaymentIntentId: id }, + displayName: "Stripe Payment" + }); + } else { + console.error("Payment was not successful"); // eslint-disable-line + setError("Payment was not successful"); + } + }, + [createStripePaymentIntent, onSubmit, stripe, setError, isConfirmationInFlight, setIsConfirmationInFlight] + ); + + useImperativeHandle(ref, () => ({ + submit() { + handleSubmit(); + } + })); + + return ( + + + + {error && ( + + {error} + + )} + +
+ + + + + + + + + + + + + +
+
+
+
+
+ ); +} + +export default forwardRef(SplitForm); diff --git a/components/StripeCard/StripeInput.js b/components/StripeCard/StripeInput.js new file mode 100644 index 0000000000..fd92fa3114 --- /dev/null +++ b/components/StripeCard/StripeInput.js @@ -0,0 +1,18 @@ +import React, { useImperativeHandle, useRef } from "react"; + +function StripeInput({ component: Component, inputRef, ...props }) { + const elementRef = useRef(); + useImperativeHandle(inputRef, () => ({ + focus: () => elementRef.current.focus + })); + return ( + { + elementRef.current = element; + }} + {...props} + /> + ); +} + +export default StripeInput; diff --git a/components/StripeCard/hooks/createStripePaymentIntent.gql b/components/StripeCard/hooks/createStripePaymentIntent.gql new file mode 100644 index 0000000000..239308af03 --- /dev/null +++ b/components/StripeCard/hooks/createStripePaymentIntent.gql @@ -0,0 +1,5 @@ +mutation createStripePaymentIntent($input: CreateStripePaymentIntentInput!) { + createStripePaymentIntent(input: $input) { + paymentIntentClientSecret + } +} diff --git a/components/StripeCard/hooks/useStripePaymentIntent.js b/components/StripeCard/hooks/useStripePaymentIntent.js new file mode 100644 index 0000000000..48191e4df7 --- /dev/null +++ b/components/StripeCard/hooks/useStripePaymentIntent.js @@ -0,0 +1,28 @@ +import { useMutation } from "@apollo/client"; +import useCartStore from "hooks/globalStores/useCartStore"; +import useShop from "hooks/shop/useShop"; + +import createStripePaymentIntentMutation from "./createStripePaymentIntent.gql"; + +export default function useStripePaymentIntent() { + const shop = useShop(); + const { accountCartId, anonymousCartId, anonymousCartToken } = useCartStore(); + + const [createStripePaymentIntentFunc, { loading }] = useMutation(createStripePaymentIntentMutation); + + const createStripePaymentIntent = async () => { + const { data } = await createStripePaymentIntentFunc({ + variables: { + input: { + cartId: anonymousCartId || accountCartId, + shopId: shop?._id, + cartToken: anonymousCartToken + } + } + }); + + return data?.createStripePaymentIntent; + }; + + return [createStripePaymentIntent, loading]; +} diff --git a/components/StripeCard/index.js b/components/StripeCard/index.js new file mode 100644 index 0000000000..c3f6a6ff73 --- /dev/null +++ b/components/StripeCard/index.js @@ -0,0 +1,5 @@ +import StripeWrapper from "./provider/StripeWrapper"; + +export { default } from "./StripeCard"; + +export { StripeWrapper }; diff --git a/components/StripeCard/package.json b/components/StripeCard/package.json new file mode 100644 index 0000000000..422764a892 --- /dev/null +++ b/components/StripeCard/package.json @@ -0,0 +1,15 @@ +{ + "name": "reaction-stripe-sca-react", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Janus Reith", + "license": "Apache-2.0", + "dependencies": { + "@stripe/react-stripe-js": "^1.4.1", + "@stripe/stripe-js": "^1.15.0" + } +} diff --git a/components/StripeCard/provider/StripeWrapper.js b/components/StripeCard/provider/StripeWrapper.js new file mode 100644 index 0000000000..fc8c594c20 --- /dev/null +++ b/components/StripeCard/provider/StripeWrapper.js @@ -0,0 +1,10 @@ +import { Elements } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; + +const stripePromise = loadStripe(process.env.STRIPE_PUBLIC_API_KEY); + +function StripeWrapper({ children }) { + return {children}; +} + +export default StripeWrapper; diff --git a/custom/paymentMethods.js b/custom/paymentMethods.js index ac0217e55f..b7c2bb5a84 100644 --- a/custom/paymentMethods.js +++ b/custom/paymentMethods.js @@ -1,18 +1,18 @@ import ExampleIOUPaymentForm from "@reactioncommerce/components/ExampleIOUPaymentForm/v1"; -import StripePaymentInput from "@reactioncommerce/components/StripePaymentInput/v1"; +import StripeCard from "components/StripeCard"; const paymentMethods = [ - { - displayName: "Credit Card", - InputComponent: StripePaymentInput, - name: "stripe_card", - shouldCollectBillingAddress: true - }, { displayName: "IOU", InputComponent: ExampleIOUPaymentForm, name: "iou_example", shouldCollectBillingAddress: true + }, + { + displayName: "Credit Card (SCA)", + InputComponent: StripeCard, + name: "stripe_payment_intent", + shouldCollectBillingAddress: true } ]; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000000..7b7aa2c772 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/package.json b/package.json index c6aeb93af1..01fdbebae3 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,9 @@ "reacto-form": "~1.4.0", "styled-components": "^5.3.0", "subscriptions-transport-ws": "~0.9.15", - "swr": "^0.5.6" + "swr": "^0.5.6", + "@stripe/react-stripe-js": "^1.4.1", + "@stripe/stripe-js": "^1.16.0" }, "devDependencies": { "@commitlint/cli": "^11.0.0", diff --git a/pages/[lang]/cart/checkout.js b/pages/[lang]/cart/checkout.js index 45c50fc8bc..bf37fbd744 100644 --- a/pages/[lang]/cart/checkout.js +++ b/pages/[lang]/cart/checkout.js @@ -137,7 +137,7 @@ const Checkout = ({ router }) => {
- Router.push("/")} messageText="Ihr Warenkorb ist leer." buttonText="Weiter einkaufen" /> + Router.push("/")} messageText="Your cart is empty." buttonText="Go to main page" />
@@ -150,7 +150,7 @@ const Checkout = ({ router }) => {
- Router.push("/")} messageText="Ihr Warenkorb ist leer." buttonText="Weiter einkaufen" /> + Router.push("/")} messageText="Your cart is empty." buttonText="Go to main page" />
diff --git a/pages/_app.js b/pages/_app.js index c691eefb5d..61825698ab 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -7,6 +7,8 @@ import { ComponentsProvider } from "@reactioncommerce/components-context"; import components from "custom/componentsContext"; import theme from "custom/reactionTheme"; +import { StripeWrapper } from "components/StripeCard"; + export default class App extends NextApp { componentDidMount() { // Remove the server-side injected CSS. @@ -20,14 +22,16 @@ export default class App extends NextApp { const { Component, pageProps, ...rest } = this.props; return ( - - - - - - - - + + + + + + + + + + ); } } diff --git a/yarn.lock b/yarn.lock index c2aca92656..0f3b68a17f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2252,6 +2252,18 @@ resolved "https://registry.npmjs.org/@snyk/gemfile/-/gemfile-1.1.0.tgz" integrity sha512-mLwF+ccuvRZMS0SxUAxA3dAp8mB3m2FxIsBIUWFTYvzxl+E4XTZb8uFrUqXHbcxhZH1Z8taHohNTbzXZn3M8ag== +"@stripe/react-stripe-js@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.4.1.tgz#884d59286fff00ba77389b32c045516f65d7a340" + integrity sha512-FjcVrhf72+9fUL3Lz3xi02ni9tzH1A1x6elXlr6tvBDgSD55oPJuodoP8eC7xTnBIKq0olF5uJvgtkJyDCdzjA== + dependencies: + prop-types "^15.7.2" + +"@stripe/stripe-js@^1.16.0": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.16.0.tgz#73bce24fb7f47d719caa6b151e58e49b4167d463" + integrity sha512-ZSHbiwTrISoaTbpercmYGuY7QTg7HxfFyNgbJBaYbwHWbzMhpEdGTsmMpaBXIU6iiqwEEDaIyD8O6yJ+H5DWCg== + "@testing-library/dom@^7.9.0": version "7.16.1" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.16.1.tgz"