├── 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 |
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 |
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 |
13 | fill
14 |
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 |
13 | fill
14 |
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 |

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 |
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 |
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 |
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 |
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 |
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 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
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 |
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 |
--------------------------------------------------------------------------------