From 934d5b01d6c846712c0ea9a556c6d41f49f00e88 Mon Sep 17 00:00:00 2001 From: Chamindu Date: Wed, 9 Aug 2023 14:25:21 +0530 Subject: [PATCH] Add stripe and Netlify functions to add a payment capability --- .gitignore | 4 + netlify/functions/create-payment-intent.js | 26 ++++ package-lock.json | 135 ++++++++++++++++-- package.json | 6 +- src/components/button/button.component.jsx | 10 +- src/components/button/button.styles.jsx | 23 +++ .../payment-form/payment-form.component.jsx | 78 ++++++++++ .../payment-form/payment-form.styles.jsx | 21 +++ src/index.js | 7 +- src/routes/checkout/checkout.component.jsx | 3 + src/utils/stripe/stripe.utils.js | 6 + 11 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 netlify/functions/create-payment-intent.js create mode 100644 src/components/payment-form/payment-form.component.jsx create mode 100644 src/components/payment-form/payment-form.styles.jsx create mode 100644 src/utils/stripe/stripe.utils.js diff --git a/.gitignore b/.gitignore index 4d29575..bb21321 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local @@ -21,3 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Local Netlify folder +.netlify diff --git a/netlify/functions/create-payment-intent.js b/netlify/functions/create-payment-intent.js new file mode 100644 index 0000000..32c5f63 --- /dev/null +++ b/netlify/functions/create-payment-intent.js @@ -0,0 +1,26 @@ +require("dotenv").config(); +const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); + +exports.handler = async (event) => { + try { + const { amount } = JSON.parse(event.body); + + const paymentIntent = await stripe.paymentIntents.create({ + amount, + currency: "usd", + payment_method_types: ["card"], + }); + + return { + statusCode: 200, + body: JSON.stringify({ paymentIntent }), + }; + } catch (error) { + console.log({ error }); + + return { + statusCode: 400, + body: JSON.stringify({ error }), + }; + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cbdecd1..a009b06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "market-place-pet-project", "version": "0.1.0", "dependencies": { + "@reduxjs/toolkit": "^1.9.5", + "@stripe/react-stripe-js": "^2.1.2", + "@stripe/stripe-js": "^2.0.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "dotenv": "^16.3.1", "firebase": "^10.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -25,6 +29,7 @@ "redux-thunk": "^2.4.2", "reselect": "^4.1.8", "sass": "^1.63.6", + "stripe": "^12.17.0", "styled-components": "^6.0.4", "web-vitals": "^2.1.4" }, @@ -4853,6 +4858,29 @@ "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz", "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==" }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", + "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==", + "dependencies": { + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", @@ -4984,6 +5012,24 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.1.2.tgz", + "integrity": "sha512-2JcKRvOdMD698uGrY0cINLHb6XHnY24L+wlWlw2+TbI2aXlQZ9t8Mc+KssFES+EuNgISbrp7fJR3w/13epfiOw==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.0.0.tgz", + "integrity": "sha512-SxZnf192En0uAfgbigUIj7oJYaXgGc5AI1aos59YXvO8DPeLI0AtT4oMg/Wuk17wbpquEv73+akyCe7xdEjGlA==" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -9032,11 +9078,14 @@ } }, "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, "node_modules/dotenv-expand": { @@ -18854,6 +18903,14 @@ } } }, + "node_modules/react-scripts/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/react-scripts/node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -20392,6 +20449,18 @@ "node": ">=0.8.0" } }, + "node_modules/stripe": { + "version": "12.17.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.17.0.tgz", + "integrity": "sha512-f8GhS2LQlGCDZ1Akyu57txgSWyLUEYf0yZYS7x2aTKJXVua5lLmmgfJFFYHfTgEdS5P6rurf0Ghcf/HVLggv5g==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -21248,16 +21317,16 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/typescript-compare": { @@ -26057,6 +26126,17 @@ "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz", "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==" }, + "@reduxjs/toolkit": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", + "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==", + "requires": { + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" + } + }, "@remix-run/router": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", @@ -26159,6 +26239,19 @@ "@sinonjs/commons": "^3.0.0" } }, + "@stripe/react-stripe-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.1.2.tgz", + "integrity": "sha512-2JcKRvOdMD698uGrY0cINLHb6XHnY24L+wlWlw2+TbI2aXlQZ9t8Mc+KssFES+EuNgISbrp7fJR3w/13epfiOw==", + "requires": { + "prop-types": "^15.7.2" + } + }, + "@stripe/stripe-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.0.0.tgz", + "integrity": "sha512-SxZnf192En0uAfgbigUIj7oJYaXgGc5AI1aos59YXvO8DPeLI0AtT4oMg/Wuk17wbpquEv73+akyCe7xdEjGlA==" + }, "@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -29177,9 +29270,9 @@ } }, "dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" }, "dotenv-expand": { "version": "5.1.0", @@ -36331,6 +36424,11 @@ "source-map": "^0.7.3" } }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" + }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -37483,6 +37581,15 @@ } } }, + "stripe": { + "version": "12.17.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.17.0.tgz", + "integrity": "sha512-f8GhS2LQlGCDZ1Akyu57txgSWyLUEYf0yZYS7x2aTKJXVua5lLmmgfJFFYHfTgEdS5P6rurf0Ghcf/HVLggv5g==", + "requires": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + } + }, "strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -38130,9 +38237,9 @@ } }, "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true }, "typescript-compare": { diff --git a/package.json b/package.json index 55bb91c..b474946 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "market-place-pet-project", "version": "0.1.0", - "homepage": "https://Chamindu36.github.io/market-place-pet-project", "private": true, "dependencies": { + "@reduxjs/toolkit": "^1.9.5", + "@stripe/react-stripe-js": "^2.1.2", + "@stripe/stripe-js": "^2.0.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "dotenv": "^16.3.1", "firebase": "^10.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -21,6 +24,7 @@ "redux-thunk": "^2.4.2", "reselect": "^4.1.8", "sass": "^1.63.6", + "stripe": "^12.17.0", "styled-components": "^6.0.4", "web-vitals": "^2.1.4" }, diff --git a/src/components/button/button.component.jsx b/src/components/button/button.component.jsx index 3f615d9..3e95fea 100644 --- a/src/components/button/button.component.jsx +++ b/src/components/button/button.component.jsx @@ -1,4 +1,4 @@ -import { BaseButton, GoogleSignInButton, InvertedButton } from './button.styles.jsx'; +import { BaseButton, GoogleSignInButton, InvertedButton, ButtonSpinner } from './button.styles.jsx'; export const BUTTON_TYPE_CLASSES = { base: "base", @@ -17,13 +17,11 @@ const getButton = (buttonType = BUTTON_TYPE_CLASSES.base) => { }; -const Button = ({ children, buttonType, ...otherProps }) => { +const Button = ({ children, buttonType, isLoading, ...otherProps }) => { const CustomButton = getButton(buttonType); return ( - - {children} + + {isLoading ? : children} ); }; diff --git a/src/components/button/button.styles.jsx b/src/components/button/button.styles.jsx index 2100932..2d5f712 100644 --- a/src/components/button/button.styles.jsx +++ b/src/components/button/button.styles.jsx @@ -17,6 +17,7 @@ export const BaseButton = styled.button` cursor: pointer; display: flex; justify-content: center; + align-items: center &:hover { background-color: white; @@ -46,3 +47,25 @@ export const InvertedButton = styled(BaseButton)` border: none; } `; + +export const ButtonSpinner = styled.div` + align-items: center + display: inline-block; + width: 30px; + height: 30px; + border: 3px solid rgba(195, 195, 195, 0.6); + border-radius: 50%; + border-top-color: #636767; + animation: spin 1s ease-in-out infinite; + -webkit-animation: spin 1s ease-in-out infinite; + @keyframes spin { + to { + -webkit-transform: rotate(360deg); + } + } + @-webkit-keyframes spin { + to { + -webkit-transform: rotate(360deg); + } + } +`; \ No newline at end of file diff --git a/src/components/payment-form/payment-form.component.jsx b/src/components/payment-form/payment-form.component.jsx new file mode 100644 index 0000000..365f6dd --- /dev/null +++ b/src/components/payment-form/payment-form.component.jsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { useSelector } from 'react-redux'; + +import { selectCartTotal } from '../../store/cart/cart.selector'; +import { selectCurrentUser } from '../../store/user/user.selector'; + +import { FormContainer } from './payment-form.styles'; +import { BUTTON_TYPE_CLASSES } from '../button/button.component'; + +import { PaymentButton, PaymentFormContainer } from './payment-form.styles'; + +const PaymentForm = () => { + const stripe = useStripe(); + const elements = useElements(); + const amount = useSelector(selectCartTotal); + const currentUser = useSelector(selectCurrentUser); + const [isProcessingPayment, setIsProcessingPayment] = useState(false); + + const paymentHandler = async (e) => { + e.preventDefault(); + if (!stripe || !elements) { + return; + } + setIsProcessingPayment(true); + + // Fetch payment intent from Stripe using netlify functions + const response = await fetch('/.netlify/functions/create-payment-intent', { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ amount: amount * 100 }), + }).then((res) => { + return res.json(); + }).catch((error) => { + console.log(error); + }); + + const clientSecret = response.paymentIntent.client_secret; + + const paymentResult = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: elements.getElement(CardElement), + billing_details: { + name: currentUser ? currentUser.displayName : 'Guest', + }, + }, + }); + + setIsProcessingPayment(false); + + if (paymentResult.error) { + alert(paymentResult.error.message); + } else { + if (paymentResult.paymentIntent.status === 'succeeded') { + alert('Payment Successful!'); + } + } + }; + + return ( + + +

Credit Card Payment:

+ + + Pay Now + +
+
+ ); +}; +export default PaymentForm; \ No newline at end of file diff --git a/src/components/payment-form/payment-form.styles.jsx b/src/components/payment-form/payment-form.styles.jsx new file mode 100644 index 0000000..93b383d --- /dev/null +++ b/src/components/payment-form/payment-form.styles.jsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; + +import Button from '../button/button.component'; + +export const PaymentFormContainer = styled.div` + height: 300px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +export const FormContainer = styled.form` + height: 100px; + min-width: 500px; +`; + +export const PaymentButton = styled(Button)` + margin-left: auto; + margin-top: 30px; +`; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 09d5df8..81e316b 100644 --- a/src/index.js +++ b/src/index.js @@ -4,20 +4,25 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; +import { Elements } from '@stripe/react-stripe-js'; import { store, persistor } from './store/store'; +import { stripePromise } from './utils/stripe/stripe.utils'; import './index.scss'; import App from './App'; import reportWebVitals from './reportWebVitals'; + const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - + + + diff --git a/src/routes/checkout/checkout.component.jsx b/src/routes/checkout/checkout.component.jsx index e44ec3d..d9e89b3 100644 --- a/src/routes/checkout/checkout.component.jsx +++ b/src/routes/checkout/checkout.component.jsx @@ -1,6 +1,8 @@ import { useSelector } from "react-redux"; import CheckoutItem from "../../components/checkout-item/checkout-item.component"; +import PaymentForm from "../../components/payment-form/payment-form.component"; + import { CheckoutContainer, CheckoutHeader, HeaderBlock, Total } from "./checkout.styles"; import { selectCartItems, selectCartTotal } from "../../store/cart/cart.selector"; @@ -32,6 +34,7 @@ const Checkout = () => { ))} TOTAL: ${cartTotal} + ); }; diff --git a/src/utils/stripe/stripe.utils.js b/src/utils/stripe/stripe.utils.js new file mode 100644 index 0000000..a3355a8 --- /dev/null +++ b/src/utils/stripe/stripe.utils.js @@ -0,0 +1,6 @@ +import { loadStripe } from "@stripe/stripe-js"; + +export const stripePromise = loadStripe( + process.env.REACT_APP_STRTIPE_PUBLISHABLE_KEY +); +