├── src ├── styles │ ├── layout.module.css │ ├── footer.module.css │ ├── blur.module.css │ ├── shipping-method.module.css │ ├── hero.module.css │ ├── tile-section.module.css │ ├── step-overview.module.css │ ├── payment.module.css │ ├── injectable-payment-card.module.css │ ├── nav-bar.module.css │ ├── globals.css │ ├── information-step.module.css │ ├── input-field.module.css │ ├── shipping-step.module.css │ ├── checkout-step.module.css │ ├── checkout-summary.module.css │ ├── product.module.css │ ├── cart-view.module.css │ └── home.module.css ├── images │ └── icon.png ├── pages │ ├── checkout.js │ ├── product │ │ └── {ContentfulProduct.handle}.js │ ├── 404.js │ ├── index.js │ ├── {ContentfulPage.slug}.js │ └── payment.js ├── utils │ ├── client.js │ ├── zero-decimal-currencies.js │ ├── stripe.js │ └── helper-functions.js ├── components │ ├── tile-section │ │ ├── tile-section.jsx │ │ └── tile.jsx │ ├── checkout │ │ ├── shipping-method.jsx │ │ ├── input-field.jsx │ │ ├── select-field.jsx │ │ ├── payment-step.jsx │ │ ├── step-overview.jsx │ │ ├── injectable-payment-card.jsx │ │ ├── shipping-step.jsx │ │ ├── checkout-step.jsx │ │ ├── checkout-summary.jsx │ │ └── information-step.jsx │ ├── layout │ │ ├── blur.jsx │ │ ├── layout.jsx │ │ └── nav-bar.jsx │ ├── link.jsx │ ├── footer │ │ └── footer.jsx │ ├── hero │ │ └── hero.jsx │ ├── seo.jsx │ └── cart-view │ │ └── cart-view.jsx ├── context │ ├── display-context.js │ └── store-context.js └── views │ └── product.js ├── static ├── favicon.ico └── images │ └── banner.png ├── .gitignore ├── .prettierrc ├── .env.template ├── gatsby-ssr.js ├── gatsby-browser.js ├── gatsby-config.js ├── package.json └── README.md /src/styles/layout.module.css: -------------------------------------------------------------------------------- 1 | .noscroll { 2 | overflow: hidden; 3 | height: 100vh; 4 | } 5 | -------------------------------------------------------------------------------- /src/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-contentful-storefront/HEAD/src/images/icon.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-contentful-storefront/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | /node_modules 4 | /public 5 | .env.development 6 | .env 7 | .env.production 8 | 9 | -------------------------------------------------------------------------------- /static/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-contentful-storefront/HEAD/static/images/banner.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | #See https://stripe.com/docs/stripe-js for reference 2 | GATSBY_STRIPE_KEY=pk_test_something 3 | CONTENTFUL_ACCESS_TOKEN= 4 | CONTENTFUL_SPACE_ID= 5 | -------------------------------------------------------------------------------- /src/pages/checkout.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import CheckoutStep from "../components/checkout/checkout-step" 3 | 4 | const Checkout = () => { 5 | return 6 | } 7 | 8 | export default Checkout 9 | -------------------------------------------------------------------------------- /src/utils/client.js: -------------------------------------------------------------------------------- 1 | import Medusa from "@medusajs/medusa-js" 2 | 3 | const BACKEND_URL = process.env.GATSBY_STORE_URL || "http://localhost:9000" 4 | 5 | export const createClient = () => new Medusa({ baseUrl: BACKEND_URL }) 6 | -------------------------------------------------------------------------------- /src/utils/zero-decimal-currencies.js: -------------------------------------------------------------------------------- 1 | const zeroDecimalCurrencies = [ 2 | "bif", 3 | "clp", 4 | "djf", 5 | "gnf", 6 | "jpy", 7 | "kmf", 8 | "krw", 9 | "mga", 10 | "pyg", 11 | "rwf", 12 | "ugx", 13 | "vnd", 14 | "vuv", 15 | "xaf", 16 | "xof", 17 | "xpf", 18 | ] 19 | 20 | export default zeroDecimalCurrencies 21 | -------------------------------------------------------------------------------- /src/styles/footer.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: flex-start; 5 | padding: 64px 0px 64px 59px; 6 | 7 | width: 100%; 8 | 9 | /* Brand / Deep Blue */ 10 | background: var(--brand-deep-blue); 11 | } 12 | 13 | .nav-item { 14 | color: white; 15 | font-size: 16px; 16 | flex: none; 17 | flex-grow: 0; 18 | margin-right: 100px; 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/blur.module.css: -------------------------------------------------------------------------------- 1 | .blur { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | right: 0; 6 | width: 100vw; 7 | height: 100vh; 8 | background: rgba(0, 0, 0, 0.5); 9 | opacity: 0; 10 | visibility: hidden; 11 | cursor: pointer; 12 | transition: all 0.2s ease-in-out; 13 | z-index: var(--z-index-mid); 14 | } 15 | 16 | .active { 17 | opacity: 1; 18 | visibility: visible; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/stripe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a singleton to ensure we only instantiate Stripe once. 3 | */ 4 | import { loadStripe } from "@stripe/stripe-js" 5 | const STRIPE_API_KEY = process.env.GATSBY_STRIPE_KEY || null 6 | let stripePromise 7 | const getStripe = () => { 8 | if (!stripePromise) { 9 | stripePromise = loadStripe(STRIPE_API_KEY) 10 | } 11 | return stripePromise 12 | } 13 | export default getStripe 14 | -------------------------------------------------------------------------------- /src/styles/shipping-method.module.css: -------------------------------------------------------------------------------- 1 | .shippingOption { 2 | border: none; 3 | display: flex; 4 | align-items: center; 5 | box-shadow: var(--shade); 6 | justify-content: space-between; 7 | background: white; 8 | border-radius: 8px; 9 | padding: 1rem 2rem; 10 | width: 100%; 11 | } 12 | 13 | .shippingOption div { 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | .chosen { 19 | border: 1px solid var(--logo-color-400); 20 | } 21 | -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { DisplayProvider } from "./src/context/display-context" 3 | import Layout from "./src/components/layout/layout" 4 | 5 | export const wrapRootElement = ({ element }) => { 6 | return {element} 7 | } 8 | 9 | export const wrapPageElement = ({ element, props }) => { 10 | const location = props.location 11 | 12 | return {element} 13 | } 14 | -------------------------------------------------------------------------------- /src/components/tile-section/tile-section.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import * as styles from "../../styles/tile-section.module.css" 4 | import Tile from "./tile" 5 | 6 | const TileSection = ({ data }) => { 7 | return ( 8 |
9 |

{data.title}

10 |
11 | {data.tiles?.map((tile) => ( 12 | 13 | ))} 14 |
15 |
16 | ) 17 | } 18 | 19 | export default TileSection 20 | -------------------------------------------------------------------------------- /src/styles/hero.module.css: -------------------------------------------------------------------------------- 1 | .hero { 2 | color: white; 3 | position: relative; 4 | text-align: left; 5 | width: 100%; 6 | height: 647px; 7 | } 8 | 9 | .button { 10 | outline: 0; 11 | border: none; 12 | 13 | /* Auto Layout */ 14 | padding: 8px 24px; 15 | 16 | background: var(--brand-green); 17 | border-radius: 30px; 18 | 19 | font-style: normal; 20 | font-weight: 600; 21 | font-size: 18px; 22 | line-height: 21px; 23 | /* identical to box height */ 24 | 25 | text-align: center; 26 | 27 | color: var(--brand-deep-blue); 28 | } 29 | -------------------------------------------------------------------------------- /src/styles/tile-section.module.css: -------------------------------------------------------------------------------- 1 | .tile-section { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100%; 5 | padding: 64px 59px; 6 | } 7 | 8 | .tile-wrapper { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: flex-start; 12 | } 13 | 14 | .tile { 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | .tile:not(:last-of-type) { 20 | margin-right: 16px; 21 | } 22 | 23 | .tile-body .tile-title { 24 | margin-bottom: 8px; 25 | } 26 | 27 | .tile-body .tile-cta { 28 | color: var(--brand-green); 29 | } 30 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { DisplayProvider } from "./src/context/display-context" 3 | import { StoreProvider } from "./src/context/store-context" 4 | import Layout from "./src/components/layout/layout" 5 | 6 | export const wrapRootElement = ({ element }) => { 7 | return ( 8 | 9 | {element} 10 | 11 | ) 12 | } 13 | 14 | export const wrapPageElement = ({ element, props }) => { 15 | const location = props.location 16 | 17 | return {element} 18 | } 19 | -------------------------------------------------------------------------------- /src/components/checkout/shipping-method.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import * as styles from "../../styles/shipping-method.module.css" 3 | import { formatPrice } from "../../utils/helper-functions" 4 | 5 | const ShippingMethod = ({ handleOption, option, chosen }) => { 6 | return ( 7 | 16 | ) 17 | } 18 | 19 | export default ShippingMethod 20 | -------------------------------------------------------------------------------- /src/components/layout/blur.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import DisplayContext from "../../context/display-context" 3 | import * as styles from "../../styles/blur.module.css" 4 | 5 | const Blur = () => { 6 | const { cartView, updateCartViewDisplay } = useContext(DisplayContext) 7 | return ( 8 |
updateCartViewDisplay()} 11 | onKeyDown={() => updateCartViewDisplay()} 12 | role="button" 13 | tabIndex="-1" 14 | aria-label="Close cart view" 15 | /> 16 | ) 17 | } 18 | 19 | export default Blur 20 | -------------------------------------------------------------------------------- /src/components/link.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link as GLink } from "gatsby" 3 | 4 | const Link = ({ link, to, children, ...rest }) => { 5 | link = link || {} 6 | let linkTo = to || link.linkTo 7 | 8 | if (link.reference) { 9 | linkTo = link.reference.slug || link.reference.handle 10 | } 11 | 12 | const internal = /^\/(?!\/)/.test(linkTo) 13 | 14 | if (internal) { 15 | return ( 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ) 27 | } 28 | 29 | export default Link 30 | -------------------------------------------------------------------------------- /src/styles/step-overview.module.css: -------------------------------------------------------------------------------- 1 | .step { 2 | display: flex; 3 | background: white; 4 | border-radius: 8px; 5 | box-shadow: var(--shade); 6 | padding: 2rem 2rem; 7 | align-items: center; 8 | font-size: var(--fz-s); 9 | margin-bottom: 0.5em; 10 | color: grey; 11 | } 12 | 13 | .stepInfo { 14 | margin-right: 0.75em; 15 | min-width: 0px; 16 | flex: 1 1 0%; 17 | white-space: nowrap; 18 | overflow: hidden; 19 | text-overflow: ellipsis; 20 | } 21 | 22 | .detail { 23 | width: 80px; 24 | font-weight: 600; 25 | } 26 | 27 | .edit { 28 | background: transparent; 29 | border: none; 30 | transition: background 0.2s ease-in; 31 | padding: 0.5rem 1rem; 32 | border-radius: 4px; 33 | } 34 | 35 | .edit:hover { 36 | background: var(--logo-color-100); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/footer/footer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useStaticQuery, graphql } from "gatsby" 3 | 4 | import * as styles from "../../styles/footer.module.css" 5 | 6 | const Footer = () => { 7 | const data = useStaticQuery(graphql` 8 | query FooterQuery { 9 | nav: contentfulNavigationMenu(title: { eq: "Footer" }) { 10 | items { 11 | id 12 | title 13 | link { 14 | linkTo 15 | } 16 | } 17 | } 18 | } 19 | `) 20 | 21 | return ( 22 |
23 | {data.nav.items.map((ni) => { 24 | return ( 25 | 26 | {ni.title} 27 | 28 | ) 29 | })} 30 |
31 | ) 32 | } 33 | 34 | export default Footer 35 | -------------------------------------------------------------------------------- /src/components/tile-section/tile.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { GatsbyImage, getImage } from "gatsby-plugin-image" 3 | 4 | import Link from "../link" 5 | import * as styles from "../../styles/tile-section.module.css" 6 | 7 | const Tile = ({ data }) => { 8 | let title = data.title 9 | let cta = data.cta 10 | let img = data.image 11 | 12 | if (data.internal.type === "ContentfulProduct") { 13 | title = data.title 14 | img = data.thumbnail 15 | } 16 | 17 | return ( 18 | 23 | 24 |
25 |

{title}

26 | {cta} 27 |
28 | 29 | ) 30 | } 31 | 32 | export default Tile 33 | -------------------------------------------------------------------------------- /src/styles/payment.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | margin: 0 auto; 6 | padding: 0 88px; 7 | max-width: 560px; 8 | min-height: 100vh; 9 | margin-bottom: 2rem; 10 | } 11 | 12 | .header { 13 | border-bottom: 1px solid var(--logo-color-100); 14 | margin-top: 4rem; 15 | } 16 | 17 | .items { 18 | padding: 22px 0; 19 | border-bottom: 1px solid var(--logo-color-100); 20 | margin-bottom: 0.5rem; 21 | } 22 | 23 | .item { 24 | margin-bottom: 0.5rem; 25 | } 26 | 27 | .price { 28 | display: flex; 29 | justify-content: space-between; 30 | padding: 0.5rem 0; 31 | } 32 | 33 | .total { 34 | font-weight: 700; 35 | border-bottom: 1px solid var(--logo-color-100); 36 | } 37 | 38 | @media (max-width: 876px) { 39 | .container { 40 | padding: 0 22px; 41 | width: 100%; 42 | max-width: 100%; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/helper-functions.js: -------------------------------------------------------------------------------- 1 | import zeroDecimalCurrencies from "./zero-decimal-currencies" 2 | 3 | export const quantity = (item) => { 4 | return item.quantity 5 | } 6 | 7 | export const sum = (prev, next) => { 8 | return prev + next 9 | } 10 | 11 | export const formatPrice = (price, currency) => { 12 | if (zeroDecimalCurrencies.includes(currency.toLowerCase())) { 13 | return `${price} ${currency.toUpperCase()}` 14 | } 15 | 16 | return `${(price / 100).toFixed(2)} ${currency.toUpperCase()}` 17 | } 18 | 19 | export const getSlug = (path) => { 20 | const tmp = path.split("/") 21 | return tmp[tmp.length - 1] 22 | } 23 | 24 | export const resetOptions = (product) => { 25 | const variantId = product.variants.slice(0).reverse()[0].medusaId 26 | const size = product.variants.slice(0).reverse()[0].title 27 | return { 28 | variantId: variantId, 29 | quantity: 1, 30 | size: size, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/product/{ContentfulProduct.handle}.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { graphql } from "gatsby" 3 | 4 | import Product from "../../views/product" 5 | 6 | const ProductPage = ({ data }) => { 7 | return 8 | } 9 | 10 | export default ProductPage 11 | 12 | export const query = graphql` 13 | query ($id: String!) { 14 | product: contentfulProduct(id: { eq: $id }) { 15 | medusaId 16 | title 17 | options { 18 | id 19 | title 20 | } 21 | thumbnail { 22 | gatsbyImageData 23 | } 24 | description { 25 | id 26 | description 27 | } 28 | variants { 29 | title 30 | medusaId 31 | prices { 32 | currency_code 33 | amount 34 | } 35 | options { 36 | option_id 37 | value 38 | } 39 | } 40 | } 41 | } 42 | ` 43 | -------------------------------------------------------------------------------- /src/components/layout/layout.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import NavBar from "./nav-bar" 3 | import Blur from "./blur" 4 | import Footer from "../footer/footer" 5 | import CartView from "../cart-view/cart-view" 6 | import DisplayContext from "../../context/display-context" 7 | import * as styles from "../../styles/layout.module.css" 8 | 9 | import "../../styles/globals.css" 10 | 11 | const Layout = ({ location, children }) => { 12 | const { cartView } = useContext(DisplayContext) 13 | const isCheckout = 14 | location.pathname === "/checkout" || location.pathname === "/payment" 15 | 16 | return ( 17 |
21 | 22 | 23 | 24 |
{children}
25 | {!isCheckout &&
} 26 |
27 | ) 28 | } 29 | 30 | export default Layout 31 | -------------------------------------------------------------------------------- /src/components/checkout/input-field.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Field } from "formik" 3 | import * as styles from "../../styles/input-field.module.css" 4 | import { MdError } from "react-icons/md" 5 | 6 | const InputField = ({ id, placeholder, error, errorMsg, type, disabled }) => { 7 | return ( 8 |
9 | {error ? ( 10 |

{errorMsg}

11 | ) : ( 12 | 15 | )} 16 |
19 | 27 | {error && } 28 |
29 |
30 | ) 31 | } 32 | 33 | export default InputField 34 | -------------------------------------------------------------------------------- /src/styles/injectable-payment-card.module.css: -------------------------------------------------------------------------------- 1 | .cardForm { 2 | background: white; 3 | box-shadow: var(--shade); 4 | padding: 2rem 2rem; 5 | border-radius: 8px; 6 | } 7 | 8 | .stepBack { 9 | background: transparent; 10 | border: none; 11 | display: flex; 12 | align-items: center; 13 | padding: 0; 14 | font-size: var(--fz-m); 15 | } 16 | 17 | .stepBack svg { 18 | margin-right: 0.5rem; 19 | } 20 | 21 | .controls { 22 | display: flex; 23 | align-items: center; 24 | justify-content: space-between; 25 | margin-top: 2rem; 26 | } 27 | 28 | .payBtn { 29 | font-size: 1.125rem; 30 | min-height: 3rem; 31 | min-width: 3rem; 32 | padding: 0.5rem 1.25rem; 33 | align-self: center; 34 | display: inline-flex; 35 | align-items: center; 36 | background: var(--brand-deep-blue); 37 | color: white; 38 | border-radius: 8px; 39 | transition: background 0.2s ease-in; 40 | font-weight: 500; 41 | border: none; 42 | } 43 | 44 | .payBtn:hover { 45 | background: var(--brand-green); 46 | color: var(--brand-deep-blue); 47 | } 48 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config() 2 | 3 | const CONTENTFUL_SPACE_ID = process.env.CONTENTFUL_SPACE_ID || "" 4 | const CONTENTFUL_ACCESS_TOKEN = process.env.CONTENTFUL_ACCESS_TOKEN || "" 5 | 6 | module.exports = { 7 | siteMetadata: { 8 | title: "Store", 9 | titleTemplate: "%s | Medusa Contentful Store", 10 | description: 11 | "Getting you up an running with a powerful headless ecommerce site in no time", 12 | url: "https://www.medusa-commerce.com", 13 | image: "/images/banner.jpg", 14 | twitterUsername: "@medusajs", 15 | }, 16 | plugins: [ 17 | "gatsby-plugin-image", 18 | "gatsby-plugin-react-helmet", 19 | "gatsby-plugin-sharp", 20 | "gatsby-transformer-sharp", 21 | { 22 | resolve: "gatsby-source-filesystem", 23 | options: { 24 | name: "images", 25 | path: "./src/images/", 26 | }, 27 | __key: "images", 28 | }, 29 | { 30 | resolve: `gatsby-source-contentful`, 31 | options: { 32 | spaceId: CONTENTFUL_SPACE_ID, 33 | accessToken: CONTENTFUL_ACCESS_TOKEN, 34 | }, 35 | }, 36 | ], 37 | } 38 | -------------------------------------------------------------------------------- /src/styles/nav-bar.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | padding: 10px 59px; 11 | z-index: var(--z-index-low); 12 | color: white; 13 | background-color: var(--brand-deep-blue); 14 | } 15 | 16 | .container h1 { 17 | margin: 0; 18 | } 19 | 20 | .main-nav { 21 | flex: 1; 22 | display: flex; 23 | flex-direction: row; 24 | align-items: flex-start; 25 | padding: 0px 59px; 26 | } 27 | 28 | .nav-item { 29 | flex: none; 30 | flex-grow: 0; 31 | margin: 0px 20px; 32 | } 33 | 34 | .logo { 35 | font-size: var(--fz-xl); 36 | color: var(--logo-color-900); 37 | } 38 | 39 | .btn { 40 | border: none; 41 | background: transparent; 42 | color: white; 43 | display: flex; 44 | align-items: center; 45 | font-size: var(--fz-sm); 46 | cursor: pointer; 47 | } 48 | 49 | .btn span { 50 | margin-left: 0.75rem; 51 | } 52 | 53 | .btn svg { 54 | stroke: white; 55 | font-size: var(--fz-ml); 56 | } 57 | 58 | @media (max-width: 876px) { 59 | .container { 60 | padding: 20px 22px; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/hero/hero.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { GatsbyImage, getImage } from "gatsby-plugin-image" 3 | 4 | import Link from "../link" 5 | import * as styles from "../../styles/hero.module.css" 6 | 7 | const Hero = ({ data }) => { 8 | return ( 9 |
10 |
11 | 18 |
19 |
29 |
30 |

{data.title}

31 | 32 | {data.cta} 33 | 34 |
35 |
36 |
37 | ) 38 | } 39 | export default Hero 40 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --brand-green: #56fbb1; 3 | --brand-deep-blue: #0a3149; 4 | --brand-cool-grey: #eef0f5; 5 | 6 | --ui-100: #89959c; 7 | --ui-75: #d9dfe8; 8 | --ui-50: #eef0f5; 9 | --ui-25: #f7f7fa; 10 | 11 | --logo-color-1000: #30363d; 12 | --logo-color-900: #454b54; 13 | --logo-color-400: #a3b1c7; 14 | --logo-color-100: #454b5411; 15 | 16 | --bg: #f7f9f9; 17 | 18 | /* Font sizes */ 19 | --fz-s: 12px; 20 | --fz-sm: 14px; 21 | --fz-m: 16px; 22 | --fz-ml: 18px; 23 | --fz-l: 22px; 24 | --fz-xl: 24px; 25 | 26 | /* box-shadow */ 27 | --shade: 0 1px 5px rgba(0, 0, 0, 0.2); 28 | 29 | /* Nav */ 30 | --nav-height: 68px; 31 | 32 | --z-index-high: 100; 33 | --z-index-mid: 75; 34 | --z-index-low: 50; 35 | } 36 | 37 | * { 38 | box-sizing: border-box; 39 | } 40 | 41 | html, 42 | body { 43 | padding: 0; 44 | margin: 0; 45 | font-family: ES Rebond Grotesque, Helvetica Neue, sans-serif; 46 | } 47 | 48 | a { 49 | color: inherit; 50 | text-decoration: none; 51 | } 52 | 53 | button { 54 | cursor: pointer; 55 | } 56 | 57 | button:disabled, 58 | button:disabled:hover { 59 | cursor: not-allowed; 60 | background: lightgrey; 61 | color: black; 62 | } 63 | 64 | * { 65 | box-sizing: border-box; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/checkout/select-field.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Field } from "formik" 3 | import * as styles from "../../styles/input-field.module.css" 4 | import { MdError } from "react-icons/md" 5 | 6 | const SelectField = ({ id, error, errorMsg, type, disabled, options }) => { 7 | return options ? ( 8 |
9 | {error ? ( 10 |

{errorMsg}

11 | ) : ( 12 | 15 | )} 16 |
19 | 27 | {options.map((o) => { 28 | return ( 29 | 32 | ) 33 | })} 34 | 35 | {error && } 36 |
37 |
38 | ) : ( 39 |
40 | ) 41 | } 42 | 43 | export default SelectField 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-starter-medusa", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "gatsby-starter-medusa", 6 | "author": "Kasper Fabricius Kristensen (https://www.medusa-commerce.com)", 7 | "keywords": [ 8 | "gatsby", 9 | "medusajs", 10 | "e-commerce" 11 | ], 12 | "scripts": { 13 | "develop": "gatsby develop", 14 | "start": "gatsby develop", 15 | "build": "gatsby build", 16 | "serve": "gatsby serve", 17 | "clean": "gatsby clean" 18 | }, 19 | "dependencies": { 20 | "@medusajs/medusa-js": "^1.2.1", 21 | "@stripe/react-stripe-js": "^1.4.1", 22 | "@stripe/stripe-js": "^1.15.0", 23 | "axios": "^0.21.1", 24 | "formik": "^2.2.9", 25 | "gatsby": "^3.6.2", 26 | "gatsby-plugin-image": "^1.6.0", 27 | "gatsby-plugin-react-helmet": "^4.6.0", 28 | "gatsby-plugin-sharp": "^3.6.0", 29 | "gatsby-source-contentful": "^5.11.1", 30 | "gatsby-source-filesystem": "^3.6.0", 31 | "gatsby-transformer-sharp": "^3.6.0", 32 | "lodash": "^4.17.21", 33 | "react": "^17.0.1", 34 | "react-dom": "^17.0.1", 35 | "react-helmet": "^6.1.0", 36 | "react-icons": "^4.2.0", 37 | "react-spinners": "^0.11.0", 38 | "yup": "^0.32.9" 39 | }, 40 | "devDependencies": { 41 | "prettier": "^2.4.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Link } from "gatsby" 3 | 4 | // styles 5 | const pageStyles = { 6 | color: "#232129", 7 | padding: "96px", 8 | fontFamily: "-apple-system, Roboto, sans-serif, serif", 9 | } 10 | const headingStyles = { 11 | marginTop: 0, 12 | marginBottom: 64, 13 | maxWidth: 320, 14 | } 15 | 16 | const paragraphStyles = { 17 | marginBottom: 48, 18 | } 19 | const codeStyles = { 20 | color: "#8A6534", 21 | padding: 4, 22 | backgroundColor: "#FFF4DB", 23 | fontSize: "1.25rem", 24 | borderRadius: 4, 25 | } 26 | 27 | // markup 28 | const NotFoundPage = () => { 29 | return ( 30 |
31 | Not found 32 |

Page not found

33 |

34 | Sorry{" "} 35 | 36 | 😔 37 | {" "} 38 | we couldn’t find what you were looking for. 39 |
40 | {process.env.NODE_ENV === "development" ? ( 41 | <> 42 |
43 | Try creating a page in src/pages/. 44 |
45 | 46 | ) : null} 47 |
48 | Go home. 49 |

50 |
51 | ) 52 | } 53 | 54 | export default NotFoundPage 55 | -------------------------------------------------------------------------------- /src/styles/information-step.module.css: -------------------------------------------------------------------------------- 1 | .styledform { 2 | width: 100%; 3 | } 4 | 5 | .sharedrow { 6 | display: flex; 7 | } 8 | 9 | .sharedrow div:first-of-type { 10 | margin-left: 0; 11 | margin-right: 10px; 12 | } 13 | 14 | .sharedrow div { 15 | margin-left: 10px; 16 | margin-right: 10px; 17 | } 18 | 19 | .sharedrow div:last-of-type { 20 | margin-right: 0; 21 | } 22 | 23 | .fieldcontainer { 24 | width: 100%; 25 | background: white; 26 | border-radius: 8px; 27 | border: 1px solid lightgrey; 28 | margin: 5px 0; 29 | display: flex; 30 | align-items: center; 31 | } 32 | 33 | .styledfield { 34 | height: 40px; 35 | width: 100%; 36 | padding: 10px; 37 | border-radius: 8px; 38 | background: transparent; 39 | border: none; 40 | } 41 | 42 | .formbtn { 43 | font-size: 1.125rem; 44 | min-height: 3rem; 45 | min-width: 3rem; 46 | padding: 0.5rem 1.25rem; 47 | align-self: center; 48 | display: inline-flex; 49 | align-items: center; 50 | background: var(--brand-deep-blue); 51 | color: white; 52 | border-radius: 8px; 53 | transition: background 0.2s ease-in; 54 | font-weight: 500; 55 | border: none; 56 | } 57 | 58 | .formbtn:hover { 59 | background: var(--brand-green); 60 | color: var(--brand-deep-blue); 61 | } 62 | 63 | .spinner { 64 | display: flex; 65 | align-items: center; 66 | justify-content: center; 67 | width: 100%; 68 | padding: 4rem; 69 | } 70 | 71 | .btncontainer { 72 | margin-top: 1.5rem; 73 | display: flex; 74 | justify-content: flex-end; 75 | align-items: center; 76 | } 77 | -------------------------------------------------------------------------------- /src/context/display-context.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from "react" 2 | 3 | export const defaultDisplayContext = { 4 | cartView: false, 5 | orderSummary: false, 6 | checkoutStep: 1, 7 | updateCartViewDisplay: () => {}, 8 | updateOrderSummaryDisplay: () => {}, 9 | updateCheckoutStep: () => {}, 10 | dispatch: () => {}, 11 | } 12 | 13 | const DisplayContext = React.createContext(defaultDisplayContext) 14 | export default DisplayContext 15 | 16 | const reducer = (state, action) => { 17 | switch (action.type) { 18 | case "updateCartViewDisplay": 19 | return { ...state, cartView: !state.cartView } 20 | case "updateOrderSummaryDisplay": 21 | return { ...state, orderSummary: !state.orderSummary } 22 | case "updateCheckoutStep": 23 | return { ...state, checkoutStep: action.payload } 24 | default: 25 | return state 26 | } 27 | } 28 | 29 | export const DisplayProvider = ({ children }) => { 30 | const [state, dispatch] = useReducer(reducer, defaultDisplayContext) 31 | 32 | const updateCartViewDisplay = () => { 33 | dispatch({ type: "updateCartViewDisplay" }) 34 | } 35 | 36 | const updateOrderSummaryDisplay = () => { 37 | dispatch({ type: "updateOrderSummaryDisplay" }) 38 | } 39 | 40 | const updateCheckoutStep = (step) => { 41 | dispatch({ type: "updateCheckoutStep", payload: step }) 42 | } 43 | 44 | return ( 45 | 54 | {children} 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/styles/input-field.module.css: -------------------------------------------------------------------------------- 1 | .colors { 2 | --error-900: #db5461; 3 | --error-400: #d67b84; 4 | } 5 | 6 | .container { 7 | width: 100%; 8 | display: flex; 9 | align-items: baseline; 10 | flex-direction: column; 11 | margin: 5px 0; 12 | } 13 | 14 | .fieldcontainer { 15 | width: 100%; 16 | background: white; 17 | border-radius: 8px; 18 | border: 1px solid lightgrey; 19 | display: flex; 20 | align-items: center; 21 | margin-top: 0.1rem; 22 | position: relative; 23 | transition: all 0.1s ease-in; 24 | } 25 | 26 | .errorfield { 27 | --error-900: #f0ada6; 28 | --error-400: #fef1f2; 29 | border-color: var(--error-900); 30 | background: var(--error-400); 31 | } 32 | 33 | .errortext { 34 | margin: 0; 35 | align-self: flex-end; 36 | font-size: var(--fz-s); 37 | color: #e07367; 38 | } 39 | 40 | .erroricon { 41 | color: #eb948b; 42 | font-size: 18px; 43 | margin-right: 10px; 44 | position: absolute; 45 | right: 0; 46 | } 47 | 48 | .fill { 49 | color: transparent; 50 | margin: 0; 51 | font-size: var(--fz-s); 52 | } 53 | 54 | .styledfield { 55 | height: 40px; 56 | width: 100%; 57 | border-radius: 8px; 58 | background: transparent; 59 | border: none; 60 | padding: 10px; 61 | outline: none; 62 | } 63 | 64 | .styledselect { 65 | height: 40px; 66 | width: 100%; 67 | border-radius: 8px; 68 | background: transparent; 69 | border: none; 70 | padding: 10px; 71 | outline: none; 72 | -webkit-appearance: none; 73 | } 74 | 75 | .fetching { 76 | height: 40px; 77 | width: 100%; 78 | border-radius: 8px; 79 | background: transparent; 80 | border: none; 81 | padding: 10px; 82 | outline: none; 83 | background: var(--logo-color-100); 84 | } 85 | -------------------------------------------------------------------------------- /src/styles/shipping-step.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 100%; 6 | padding: 4rem; 7 | height: 530px; 8 | } 9 | 10 | .container { 11 | margin-top: 20px; 12 | flex-grow: 1; 13 | } 14 | 15 | .shippingOption { 16 | display: flex; 17 | align-items: center; 18 | box-shadow: var(--shade); 19 | justify-content: space-between; 20 | background: white; 21 | border-radius: 8px; 22 | padding: 1rem; 23 | } 24 | 25 | .shippingOption div { 26 | display: flex; 27 | align-items: center; 28 | } 29 | 30 | .stepBack { 31 | background: transparent; 32 | border: none; 33 | display: flex; 34 | align-items: center; 35 | padding: 0; 36 | font-size: var(--fz-m); 37 | } 38 | 39 | .stepBack svg { 40 | margin-right: 0.5rem; 41 | } 42 | 43 | .controls { 44 | display: flex; 45 | align-items: center; 46 | justify-content: space-between; 47 | } 48 | 49 | .nextBtn { 50 | font-size: 1.125rem; 51 | min-height: 3rem; 52 | min-width: 3rem; 53 | padding: 0.5rem 1.25rem; 54 | align-self: center; 55 | display: inline-flex; 56 | align-items: center; 57 | background: var(--brand-deep-blue); 58 | color: white; 59 | border-radius: 8px; 60 | transition: background 0.2s ease-in; 61 | font-weight: 500; 62 | border: none; 63 | } 64 | 65 | .nextBtn:hover { 66 | background: var(--brand-green); 67 | color: var(--brand-deep-blue); 68 | } 69 | 70 | .error { 71 | display: flex; 72 | opacity: 0; 73 | visibility: hidden; 74 | align-items: center; 75 | transition: all 0.1s ease-in; 76 | color: #db5461; 77 | } 78 | 79 | .error p { 80 | margin-left: 0.5rem; 81 | } 82 | 83 | .error.active { 84 | opacity: 1; 85 | visibility: visible; 86 | } 87 | -------------------------------------------------------------------------------- /src/styles/checkout-step.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | height: 100vh; 4 | } 5 | 6 | .steps { 7 | width: 60%; 8 | height: 100vh; 9 | padding: 110px 88px 28px; 10 | display: flex; 11 | flex-direction: column; 12 | overflow-y: scroll; 13 | scrollbar-width: thin; 14 | scrollbar-color: var(--logo-color-400) transparent; 15 | } 16 | 17 | .summary { 18 | width: 40%; 19 | display: flex; 20 | flex-direction: column; 21 | z-index: var(--z-index-high); 22 | } 23 | 24 | .back-btn { 25 | margin-bottom: 24px; 26 | background: none; 27 | border: none; 28 | } 29 | 30 | .orderBtn { 31 | width: 100%; 32 | font-size: 1rem; 33 | min-height: 3rem; 34 | padding: 0.5rem 0; 35 | align-self: center; 36 | display: inline-flex; 37 | align-items: center; 38 | justify-content: center; 39 | border-radius: 8px; 40 | transition: background 0.2s ease-in; 41 | font-weight: 500; 42 | cursor: pointer; 43 | border: none; 44 | display: none; 45 | justify-self: flex-end; 46 | background: transparent; 47 | } 48 | 49 | .orderBtn:hover { 50 | background: var(--logo-color-100); 51 | } 52 | 53 | .breadcrumbs { 54 | display: flex; 55 | } 56 | 57 | .breadcrumbs p { 58 | margin-right: 0.5rem; 59 | color: grey; 60 | transition: color 0.2s ease-in; 61 | } 62 | 63 | .breadcrumbs p:last-child { 64 | margin-right: 0; 65 | } 66 | 67 | .breadcrumbs p.activeStep { 68 | color: black; 69 | } 70 | 71 | @media (max-width: 876px) { 72 | .container { 73 | flex-direction: column; 74 | } 75 | 76 | .steps { 77 | padding: 0px 22px; 78 | width: 100%; 79 | height: 100%; 80 | } 81 | 82 | .breadcrumbs { 83 | margin-top: 6rem; 84 | } 85 | 86 | .orderBtn { 87 | margin-bottom: 2rem; 88 | display: block; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/checkout/payment-step.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from "react" 2 | import { navigate } from "gatsby" 3 | import { Elements } from "@stripe/react-stripe-js" 4 | import StoreContext from "../../context/store-context" 5 | import InjectablePaymentCard from "./injectable-payment-card" 6 | import * as styles from "../../styles/injectable-payment-card.module.css" 7 | import getStripe from "../../utils/stripe" 8 | 9 | const PaymentStep = () => { 10 | const { cart, createPaymentSession, setPaymentSession } = 11 | useContext(StoreContext) 12 | 13 | useEffect(() => { 14 | createPaymentSession() 15 | }, []) 16 | 17 | const handlePayment = async () => { 18 | await setPaymentSession("manual").then(() => { 19 | navigate(`/payment`) 20 | }) 21 | } 22 | 23 | return ( 24 |
25 | {cart && 26 | cart.payment_sessions && 27 | cart.payment_sessions.map((ps) => { 28 | switch (ps.provider_id) { 29 | case "stripe": 30 | return ( 31 | 32 |

Stripe Payment

33 | setPaymentSession("stripe")} 36 | /> 37 |
38 | ) 39 | case "manual": 40 | return ( 41 |
42 |

Test Payment

43 | 50 |
51 | ) 52 | default: 53 | return null 54 | } 55 | })} 56 |
57 | ) 58 | } 59 | 60 | export default PaymentStep 61 | -------------------------------------------------------------------------------- /src/components/layout/nav-bar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import { useStaticQuery, graphql } from "gatsby" 3 | 4 | import Link from "../link" 5 | import DisplayContext from "../../context/display-context" 6 | import StoreContext from "../../context/store-context" 7 | import { quantity, sum } from "../../utils/helper-functions" 8 | import { BiShoppingBag } from "react-icons/bi" 9 | import * as styles from "../../styles/nav-bar.module.css" 10 | 11 | const NavBar = ({ isCheckout }) => { 12 | const { updateCartViewDisplay } = useContext(DisplayContext) 13 | const { cart } = useContext(StoreContext) 14 | 15 | const data = useStaticQuery(graphql` 16 | query HeaderQuery { 17 | logo: contentfulAsset(title: { eq: "Logo" }) { 18 | id 19 | file { 20 | url 21 | } 22 | } 23 | nav: contentfulNavigationMenu(title: { eq: "Main" }) { 24 | items { 25 | id 26 | title 27 | link { 28 | linkTo 29 | reference { 30 | slug 31 | } 32 | } 33 | } 34 | } 35 | } 36 | `) 37 | 38 | return ( 39 |
40 | 41 | Medusa Logo 42 | 43 | {!isCheckout && ( 44 |
45 | {data.nav.items.map((item) => { 46 | return ( 47 | 48 | {item.title} 49 | 50 | ) 51 | })} 52 |
53 | )} 54 | {!isCheckout ? ( 55 | 61 | ) : null} 62 |
63 | ) 64 | } 65 | 66 | export default NavBar 67 | -------------------------------------------------------------------------------- /src/styles/checkout-summary.module.css: -------------------------------------------------------------------------------- 1 | .spinnerContainer { 2 | width: 100%; 3 | height: 100vh; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .container { 10 | --py: 35px; 11 | 12 | position: fixed; 13 | width: 40%; 14 | height: 100vh; 15 | max-height: 100vh; 16 | -webkit-box-shadow: var(--shade); 17 | box-shadow: var(--shade); 18 | background: white; 19 | z-index: 11; 20 | display: flex; 21 | flex-direction: column; 22 | overflow: hidden; 23 | justify-content: space-between; 24 | right: 0; 25 | position: fixed; 26 | top: 0; 27 | } 28 | 29 | .closeBtn { 30 | background: transparent; 31 | border: none; 32 | cursor: pointer; 33 | display: none; 34 | } 35 | 36 | .breakdown { 37 | display: flex; 38 | align-items: center; 39 | justify-content: space-between; 40 | padding: 10px var(--py); 41 | } 42 | 43 | .total { 44 | display: flex; 45 | align-items: center; 46 | justify-content: space-between; 47 | padding: 10px var(--py) 20px; 48 | font-weight: 700; 49 | } 50 | 51 | .total p { 52 | margin: 0; 53 | } 54 | 55 | .breakdown p { 56 | margin: 0; 57 | } 58 | 59 | @media (max-width: 876px) { 60 | .container { 61 | --py: 35px; 62 | 63 | position: fixed; 64 | height: calc(100vh - 20px); 65 | max-height: calc(100vh - 20px); 66 | -webkit-box-shadow: var(--shade); 67 | box-shadow: var(--shade); 68 | background: white; 69 | z-index: 11; 70 | display: flex; 71 | flex-direction: column; 72 | overflow: hidden; 73 | justify-content: space-between; 74 | top: 20px; 75 | bottom: 0; 76 | border-radius: 8px; 77 | transition: -webkit-transform 0.5s ease; 78 | transition: transform 0.5s ease; 79 | -webkit-transform: translateY(110%); 80 | -ms-transform: translateY(110%); 81 | transform: translateY(110%); 82 | width: 100%; 83 | transition: -webkit-transform 0.5s ease; 84 | transition: transform 0.5s ease; 85 | } 86 | 87 | .active { 88 | -webkit-transform: translateY(0px); 89 | -ms-transform: translateY(0px); 90 | transform: translateY(0px); 91 | } 92 | 93 | .closeBtn { 94 | display: block; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/checkout/step-overview.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import DisplayContext from "../../context/display-context" 3 | import StoreContext from "../../context/store-context" 4 | import * as styles from "../../styles/step-overview.module.css" 5 | 6 | const StepOverview = () => { 7 | const { cart } = useContext(StoreContext) 8 | const { checkoutStep, updateCheckoutStep } = useContext(DisplayContext) 9 | return ( 10 |
11 |

Steps

12 |
13 | {cart?.shipping_address ? ( 14 | <> 15 |
16 | Contact 17 |
18 | {cart.shipping_address.first_name}{" "} 19 | {cart.shipping_address.last_name} 20 |
21 | 27 |
28 |
29 | Address 30 |
31 | {cart.shipping_address.address_1}, {cart.shipping_address.city} 32 |
33 | 39 |
40 | 41 | ) : null} 42 | {cart?.shipping_methods[0] && checkoutStep !== 2 ? ( 43 |
44 | Shipping 45 |
46 | {cart.shipping_methods[0].shipping_option.name} 47 |
48 | 54 |
55 | ) : null} 56 |
57 |
58 | ) 59 | } 60 | 61 | export default StepOverview 62 | -------------------------------------------------------------------------------- /src/components/seo.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import { Helmet } from "react-helmet" 4 | import { useLocation } from "@reach/router" 5 | import { useStaticQuery, graphql } from "gatsby" 6 | 7 | const SEO = ({ title, description, image, article }) => { 8 | const { pathname } = useLocation() 9 | const { site } = useStaticQuery(query) 10 | 11 | const { 12 | defaultTitle, 13 | titleTemplate, 14 | defaultDescription, 15 | siteUrl, 16 | defaultImage, 17 | twitterUsername, 18 | } = site.siteMetadata 19 | 20 | const seo = { 21 | title: title || defaultTitle, 22 | description: description || defaultDescription, 23 | image: `${siteUrl}${image || defaultImage}`, 24 | url: `${siteUrl}${pathname}`, 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | {seo.url && } 33 | 34 | {(article ? true : null) && } 35 | 36 | {seo.title && } 37 | 38 | {seo.description && ( 39 | 40 | )} 41 | 42 | {seo.image && } 43 | 44 | 45 | 46 | {twitterUsername && ( 47 | 48 | )} 49 | 50 | {seo.title && } 51 | 52 | {seo.description && ( 53 | 54 | )} 55 | 56 | {seo.image && } 57 | 58 | ) 59 | } 60 | 61 | export default SEO 62 | 63 | SEO.propTypes = { 64 | title: PropTypes.string, 65 | description: PropTypes.string, 66 | image: PropTypes.string, 67 | article: PropTypes.bool, 68 | } 69 | 70 | SEO.defaultProps = { 71 | title: null, 72 | description: null, 73 | image: null, 74 | article: false, 75 | } 76 | 77 | const query = graphql` 78 | query SEO { 79 | site { 80 | siteMetadata { 81 | defaultTitle: title 82 | titleTemplate 83 | defaultDescription: description 84 | siteUrl: url 85 | defaultImage: image 86 | twitterUsername 87 | } 88 | } 89 | } 90 | ` 91 | -------------------------------------------------------------------------------- /src/components/checkout/injectable-payment-card.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react" 2 | import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js" 3 | import { navigate } from "gatsby" 4 | import DisplayContext from "../../context/display-context" 5 | import * as styles from "../../styles/injectable-payment-card.module.css" 6 | import { BiLeftArrowAlt } from "react-icons/bi" 7 | 8 | const InjectablePaymentCard = ({ session, onSetPaymentSession }) => { 9 | const stripe = useStripe() 10 | const elements = useElements() 11 | const [succeeded, setSucceeded] = useState(false) 12 | const [error, setError] = useState(null) 13 | const [processing, setProcessing] = useState("") 14 | const [disabled, setDisabled] = useState(true) 15 | const { updateCheckoutStep } = useContext(DisplayContext) 16 | 17 | const handleChange = async (event) => { 18 | setDisabled(event.empty) 19 | setError(event.error ? event.error.message : "") 20 | } 21 | 22 | const handleSubmit = async (ev) => { 23 | ev.preventDefault() 24 | setProcessing(true) 25 | 26 | await onSetPaymentSession() 27 | 28 | const payload = await stripe.confirmCardPayment( 29 | session.data.client_secret, 30 | { 31 | payment_method: { 32 | card: elements.getElement(CardElement), 33 | }, 34 | } 35 | ) 36 | if (payload.error) { 37 | setError(`${payload.error.message}`) 38 | setProcessing(false) 39 | } else { 40 | setError(null) 41 | setProcessing(false) 42 | setSucceeded(true) 43 | navigate(`/payment`) 44 | } 45 | } 46 | 47 | return ( 48 |
49 | 54 | {/* Show any error that happens when processing the payment */} 55 | {error && ( 56 |
57 | {error} 58 |
59 | )} 60 |
61 | 67 | 74 |
75 | 76 | ) 77 | } 78 | 79 | export default InjectablePaymentCard 80 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { graphql } from "gatsby" 3 | 4 | import SEO from "../components/seo" 5 | import Hero from "../components/hero/hero" 6 | import TileSection from "../components/tile-section/tile-section" 7 | import * as styles from "../styles/home.module.css" 8 | 9 | // markup 10 | const IndexPage = ({ data }) => { 11 | return ( 12 |
13 | 17 |
18 | {data.page.contentModules.map((cm) => { 19 | switch (cm.internal.type) { 20 | case "ContentfulHero": 21 | return 22 | case "ContentfulTileSection": 23 | return 24 | default: 25 | return null 26 | } 27 | })} 28 |
29 |
30 | ) 31 | } 32 | 33 | export const query = graphql` 34 | query HomeQuery { 35 | page: contentfulPage(title: { eq: "Home" }) { 36 | title 37 | metaDescription { 38 | metaDescription 39 | } 40 | contentModules { 41 | ... on ContentfulHero { 42 | id 43 | backgroundImage { 44 | gatsbyImageData 45 | } 46 | title 47 | cta 48 | link { 49 | linkTo 50 | reference { 51 | slug 52 | } 53 | } 54 | internal { 55 | type 56 | } 57 | } 58 | ... on ContentfulTileSection { 59 | id 60 | title 61 | tiles { 62 | ... on ContentfulProduct { 63 | id 64 | title 65 | handle 66 | thumbnail { 67 | gatsbyImageData 68 | } 69 | internal { 70 | type 71 | } 72 | } 73 | ... on ContentfulTile { 74 | id 75 | title 76 | cta 77 | image { 78 | gatsbyImageData 79 | } 80 | link { 81 | linkTo 82 | reference { 83 | slug 84 | } 85 | } 86 | internal { 87 | type 88 | } 89 | } 90 | } 91 | internal { 92 | type 93 | } 94 | } 95 | } 96 | } 97 | } 98 | ` 99 | 100 | export default IndexPage 101 | -------------------------------------------------------------------------------- /src/pages/{ContentfulPage.slug}.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { graphql } from "gatsby" 3 | 4 | import SEO from "../components/seo" 5 | import Hero from "../components/hero/hero" 6 | import TileSection from "../components/tile-section/tile-section" 7 | import * as styles from "../styles/home.module.css" 8 | 9 | // markup 10 | const Page = ({ data }) => { 11 | return ( 12 |
13 | 17 |
18 | {data.page.contentModules.map((cm) => { 19 | switch (cm.internal.type) { 20 | case "ContentfulHero": 21 | return 22 | case "ContentfulTileSection": 23 | return 24 | default: 25 | return null 26 | } 27 | })} 28 |
29 |
30 | ) 31 | } 32 | 33 | export const query = graphql` 34 | query ($id: String!) { 35 | page: contentfulPage(id: { eq: $id }) { 36 | title 37 | metaDescription { 38 | metaDescription 39 | } 40 | contentModules { 41 | ... on ContentfulHero { 42 | id 43 | backgroundImage { 44 | gatsbyImageData 45 | } 46 | title 47 | cta 48 | link { 49 | linkTo 50 | reference { 51 | slug 52 | } 53 | } 54 | internal { 55 | type 56 | } 57 | } 58 | ... on ContentfulTileSection { 59 | id 60 | title 61 | tiles { 62 | ... on ContentfulProduct { 63 | id 64 | title 65 | handle 66 | thumbnail { 67 | gatsbyImageData 68 | } 69 | internal { 70 | type 71 | } 72 | } 73 | ... on ContentfulTile { 74 | id 75 | title 76 | cta 77 | image { 78 | gatsbyImageData 79 | } 80 | link { 81 | linkTo 82 | reference { 83 | slug 84 | } 85 | } 86 | internal { 87 | type 88 | } 89 | } 90 | } 91 | internal { 92 | type 93 | } 94 | } 95 | } 96 | } 97 | } 98 | ` 99 | 100 | export default Page 101 | -------------------------------------------------------------------------------- /src/styles/product.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | min-height: 100vh; 4 | display: flex; 5 | flex-wrap: wrap; 6 | } 7 | 8 | .controls { 9 | display: flex; 10 | width: 100%; 11 | } 12 | 13 | .image { 14 | width: 60%; 15 | height: 100vh; 16 | margin: 0; 17 | } 18 | 19 | .placeholder { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | height: 100%; 24 | width: 100%; 25 | } 26 | 27 | .info { 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: center; 31 | padding: 1rem 3rem; 32 | } 33 | 34 | .sizebtn { 35 | padding: 0.7rem; 36 | border: none; 37 | background: lightgrey; 38 | border-radius: 2px; 39 | margin-right: 0.5rem; 40 | transition: all 0.2s ease-in; 41 | } 42 | 43 | .sizebtn:hover { 44 | background: var(--ui-100); 45 | } 46 | 47 | .selected { 48 | background: var(--brand-deep-blue); 49 | color: white; 50 | } 51 | 52 | .ticker { 53 | width: 35px; 54 | height: 35px; 55 | text-align: center; 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | } 60 | 61 | .qty { 62 | display: flex; 63 | align-items: center; 64 | } 65 | 66 | .selection { 67 | margin: 2rem 0; 68 | } 69 | 70 | .selection p { 71 | margin: 0; 72 | margin-bottom: 0.7rem; 73 | } 74 | 75 | .qtybtn { 76 | height: 38px; 77 | width: 38px; 78 | border: none; 79 | background: none; 80 | transition: all 0.2s ease-in; 81 | border-radius: 2px; 82 | } 83 | 84 | .qtybtn:hover, 85 | .qtybtn:focus { 86 | background: var(--logo-color-100); 87 | } 88 | 89 | .addbtn { 90 | font-size: 1.125rem; 91 | min-height: 3rem; 92 | min-width: 3rem; 93 | padding: 0.5rem 1.25rem; 94 | align-self: center; 95 | display: inline-flex; 96 | align-items: center; 97 | background: var(--brand-deep-blue); 98 | color: white; 99 | border-radius: 4px; 100 | transition: background 0.2s ease-in; 101 | font-weight: 500; 102 | border: none; 103 | } 104 | 105 | .addbtn svg { 106 | margin-left: 0.7rem; 107 | } 108 | 109 | .addbtn:hover { 110 | background: var(--brand-green); 111 | color: var(--brand-deep-blue); 112 | } 113 | 114 | .tabs { 115 | margin-top: 2rem; 116 | max-width: 500px; 117 | } 118 | 119 | .tabtitle { 120 | background: transparent; 121 | border: none; 122 | padding: 0.5rem 0; 123 | font-size: var(--fz-m); 124 | border-bottom: 1px solid var(--logo-color-400); 125 | } 126 | 127 | .content-modules { 128 | display: flex; 129 | flex-direction: column; 130 | width: 100%; 131 | } 132 | 133 | @media (max-width: 876px) { 134 | .container { 135 | flex-direction: column; 136 | } 137 | 138 | .image, 139 | .info { 140 | width: 100%; 141 | } 142 | 143 | .image { 144 | height: 50vh; 145 | } 146 | 147 | .info { 148 | margin-top: 1rem; 149 | padding: 0 22px; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/components/checkout/shipping-step.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from "react" 2 | import * as styles from "../../styles/shipping-step.module.css" 3 | import ShippingMethod from "./shipping-method" 4 | import { BiLeftArrowAlt } from "react-icons/bi" 5 | import DisplayContext from "../../context/display-context" 6 | import { isEmpty } from "lodash" 7 | import StoreContext from "../../context/store-context" 8 | import { MdError } from "react-icons/md" 9 | 10 | const ShippingStep = ({ handleDeliverySubmit, isProcessing, cart }) => { 11 | const [shippingOptions, setShippingOptions] = useState([]) 12 | const [selectedOption, setSelectedOption] = useState() 13 | const [error, setError] = useState(false) 14 | 15 | const { getShippingOptions } = useContext(StoreContext) 16 | const { updateCheckoutStep } = useContext(DisplayContext) 17 | 18 | useEffect(() => { 19 | // Wait until the customer has entered their address information 20 | if (!cart.shipping_address?.country_code) { 21 | return 22 | } 23 | 24 | getShippingOptions().then((partitioned) => { 25 | setShippingOptions(partitioned) 26 | }) 27 | 28 | //if method is already selected, then preselect 29 | if (cart.shipping_methods.length > 0) { 30 | setSelectedOption(cart.shipping_methods[0].shipping_option) 31 | } 32 | }, [cart, setSelectedOption, getShippingOptions]) 33 | 34 | const handleSelectOption = (o) => { 35 | setSelectedOption(o) 36 | 37 | if (error) { 38 | setError(false) 39 | } 40 | } 41 | 42 | const handleSubmit = () => { 43 | if (!selectedOption) { 44 | setError(true) 45 | } else { 46 | handleDeliverySubmit(selectedOption) 47 | } 48 | } 49 | 50 | return ( 51 |
52 |

Delivery

53 | {isEmpty(shippingOptions) || isProcessing ? ( 54 |
loading...
55 | ) : ( 56 |
57 | {shippingOptions.map((so) => { 58 | return ( 59 |
60 | 65 |
66 | ) 67 | })} 68 |
69 | )} 70 |
71 | 72 |

Select a shipping method

73 |
74 |
75 | 81 | 84 |
85 |
86 | ) 87 | } 88 | 89 | export default ShippingStep 90 | -------------------------------------------------------------------------------- /src/components/checkout/checkout-step.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react" 2 | import DisplayContext from "../../context/display-context" 3 | import StoreContext from "../../context/store-context" 4 | import * as styles from "../../styles/checkout-step.module.css" 5 | import Link from "../link" 6 | import CheckoutSummary from "./checkout-summary" 7 | import InformationStep from "./information-step" 8 | import PaymentStep from "./payment-step" 9 | import ShippingStep from "./shipping-step" 10 | import StepOverview from "./step-overview" 11 | 12 | const CheckoutStep = () => { 13 | const { checkoutStep, updateCheckoutStep, updateOrderSummaryDisplay } = 14 | useContext(DisplayContext) 15 | const { cart, updateAddress, setShippingMethod } = useContext(StoreContext) 16 | 17 | const [isProcessingInfo, setIsProcessingInfo] = useState(false) 18 | const [isProcessingShipping, setIsProcessingShipping] = useState(false) 19 | 20 | const handleShippingSubmit = async (address, email) => { 21 | setIsProcessingInfo(true) 22 | 23 | await updateAddress(address, email) 24 | 25 | setIsProcessingInfo(false) 26 | updateCheckoutStep(2) 27 | } 28 | 29 | const handleDeliverySubmit = async (option) => { 30 | setIsProcessingShipping(true) 31 | await setShippingMethod(option.id) 32 | .then(() => { 33 | updateCheckoutStep(3) 34 | }) 35 | .finally(() => { 36 | setIsProcessingShipping(false) 37 | }) 38 | } 39 | 40 | const handleStep = () => { 41 | switch (checkoutStep) { 42 | case 1: 43 | return ( 44 | 52 | handleShippingSubmit(submittedAddr, submittedEmail) 53 | } 54 | /> 55 | ) 56 | case 2: 57 | return ( 58 | 64 | ) 65 | case 3: 66 | return 67 | default: 68 | return null 69 | } 70 | } 71 | 72 | return ( 73 |
74 |
75 | 76 | ← Back to store 77 | 78 |
79 |

80 | Information 81 |

82 |

/

83 |

84 | Delivery 85 |

86 |

/

87 |

Payment

88 |
89 | {checkoutStep !== 1 ? : null} 90 | {handleStep()} 91 | 97 |
98 |
99 | 100 |
101 |
102 | ) 103 | } 104 | 105 | export default CheckoutStep 106 | -------------------------------------------------------------------------------- /src/components/checkout/checkout-summary.jsx: -------------------------------------------------------------------------------- 1 | import * as itemStyles from "../../styles/cart-view.module.css" 2 | import * as styles from "../../styles/checkout-summary.module.css" 3 | 4 | import React, { useContext } from "react" 5 | import { quantity, sum } from "../../utils/helper-functions" 6 | 7 | import DisplayContext from "../../context/display-context" 8 | import { Link } from "gatsby" 9 | import { PuffLoader } from "react-spinners" 10 | import { formatPrice } from "../../utils/helper-functions" 11 | 12 | const CheckoutSummary = ({ cart }) => { 13 | const { orderSummary, updateOrderSummaryDisplay } = useContext(DisplayContext) 14 | return cart ? ( 15 |
16 |
17 |

18 | Order Summary 19 |

20 |

21 | {cart.items.length > 0 ? cart.items.map(quantity).reduce(sum) : 0}{" "} 22 | {cart.items.length > 0 && cart.items.map(quantity).reduce(sum) === 1 23 | ? "item" 24 | : "items"} 25 |

26 | 32 |
33 |
34 | {cart.items 35 | .sort((a, b) => { 36 | const createdAtA = new Date(a.created_at), 37 | createdAtB = new Date(b.created_at) 38 | 39 | if (createdAtA < createdAtB) return -1 40 | if (createdAtA > createdAtB) return 1 41 | return 0 42 | }) 43 | .map((i) => { 44 | return ( 45 |
46 |
47 | 48 | {i.title} 49 |
50 | 51 |
52 |
53 |
54 |
55 | 56 | {i.title} 57 | 58 |

Size: {i.variant.title}

59 |

60 | Price:{" "} 61 | {formatPrice(i.unit_price, cart.region.currency_code)} 62 |

63 |

Quantity: {i.quantity}

64 |
65 |
66 |
67 |
68 | ) 69 | })} 70 |
71 |
72 |

Subtotal (incl. taxes)

73 | 74 | {cart.region 75 | ? formatPrice(cart.subtotal, cart.region.currency_code) 76 | : 0} 77 | 78 |
79 |
80 |

Shipping

81 | 82 | {cart.region 83 | ? formatPrice(cart.shipping_total, cart.region.currency_code) 84 | : 0} 85 | 86 |
87 |
88 |

Total

89 | 90 | {cart.region ? formatPrice(cart.total, cart.region.currency_code) : 0} 91 | 92 |
93 |
94 | ) : ( 95 |
96 | 97 |
98 | ) 99 | } 100 | 101 | export default CheckoutSummary 102 | -------------------------------------------------------------------------------- /src/pages/payment.js: -------------------------------------------------------------------------------- 1 | import * as itemStyles from "../styles/cart-view.module.css" 2 | import * as styles from "../styles/payment.module.css" 3 | 4 | import React, { useContext, useEffect, useState } from "react" 5 | 6 | import { Link } from "gatsby" 7 | import StoreContext from "../context/store-context" 8 | import { formatPrice } from "../utils/helper-functions" 9 | 10 | const style = { 11 | height: "100vh", 12 | width: "100%", 13 | display: "flex", 14 | flexDirection: "column", 15 | justifyContent: "center", 16 | alignItems: "center", 17 | textAlign: "center", 18 | } 19 | 20 | const Payment = () => { 21 | const [order, setOrder] = useState() 22 | const { cart, completeCart, createCart } = useContext(StoreContext) 23 | 24 | useEffect(() => { 25 | if (cart.items.length > 0) { 26 | completeCart().then((order) => { 27 | setOrder(order) 28 | createCart() 29 | }) 30 | } 31 | }, [cart, completeCart, createCart]) 32 | 33 | return !order ? ( 34 |
35 |

Hang on while we validate your payment...

36 |
37 | ) : ( 38 |
39 |
40 |

Order Summary

41 |

Thank you for your order!

42 |
43 |
44 | {order.items 45 | .sort((a, b) => { 46 | const createdAtA = new Date(a.created_at), 47 | createdAtB = new Date(b.created_at) 48 | 49 | if (createdAtA < createdAtB) return -1 50 | if (createdAtA > createdAtB) return 1 51 | return 0 52 | }) 53 | .map((i) => { 54 | return ( 55 |
56 |
57 |
58 | 59 | {i.title} 60 |
61 | 62 |
63 |
64 |
65 |
66 | 67 | {i.title} 68 | 69 |

70 | Size: {i.variant.title} 71 |

72 |

73 | Price:{" "} 74 | {formatPrice(i.unit_price, order.currency_code)} 75 |

76 |

77 | Quantity: {i.quantity} 78 |

79 |
80 |
81 |
82 |
83 |
84 | ) 85 | })} 86 |
87 |
88 |
89 |
Subtotal
90 |
{formatPrice(order.subtotal, order.region.currency_code)}
91 |
92 |
93 |
Shipping
94 |
95 | {formatPrice(order.shipping_total, order.region.currency_code)} 96 |
97 |
98 |
99 |
Total
100 |
{formatPrice(order.total, order.region.currency_code)}
101 |
102 |
103 |
104 |

An order comfirmation will be sent to you at {order.email}

105 |
106 |
107 | ) 108 | } 109 | 110 | export default Payment 111 | -------------------------------------------------------------------------------- /src/styles/cart-view.module.css: -------------------------------------------------------------------------------- 1 | .cart-container { 2 | --py: 35px; 3 | 4 | z-index: var(--z-index-high); 5 | position: fixed; 6 | min-width: 460px; 7 | height: 100vh; 8 | max-height: 100vh; 9 | -webkit-box-shadow: var(--shade); 10 | box-shadow: var(--shade); 11 | background: white; 12 | display: flex; 13 | flex-direction: column; 14 | overflow: hidden; 15 | justify-content: space-between; 16 | right: -460px; 17 | top: 0; 18 | transition: -webkit-transform 0.5s ease; 19 | transition: transform 0.5s ease; 20 | -webkit-transform: translateX(110%); 21 | -ms-transform: translateX(110%); 22 | transform: translateX(110%); 23 | } 24 | 25 | .active { 26 | composes: cart-container; 27 | -webkit-transform: translateX(-460px); 28 | -ms-transform: translateX(-460px); 29 | transform: translateX(-460px); 30 | } 31 | 32 | .top { 33 | display: flex; 34 | align-items: center; 35 | justify-content: space-between; 36 | padding: 2.5px var(--py); 37 | border-bottom: 1px solid var(--brand-deep-blue); 38 | } 39 | 40 | .closebtn { 41 | background: transparent; 42 | border: none; 43 | cursor: pointer; 44 | } 45 | 46 | .subtotal { 47 | display: flex; 48 | align-items: center; 49 | justify-content: space-between; 50 | padding: 15px var(--py); 51 | } 52 | 53 | .bottom { 54 | padding: 15px var(--py); 55 | } 56 | 57 | .overview { 58 | flex-grow: 1; 59 | overflow-y: scroll; 60 | scrollbar-width: thin; 61 | scrollbar-color: var(--logo-color-400) transparent; 62 | } 63 | 64 | .overview::-webkit-scrollbar { 65 | width: 12px; 66 | border-radius: 12px; 67 | } 68 | 69 | .overview::-webkit-scrollbar-track { 70 | background: transparent; 71 | border-radius: 12px; 72 | } 73 | 74 | .overview::-webkit-scrollbar-thumb { 75 | background-color: var(--logo-color-400); 76 | border-radius: 20px; 77 | border: 1px solid var(--bg); 78 | } 79 | 80 | .product { 81 | padding: 24px var(--py) 0; 82 | margin-top: 0; 83 | position: relative; 84 | min-height: 120px; 85 | display: flex; 86 | } 87 | 88 | .mid { 89 | display: flex; 90 | flex-direction: column; 91 | } 92 | 93 | .price { 94 | margin: 0; 95 | } 96 | 97 | .selector { 98 | display: flex; 99 | align-items: center; 100 | } 101 | 102 | .product figure { 103 | width: 126px; 104 | height: 189px; 105 | margin: 0; 106 | margin-right: 1rem; 107 | } 108 | 109 | .product figure img { 110 | width: 100%; 111 | height: auto; 112 | object-fit: cover; 113 | } 114 | 115 | .placeholder { 116 | width: 100%; 117 | height: 100%; 118 | cursor: pointer; 119 | } 120 | 121 | .controls { 122 | display: flex; 123 | flex-direction: column; 124 | justify-content: space-around; 125 | } 126 | 127 | .remove { 128 | background: transparent; 129 | border: none; 130 | cursor: pointer; 131 | padding: 0; 132 | text-align: left; 133 | text-decoration: underline; 134 | color: lightgrey; 135 | transition: color 0.1s ease-in; 136 | } 137 | 138 | .remove:hover { 139 | color: var(--logo-color-900); 140 | } 141 | 142 | .size { 143 | font-size: var(--fz-sm); 144 | color: grey; 145 | } 146 | 147 | .ticker { 148 | width: 25px; 149 | text-align: center; 150 | user-select: none; 151 | } 152 | 153 | .qtybtn { 154 | background: transparent; 155 | border: transparent; 156 | color: grey; 157 | transition: color 0.1s ease-in; 158 | cursor: pointer; 159 | } 160 | 161 | .qtybtn:hover { 162 | color: var(--logo-color-900); 163 | } 164 | 165 | .checkoutbtn { 166 | width: 100%; 167 | font-size: 1.125rem; 168 | min-height: 3rem; 169 | padding: 0.5rem 0; 170 | align-self: center; 171 | display: inline-flex; 172 | align-items: center; 173 | justify-content: center; 174 | background: var(--brand-green); 175 | color: var(--brand-deep-blue); 176 | border-radius: 8px; 177 | transition: background 0.2s ease-in; 178 | font-weight: 500; 179 | cursor: pointer; 180 | border: none; 181 | } 182 | 183 | .checkoutbtn:hover { 184 | color: var(--brand-cool-grey); 185 | background: var(--brand-deep-blue); 186 | } 187 | 188 | @media (max-width: 876px) { 189 | .container { 190 | width: 100%; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Medusa 4 | 5 |

6 |

7 | Medusa Contentful Storefront 8 |

9 |

10 | Medusa is an open-source headless commerce engine that enables developers to create amazing digital commerce experiences. 11 |

12 |

13 | 14 | Medusa is released under the MIT license. 15 | 16 | 17 | PRs welcome! 18 | 19 | 20 | Discord Chat 21 | 22 | 23 | Follow @medusajs 24 | 25 |

26 | 27 | > :warning: **This storefront is deprecated and may not work with the latest versions of Medusa. It's recommended to integrate Contentful in your storefront manually.** 28 | 29 | > **Prerequisites**: This starter works with [`medusa-starter-contentful`](https://github.com/medusajs/medusa-starter-contentful). Make sure to have this starter installed and running. 30 | 31 | ## Quick start 32 | 33 | 1. **Install the storefront** 34 | ```shell 35 | gatsby new medusa-contentful-storefront https://github.com/medusajs/medusa-contentful-storefront/edit/master/README.md 36 | ``` 37 | 2. **Setup your environment variables** 38 | 39 | ```shell 40 | mv .env.template .env 41 | ``` 42 | 43 | Go to your [Contentful space](https://app.contentful.com), then click **Settings** > **API Keys** > **Add API key**. Copy the value in the field "Content Delivery API - access token" and paste it into your `.env` together with your Contentful space id: 44 | 45 | ``` 46 | CONTENTFUL_SPACE_ID=***** 47 | CONTENTFUL_ACCESS_TOKEN=************** 48 | ``` 49 | 50 | 3. **Start developing.** 51 | 52 | Start up the local server. 53 | 54 | ```shell 55 | yarn start 56 | ``` 57 | 58 | 4. **Open the code and start customizing!** 59 | 60 | Your site is now running at http://localhost:8000! 61 | 62 | Edit `src/pages/index.js` to see your site update in real-time! 63 | 64 | 5. **Learn more about Medusa** 65 | 66 | - [Website](https://www.medusa-commerce.com/) 67 | - [GitHub](https://github.com/medusajs) 68 | - [Documentation](https://docs.medusa-commerce.com/) 69 | 70 | 6. **Learn more about Contentful** 71 | 72 | - [Website](https://contentful.com/) 73 | - [Documentation](https://www.contentful.com/developers/docs/) 74 | - [Migrations](https://www.contentful.com/developers/docs/tutorials/cli/scripting-migrations/) 75 | 76 | 7. **Learn more about Gatsby** 77 | 78 | - [Documentation](https://www.gatsbyjs.com/docs/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 79 | 80 | - [Tutorials](https://www.gatsbyjs.com/tutorial/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 81 | 82 | - [Guides](https://www.gatsbyjs.com/tutorial/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 83 | 84 | - [API Reference](https://www.gatsbyjs.com/docs/api-reference/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 85 | 86 | - [Plugin Library](https://www.gatsbyjs.com/plugins?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 87 | 88 | - [Cheat Sheet](https://www.gatsbyjs.com/docs/cheat-sheet/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 89 | 90 | ## Thank you! 91 | 92 |

93 | 94 | Website 95 | 96 | | 97 | 98 | Notion Home 99 | 100 | | 101 | 102 | Twitter 103 | 104 | | 105 | 106 | Docs 107 | 108 |

109 | -------------------------------------------------------------------------------- /src/styles/home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 0; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .main { 8 | width: 100%; 9 | padding: 0 0; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | 14 | .title { 15 | margin: 0; 16 | line-height: 1.15; 17 | font-size: clamp(2rem, 8vw, 4rem); 18 | } 19 | 20 | .description { 21 | line-height: 1.5; 22 | font-size: clamp(1rem, 2vw, 1.5rem); 23 | } 24 | 25 | .tag { 26 | border-radius: 5px; 27 | padding: 0.75rem; 28 | font-size: var(--fz-s); 29 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 30 | Bitstream Vera Sans Mono, Courier New, monospace; 31 | margin-right: 1rem; 32 | } 33 | 34 | .grid { 35 | display: flex; 36 | align-items: center; 37 | justify-content: center; 38 | flex-wrap: wrap; 39 | } 40 | 41 | .products { 42 | display: flex; 43 | flex-direction: column; 44 | align-items: flex-start; 45 | width: 100%; 46 | margin-top: 3rem; 47 | } 48 | 49 | .card { 50 | margin-right: 1rem; 51 | padding: 1.5rem; 52 | text-align: left; 53 | color: inherit; 54 | text-decoration: none; 55 | border: 1px solid #eaeaea; 56 | border-radius: 10px; 57 | transition: color 0.15s ease, border-color 0.15s ease; 58 | } 59 | 60 | .card:hover, 61 | .card:focus, 62 | .card:active { 63 | color: var(--logo-color-900); 64 | border-color: var(--logo-color-900); 65 | } 66 | 67 | .card h2 { 68 | margin: 0 0 0.5rem 0; 69 | font-size: 1.25rem; 70 | } 71 | 72 | .card p { 73 | margin: 0; 74 | font-size: 1rem; 75 | line-height: 1.5; 76 | } 77 | 78 | .logo { 79 | height: 1em; 80 | margin-left: 0.5rem; 81 | } 82 | 83 | .tile-section { 84 | display: flex; 85 | flex-direction: column; 86 | width: 100%; 87 | padding: 64px 59px; 88 | } 89 | 90 | .tile-wrapper { 91 | display: flex; 92 | justify-content: space-between; 93 | align-items: flex-start; 94 | } 95 | 96 | .tile { 97 | display: flex; 98 | flex-direction: column; 99 | } 100 | 101 | .tile-body .tile-title { 102 | margin-bottom: 8px; 103 | } 104 | 105 | .tile-body .tile-cta { 106 | color: var(--brand-green); 107 | } 108 | 109 | .hero { 110 | color: white; 111 | position: relative; 112 | text-align: left; 113 | width: 100%; 114 | height: 647px; 115 | } 116 | 117 | .links { 118 | display: flex; 119 | align-items: center; 120 | } 121 | 122 | .btn { 123 | font-size: 1.125rem; 124 | min-height: 3rem; 125 | min-width: 3rem; 126 | padding: 0.5rem 1.25rem; 127 | align-self: center; 128 | display: inline-flex; 129 | align-items: center; 130 | background: var(--logo-color-900); 131 | color: white; 132 | border-radius: 8px; 133 | transition: background 0.2s ease-in; 134 | font-weight: 500; 135 | border: none; 136 | } 137 | 138 | .btn:hover { 139 | background: var(--logo-color-1000); 140 | } 141 | 142 | .btn svg { 143 | margin-left: 0.5rem; 144 | font-size: var(--fz-l); 145 | } 146 | 147 | .btn:hover svg { 148 | transform: scale(1.1); 149 | transform-origin: center; 150 | -webkit-animation: heartbeat 1.5s infinite both; 151 | animation: heartbeat 1.5s infinite both; 152 | } 153 | 154 | .links .btn:first-child { 155 | margin-right: 1rem; 156 | } 157 | 158 | .links .btn:last-child { 159 | background: transparent; 160 | color: black; 161 | font-weight: 400; 162 | } 163 | 164 | .links .btn:last-child:hover { 165 | background: var(--logo-color-100); 166 | } 167 | 168 | .tags { 169 | display: flex; 170 | align-items: center; 171 | flex-wrap: nowrap; 172 | margin-bottom: 3rem; 173 | } 174 | 175 | @media (max-width: 876px) { 176 | .grid { 177 | width: 100%; 178 | flex-direction: column; 179 | } 180 | 181 | .container { 182 | padding: 0 22px; 183 | } 184 | 185 | .card { 186 | width: 100%; 187 | margin-right: 0; 188 | margin-bottom: 1rem; 189 | } 190 | } 191 | 192 | @-webkit-keyframes heartbeat { 193 | from { 194 | -webkit-transform: scale(1); 195 | } 196 | 20% { 197 | -webkit-transform: scale(1); 198 | } 199 | 50% { 200 | -webkit-transform: scale(0.9); 201 | } 202 | 100% { 203 | -webkit-transform: scale(1); 204 | } 205 | } 206 | @keyframes heartbeat { 207 | from { 208 | -webkit-transform: scale(1); 209 | transform: scale(1); 210 | } 211 | 20% { 212 | -webkit-transform: scale(1); 213 | transform: scale(1); 214 | } 215 | 50% { 216 | -webkit-transform: scale(0.9); 217 | transform: scale(0.9); 218 | } 219 | 100% { 220 | -webkit-transform: scale(1); 221 | transform: scale(1); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/components/cart-view/cart-view.jsx: -------------------------------------------------------------------------------- 1 | import * as styles from "../../styles/cart-view.module.css" 2 | 3 | import { Link, navigate } from "gatsby" 4 | import React, { useContext } from "react" 5 | import { formatPrice, quantity, sum } from "../../utils/helper-functions" 6 | 7 | import DisplayContext from "../../context/display-context" 8 | import StoreContext from "../../context/store-context" 9 | 10 | const CartView = () => { 11 | const { cartView, updateCartViewDisplay, updateCheckoutStep } = 12 | useContext(DisplayContext) 13 | const { cart, currencyCode, updateLineItem, removeLineItem } = 14 | useContext(StoreContext) 15 | 16 | return ( 17 |
18 |
19 |

Bag

20 |

21 | {cart.items.length > 0 ? cart.items.map(quantity).reduce(sum) : 0}{" "} 22 | {cart.items.length > 0 && cart.items.map(quantity).reduce(sum) === 1 23 | ? "item" 24 | : "items"} 25 |

26 | 32 |
33 |
34 | {cart.items 35 | .sort((a, b) => { 36 | const createdAtA = new Date(a.created_at), 37 | createdAtB = new Date(b.created_at) 38 | 39 | if (createdAtA < createdAtB) return -1 40 | if (createdAtA > createdAtB) return 1 41 | return 0 42 | }) 43 | .map((i) => { 44 | return ( 45 |
46 |
47 | 48 | {i.title} 49 |
50 | 51 |
52 |
53 |
54 |
55 | 56 | {i.title} 57 | 58 |

Size: {i.variant.title}

59 |

60 | Price:{" "} 61 | {formatPrice(i.unit_price, cart.region.currency_code)} 62 |

63 |
64 |
65 |
66 |
67 | 78 |

{i.quantity}

79 | 90 |
91 |
92 |

{}

93 |
94 |
95 | 101 |
102 |
103 | ) 104 | })} 105 |
106 |
107 |

Subtotal (incl. taxes)

108 | 109 | {cart.region ? formatPrice(cart.subtotal, currencyCode) : 0} 110 | 111 |
112 |
113 | 124 |
125 |
126 | ) 127 | } 128 | 129 | export default CartView 130 | -------------------------------------------------------------------------------- /src/components/checkout/information-step.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react" 2 | import { Form, Formik } from "formik" 3 | import * as Yup from "yup" 4 | import PuffLoader from "react-spinners/PuffLoader" 5 | import * as styles from "../../styles/information-step.module.css" 6 | import InputField from "./input-field" 7 | import StoreContext from "../../context/store-context" 8 | import SelectField from "./select-field" 9 | 10 | const InformationStep = ({ handleSubmit, savedValues, isProcessing }) => { 11 | const { cart } = useContext(StoreContext) 12 | let Schema = Yup.object().shape({ 13 | first_name: Yup.string() 14 | .min(2, "Too short") 15 | .max(50, "Too long") 16 | .required("Required"), 17 | last_name: Yup.string() 18 | .min(2, "Too short") 19 | .max(50, "Too long") 20 | .required("Required"), 21 | email: Yup.string().email("Invalid email").required("Required"), 22 | address_1: Yup.string() 23 | .required("Required") 24 | .max(45, "Limit on 45 characters"), 25 | address_2: Yup.string().nullable(true).max(45, "Limit on 45 characters"), 26 | country_code: Yup.string().required("Required"), 27 | city: Yup.string().required("Required"), 28 | postal_code: Yup.string().required("Required"), 29 | province: Yup.string().nullable(true), 30 | phone: Yup.string().required("Required"), 31 | }) 32 | 33 | return ( 34 |
35 |

Address

36 | { 50 | const { email, ...rest } = values 51 | handleSubmit(rest, email) 52 | }} 53 | > 54 | {({ errors, touched, values }) => ( 55 |
56 | {isProcessing ? ( 57 |
58 | 59 |
60 | ) : ( 61 | <> 62 |
63 | 70 | 78 |
79 | 86 | 93 | 100 | 106 |
107 | 114 | 121 |
122 | 129 |
130 | 133 |
134 | 135 | )} 136 | 137 | )} 138 |
139 |
140 | ) 141 | } 142 | 143 | export default InformationStep 144 | -------------------------------------------------------------------------------- /src/views/product.js: -------------------------------------------------------------------------------- 1 | import * as styles from "../styles/product.module.css" 2 | 3 | import { GatsbyImage, getImage } from "gatsby-plugin-image" 4 | import React, { useCallback, useContext, useEffect, useState } from "react" 5 | import { formatPrice, resetOptions } from "../utils/helper-functions" 6 | 7 | import { BiShoppingBag } from "react-icons/bi" 8 | import SEO from "../components/seo" 9 | import StoreContext from "../context/store-context" 10 | import { createClient } from "../utils/client" 11 | 12 | const Product = ({ product }) => { 13 | const { cart, addVariantToCart } = useContext(StoreContext) 14 | const [options, setOptions] = useState({ 15 | variantId: "", 16 | quantity: 0, 17 | size: "", 18 | }) 19 | 20 | const [productStatus, setProductStatus] = useState(undefined) 21 | const client = createClient() 22 | 23 | useEffect(() => { 24 | const getProduct = async () => { 25 | const response = await client.products.retrieve(product.medusaId) 26 | setProductStatus(response.product) 27 | } 28 | 29 | getProduct() 30 | }, [product.medusaId]) 31 | 32 | useEffect(() => { 33 | if (product) { 34 | setOptions(resetOptions(product)) 35 | } 36 | }, [product]) 37 | 38 | const handleQtyChange = (action) => { 39 | if (action === "inc") { 40 | if ( 41 | options.quantity < 42 | productStatus.variants.find(({ id }) => id === options.variantId) 43 | .inventory_quantity 44 | ) 45 | setOptions({ 46 | variantId: options.variantId, 47 | quantity: options.quantity + 1, 48 | size: options.size, 49 | }) 50 | } 51 | if (action === "dec") { 52 | if (options.quantity > 1) 53 | setOptions({ 54 | variantId: options.variantId, 55 | quantity: options.quantity - 1, 56 | size: options.size, 57 | }) 58 | } 59 | } 60 | 61 | const handleAddToBag = () => { 62 | addVariantToCart({ 63 | variantId: options.variantId, 64 | quantity: options.quantity, 65 | }) 66 | if (product) setOptions(resetOptions(product)) 67 | } 68 | 69 | const renderPrice = useCallback( 70 | (variant) => { 71 | if (!cart.id) return 72 | if (!variant) return 73 | 74 | const region = cart.region 75 | const currency = region.currency_code 76 | 77 | const price = variant.prices.find( 78 | (ma) => ma.currency_code.toLowerCase() === currency.toLowerCase() 79 | ) 80 | return formatPrice(price.amount, price.currency_code) 81 | }, 82 | [cart] 83 | ) 84 | 85 | return ( 86 |
87 | 91 |
92 |
93 |
94 | 100 |
101 |
102 |
103 | 104 |
105 |
106 |

{product.title}

107 |
108 |

109 | {renderPrice( 110 | product.variants.find((v) => v.medusaId === options.variantId) 111 | )} 112 |

113 |
114 |

Select Size

115 |
116 | {product.variants 117 | .slice(0) 118 | .reverse() 119 | .map((v) => { 120 | return ( 121 | 136 | ) 137 | })} 138 |
139 |
140 |
141 |

Select Quantity

142 |
143 | 149 | {options.quantity} 150 | 156 |
157 |
158 | 162 |
163 |
164 | 165 |
166 |
167 |

{product.description?.description}

168 |
169 |
170 |
171 |
172 |
173 |
174 | ) 175 | } 176 | 177 | export default Product 178 | -------------------------------------------------------------------------------- /src/context/store-context.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useReducer, useRef } from "react" 2 | 3 | import { createClient } from "../utils/client" 4 | 5 | export const defaultStoreContext = { 6 | adding: false, 7 | cart: { 8 | items: [], 9 | }, 10 | order: {}, 11 | products: [], 12 | currencyCode: "eur", 13 | /** 14 | * 15 | * @param {*} variantId 16 | * @param {*} quantity 17 | * @returns 18 | */ 19 | addVariantToCart: async () => {}, 20 | createCart: async () => {}, 21 | removeLineItem: async () => {}, 22 | updateLineItem: async () => {}, 23 | setShippingMethod: async () => {}, 24 | updateAddress: async () => {}, 25 | createPaymentSession: async () => {}, 26 | completeCart: async () => {}, 27 | retrieveOrder: async () => {}, 28 | dispatch: () => {}, 29 | } 30 | 31 | const StoreContext = React.createContext(defaultStoreContext) 32 | export default StoreContext 33 | 34 | const reducer = (state, action) => { 35 | switch (action.type) { 36 | case "setCart": 37 | return { 38 | ...state, 39 | cart: action.payload, 40 | currencyCode: action.payload.region.currency_code, 41 | } 42 | case "setOrder": 43 | return { 44 | ...state, 45 | order: action.payload, 46 | } 47 | case "setProducts": 48 | return { 49 | ...state, 50 | products: action.payload, 51 | } 52 | default: 53 | return state 54 | } 55 | } 56 | 57 | const client = createClient() 58 | 59 | export const StoreProvider = ({ children }) => { 60 | const [state, dispatch] = useReducer(reducer, defaultStoreContext) 61 | const stateCartId = useRef() 62 | 63 | useEffect(() => { 64 | stateCartId.current = state.cart.id 65 | }, [state.cart]) 66 | 67 | useEffect(() => { 68 | let cartId 69 | if (localStorage) { 70 | cartId = localStorage.getItem("cart_id") 71 | } 72 | 73 | if (cartId) { 74 | client.carts.retrieve(cartId).then(({ cart }) => { 75 | dispatch({ type: "setCart", payload: cart }) 76 | }) 77 | } else { 78 | client.carts.create({}).then(({ cart }) => { 79 | dispatch({ type: "setCart", payload: cart }) 80 | if (localStorage) { 81 | localStorage.setItem("cart_id", cart.id) 82 | } 83 | }) 84 | } 85 | 86 | client.products.list().then(({ products }) => { 87 | dispatch({ type: "setProducts", payload: products }) 88 | }) 89 | }, []) 90 | 91 | const createCart = () => { 92 | if (localStorage) { 93 | localStorage.removeItem("cart_id") 94 | } 95 | client.carts.create({}).then(({ cart }) => { 96 | dispatch({ type: "setCart", payload: cart }) 97 | }) 98 | } 99 | 100 | const setPaymentSession = async (provider) => { 101 | client.carts 102 | .setPaymentSession(state.cart.id, { 103 | provider_id: provider, 104 | }) 105 | .then(({ cart }) => { 106 | dispatch({ type: "setCart", payload: cart }) 107 | return cart 108 | }) 109 | } 110 | 111 | const addVariantToCart = async ({ variantId, quantity }) => { 112 | client.carts.lineItems 113 | .create(state.cart.id, { 114 | variant_id: variantId, 115 | quantity: quantity, 116 | }) 117 | .then(({ cart }) => { 118 | dispatch({ type: "setCart", payload: cart }) 119 | }) 120 | } 121 | 122 | const removeLineItem = async (lineId) => { 123 | client.carts.lineItems.delete(state.cart.id, lineId).then(({ cart }) => { 124 | dispatch({ type: "setCart", payload: cart }) 125 | }) 126 | } 127 | 128 | const updateLineItem = async ({ lineId, quantity }) => { 129 | client.carts.lineItems 130 | .update(state.cart.id, lineId, { quantity: quantity }) 131 | .then(({ cart }) => { 132 | dispatch({ type: "setCart", payload: cart }) 133 | }) 134 | } 135 | 136 | const getShippingOptions = async () => { 137 | const shipping_options = await client.shippingOptions 138 | .list() 139 | .then(({ shipping_options }) => shipping_options) 140 | 141 | if (shipping_options) { 142 | return shipping_options 143 | } else { 144 | return undefined 145 | } 146 | } 147 | 148 | const setShippingMethod = async (id) => { 149 | return await client.carts 150 | .addShippingMethod(state.cart.id, { 151 | option_id: id, 152 | }) 153 | .then(({ cart }) => { 154 | dispatch({ type: "setCart", payload: cart }) 155 | return cart 156 | }) 157 | } 158 | 159 | const createPaymentSession = async () => { 160 | return await client.carts 161 | .createPaymentSessions(state.cart.id) 162 | .then(({ cart }) => { 163 | dispatch({ type: "setCart", payload: cart }) 164 | return cart 165 | }) 166 | } 167 | 168 | const completeCart = async () => { 169 | const data = await client.carts 170 | .complete(state.cart.id) 171 | .then(({ data }) => data) 172 | 173 | if (data) { 174 | return data 175 | } else { 176 | return undefined 177 | } 178 | } 179 | 180 | const retrieveOrder = async (orderId) => { 181 | const order = await client.orders.retrieve(orderId).then(({ order }) => order) 182 | 183 | if (order) { 184 | return order 185 | } else { 186 | return undefined 187 | } 188 | } 189 | 190 | const updateAddress = (address, email) => { 191 | client.carts 192 | .update(state.cart.id, { 193 | shipping_address: address, 194 | billing_address: address, 195 | email: email, 196 | }) 197 | .then(({ cart }) => { 198 | dispatch({ type: "setCart", payload: cart }) 199 | }) 200 | } 201 | 202 | return ( 203 | 220 | {children} 221 | 222 | ) 223 | } 224 | --------------------------------------------------------------------------------