├── .env.template ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── README.md ├── netlify.toml ├── package.json ├── public ├── check.png ├── discord.png ├── favicon.ico ├── github.png ├── mark-grey.png ├── mark.png ├── medusa-svg.svg └── medusa.png ├── src ├── components │ ├── code-snippet │ │ └── index.js │ ├── layout │ │ ├── completed-layout.js │ │ ├── index.js │ │ ├── layout.js │ │ └── logo.js │ ├── payment │ │ ├── payment-form.js │ │ ├── payment.js │ │ ├── review.js │ │ └── total.js │ ├── product-selection │ │ ├── index.js │ │ └── product-display │ │ │ ├── index.js │ │ │ ├── info.js │ │ │ └── option-selector.js │ ├── region-selector │ │ └── region-selector │ │ │ └── index.js │ ├── seo │ │ └── index.js │ ├── shipping │ │ ├── forms │ │ │ ├── contact.js │ │ │ ├── delivery.js │ │ │ ├── field-splitter.js │ │ │ ├── field.js │ │ │ ├── index.js │ │ │ └── select-shipping.js │ │ └── index.js │ ├── spinner │ │ └── spinner.js │ └── steps │ │ ├── index.js │ │ ├── order-confirmation.js │ │ ├── payment.js │ │ ├── product.js │ │ └── shipping.js ├── context │ └── product-context.js ├── fonts │ ├── Inter-Regular.ttf │ ├── Inter-SemiBold.ttf │ └── index.css ├── icons │ ├── logo-name.svg │ └── medusa-small.svg ├── pages │ ├── [handle].js │ ├── _app.js │ ├── completed.js │ ├── completing.js │ └── index.js ├── theme │ └── index.js └── utils │ ├── client.js │ ├── get-from.js │ ├── is-equal.js │ └── validator.js └── yarn.lock /.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | package-lock.json 4 | public -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false 4 | } -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"], 3 | }; 4 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Medusa 4 | 5 |

6 |

7 | Medusa Express 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 |
28 | 29 | ## Built with 30 | ### [Medusa](https://www.medusajs.com): Commerce engine 31 | ### [Next.js](https://nextjs.org/): React framework 32 | ### [Stripe](https://stripe.com/en-gb-dk): Payment provider 33 | ### [Medusa React](https://github.com/medusajs/medusa/tree/master/packages/medusa-react): Hooks and components for Medusa 34 | 35 |
36 | 37 | **Prerequisites**: To use Medusa Express, you need a Medusa server. Check out [medusa-starter-default](https://github.com/medusajs/medusa-starter-default) for a quick setup. 38 | 39 |
40 | 41 | ## 🚀 Get started! 42 | 43 | ### Deploy in 5 minutes 44 | 45 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/medusajs/medusa-express-nextjs) 46 | 47 | ### 1. Create your Medusa Express project 48 | 49 | #### Use npx and select medusa.express (recommended) 50 | ```zsh 51 | npx create-medusa-app@latest 52 | ``` 53 | 54 | #### Use git clone 55 | ```zsh 56 | git clone --depth=1 https://github.com/medusajs/medusa-express-nextjs medusa-express 57 | ``` 58 | 59 | ### 2. Navigate to project and install dependencies 60 | 61 | ```zsh 62 | cd 63 | 64 | yarn 65 | # or 66 | npm install 67 | ``` 68 | 69 | ### 3. Link your Medusa server 70 | 71 | In your project, you should have a `.env.template` file with the following content: 72 | 73 | ```shell 74 | NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000 75 | ``` 76 | 77 | Copy the template into a file used for local development: 78 | ```zsh 79 | mv .env.template .env.local 80 | ``` 81 | 82 | Add Stripe API key as environment variable to complete orders: 83 | ```zsh 84 | # Stripe key is required for completing orders 85 | NEXT_PUBLIC_STRIPE_API_KEY=pk_test_... 86 | ``` 87 | 88 | Your Medusa server runs locally on port 9000 by default. Make sure to update the above environment variable, if you've changed the port. 89 | 90 | ### 4. Try it out! 91 | 92 | Start up both your Medusa server and Medusa Express and try it out! 93 | 94 | Medusa Express is running at `http://localhost:8000`! 95 | 96 | > **Important**: Medusa Express requires existing product. Either seed your Medusa server with some dummy products, or create your own through Medusa Admin. 97 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [template.environment] 2 | NEXT_PUBLIC_MEDUSA_BACKEND_URL="URL of your Medusa Server" 3 | NEXT_PUBLIC_STRIPE_API_KEY="Your Stripe Publishable API Key" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medusa-express-nextjs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Medusa Express - One-page checkout", 6 | "author": "Medusa Engineering", 7 | "keywords": [ 8 | "nextjs" 9 | ], 10 | "scripts": { 11 | "dev": "next dev -p 8000", 12 | "build": "next build", 13 | "start": "next start", 14 | "storybook": "start-storybook -p 6006", 15 | "build-storybook": "build-storybook" 16 | }, 17 | "dependencies": { 18 | "@emotion/react": "^11.7.1", 19 | "@medusajs/medusa-js": "^1.0.1-canary.3", 20 | "@stripe/react-stripe-js": "^1.7.0", 21 | "@stripe/stripe-js": "^1.22.0", 22 | "axios": "^0.23.0", 23 | "deepmerge": "^4.2.2", 24 | "formik": "^2.2.9", 25 | "medusa-react": "^0.1.2", 26 | "moment": "^2.29.1", 27 | "next": "^12.0.7", 28 | "prettier": "^2.5.1", 29 | "react": "^17.0.1", 30 | "react-dom": "^17.0.1", 31 | "react-helmet": "^6.1.0", 32 | "react-query": "^3.34.7", 33 | "sharp": "^0.29.3", 34 | "theme-ui": "^0.11.3", 35 | "yup": "^0.32.11" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.15.8", 39 | "@storybook/addon-actions": "^6.3.12", 40 | "@storybook/addon-essentials": "^6.3.12", 41 | "@storybook/addon-links": "^6.3.12", 42 | "@storybook/react": "^6.3.12", 43 | "babel-loader": "^8.2.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/check.png -------------------------------------------------------------------------------- /public/discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/discord.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/favicon.ico -------------------------------------------------------------------------------- /public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/github.png -------------------------------------------------------------------------------- /public/mark-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/mark-grey.png -------------------------------------------------------------------------------- /public/mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/mark.png -------------------------------------------------------------------------------- /public/medusa-svg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/medusa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/public/medusa.png -------------------------------------------------------------------------------- /src/components/code-snippet/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Box, Flex, Text } from "theme-ui" 3 | 4 | const CodeSnippet = ({ children, type, ...rest }) => { 5 | let content = children 6 | 7 | if (typeof children === "string") { 8 | if (type === "cli") { 9 | let lines = children.split("\n") 10 | content = lines.map(l => { 11 | return ( 12 | 13 | 20 | $ 21 | 22 | {l} 23 | 24 | ) 25 | }) 26 | } 27 | } 28 | 29 | return ( 30 | 42 | {content} 43 | 44 | ) 45 | } 46 | 47 | export default CodeSnippet 48 | -------------------------------------------------------------------------------- /src/components/layout/completed-layout.js: -------------------------------------------------------------------------------- 1 | import { Card, Flex } from "@theme-ui/components" 2 | import { useOrder } from "medusa-react" 3 | import { useRouter } from "next/router" 4 | import React from "react" 5 | import OrderConfirmation from "../steps/order-confirmation" 6 | import Layout from "./layout" 7 | 8 | const CompletedLayout = () => { 9 | const router = useRouter() 10 | 11 | const { order } = useOrder(router.query.oid) 12 | 13 | return ( 14 | 15 | {order && ( 16 | 17 | <> 18 | 19 | 29 | 30 | 31 | )} 32 | 33 | ) 34 | } 35 | 36 | export default CompletedLayout 37 | -------------------------------------------------------------------------------- /src/components/layout/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import OuterLayout from "./layout" 3 | 4 | const Layout = ({ children, regions, country, handleRegionChange }) => { 5 | return ( 6 | 11 | {children} 12 | 13 | ) 14 | } 15 | 16 | export default Layout 17 | -------------------------------------------------------------------------------- /src/components/layout/layout.js: -------------------------------------------------------------------------------- 1 | import { Flex, Link, Text } from "@theme-ui/components" 2 | import Image from "next/image" 3 | import React from "react" 4 | import RegionSelector from "../region-selector/region-selector" 5 | 6 | const Layout = ({ children, country, regions, handleRegionChange }) => { 7 | return ( 8 | 15 | 24 | 31 | {children} 32 | 33 | 43 | 51 | 59 | Powered by 60 | 61 | 71 | 72 | 73 | 82 | medusa{" "} 83 | 84 | 85 | 86 | {regions?.length && ( 87 | 92 | )} 93 | 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | export default Layout 101 | -------------------------------------------------------------------------------- /src/components/layout/logo.js: -------------------------------------------------------------------------------- 1 | import { Box, Image } from "@theme-ui/components" 2 | import React from "react" 3 | import logo from "../../icons/logo.svg" 4 | import medusaSmall from "../../icons/medusa-small.svg" 5 | import logoText from "../../icons/logo-name.svg" 6 | 7 | const Logo = () => { 8 | return ( 9 | 10 | 17 | 18 | ) 19 | } 20 | 21 | export const MedusaLogo = () => { 22 | return ( 23 | 24 | 31 | 32 | ) 33 | } 34 | 35 | export const LogoText = () => { 36 | return ( 37 | 38 | 45 | 46 | ) 47 | } 48 | 49 | export default Logo 50 | -------------------------------------------------------------------------------- /src/components/payment/payment-form.js: -------------------------------------------------------------------------------- 1 | import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js" 2 | import { Box, Button, Flex, Text } from "@theme-ui/components" 3 | import { useCart } from "medusa-react" 4 | import React, { useState } from "react" 5 | 6 | const PaymentForm = ({ session, handleSubmit, setLoading }) => { 7 | const [errorMessage, setErrorMessage] = useState() 8 | 9 | const { cart } = useCart() 10 | const stripe = useStripe() 11 | const elements = useElements() 12 | 13 | const handlePayment = async e => { 14 | e.preventDefault() 15 | 16 | setLoading(true) 17 | 18 | if (!stripe || !elements) { 19 | return 20 | } 21 | 22 | const { client_secret } = session.data 23 | const email = cart.email 24 | const address = cart.shipping_address 25 | 26 | return stripe 27 | .confirmCardPayment(client_secret, { 28 | payment_method: { 29 | card: elements.getElement(CardElement), 30 | billing_details: { 31 | name: address.fullName, 32 | email: email, 33 | phone: address.phone, 34 | address: { 35 | city: address.city, 36 | country: address.country, 37 | line1: address.line1, 38 | line2: address.line2, 39 | postal_code: address.postal, 40 | }, 41 | }, 42 | }, 43 | }) 44 | .then(async ({ error, paymentIntent }) => { 45 | if (error) { 46 | setLoading(false) 47 | const pi = error.payment_intent 48 | 49 | if ( 50 | (pi && pi.status === "requires_capture") || 51 | (pi && pi.status === "succeeded") 52 | ) { 53 | return handleSubmit() 54 | } 55 | 56 | setErrorMessage(error.message) 57 | return 58 | } 59 | 60 | if ( 61 | (paymentIntent && paymentIntent.status === "requires_capture") || 62 | paymentIntent.status === "succeeded" 63 | ) { 64 | return handleSubmit() 65 | } 66 | 67 | return 68 | }) 69 | } 70 | 71 | return ( 72 |
73 | {errorMessage && {errorMessage}} 74 | 75 | 76 | 77 | 78 | 79 | 80 |
81 | ) 82 | } 83 | export default PaymentForm 84 | -------------------------------------------------------------------------------- /src/components/payment/payment.js: -------------------------------------------------------------------------------- 1 | import { Elements } from "@stripe/react-stripe-js" 2 | import { loadStripe } from "@stripe/stripe-js" 3 | import { useCart } from "medusa-react" 4 | import React, { useMemo } from "react" 5 | import PaymentForm from "./payment-form" 6 | 7 | const STRIPE_KEY = process.env.NEXT_PUBLIC_STRIPE_API_KEY || "" 8 | const stripePromise = loadStripe(STRIPE_KEY) 9 | 10 | const Payment = ({ handleSubmit, setLoading }) => { 11 | const { cart } = useCart() 12 | 13 | const stripeSession = useMemo(() => { 14 | if (cart.payment_sessions) { 15 | return cart.payment_sessions.find(s => s.provider_id === "stripe") 16 | } 17 | 18 | return null 19 | }, [cart.payment_sessions]) 20 | 21 | if (!stripeSession) { 22 | return null 23 | } 24 | 25 | const options = { 26 | client_secret: stripeSession.data.client_secret, 27 | } 28 | 29 | return ( 30 | 31 | 36 | 37 | ) 38 | } 39 | 40 | export default Payment 41 | -------------------------------------------------------------------------------- /src/components/payment/review.js: -------------------------------------------------------------------------------- 1 | import { Flex, Image, Text } from "@theme-ui/components" 2 | import React, { useMemo } from "react" 3 | 4 | const Review = ({ cart }) => { 5 | const item = useMemo(() => { 6 | return cart.items[0] 7 | }, [cart.items]) 8 | 9 | return ( 10 | 15 | {item.title} 26 | 35 | {item.title} 36 | 43 | 44 | Size: 45 | {item.variant.title} 46 | 47 | 48 | 49 | Quantity: 50 | {item.quantity} 51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | export default Review 58 | -------------------------------------------------------------------------------- /src/components/payment/total.js: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from "@theme-ui/components" 2 | import { formatAmount } from "medusa-react" 3 | import React from "react" 4 | 5 | const Total = ({ cart }) => { 6 | return ( 7 | 17 | 25 | Subtotal 26 | 27 | {formatAmount({ amount: cart.subtotal, region: cart.region })} 28 | 29 | 30 | 38 | Shipping 39 | 40 | {formatAmount({ amount: cart.shipping_total, region: cart.region })} 41 | 42 | 43 | 50 | 51 | Total 52 | 53 | 54 | {formatAmount({ amount: cart.total, region: cart.region })} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export default Total 62 | -------------------------------------------------------------------------------- /src/components/product-selection/index.js: -------------------------------------------------------------------------------- 1 | import { Box, Button, Divider, Flex, Text } from "@theme-ui/components" 2 | import { useCart } from "medusa-react" 3 | import React, { useContext } from "react" 4 | import ProductContext from "../../context/product-context" 5 | import ProductDisplay from "./product-display" 6 | 7 | const ProductSelection = ({ 8 | product, 9 | region, 10 | country, 11 | nextStep, 12 | setLoading, 13 | }) => { 14 | const { variant, quantity } = useContext(ProductContext) 15 | const { createCart, startCheckout } = useCart() 16 | 17 | const handleSubmit = async () => { 18 | setLoading(true) 19 | await createCart.mutateAsync({ 20 | region_id: region.id, 21 | country_code: country, 22 | items: [ 23 | { 24 | variant_id: variant.id, 25 | quantity, 26 | }, 27 | ], 28 | }) 29 | 30 | await startCheckout.mutateAsync() 31 | setLoading(false) 32 | 33 | nextStep() 34 | } 35 | 36 | return ( 37 | 38 | Product 39 | 40 | 41 | 42 | 43 | 46 | 47 | ) 48 | } 49 | 50 | export default ProductSelection 51 | -------------------------------------------------------------------------------- /src/components/product-selection/product-display/index.js: -------------------------------------------------------------------------------- 1 | import { Flex, Image, Text } from "@theme-ui/components" 2 | import React from "react" 3 | import Info from "./info" 4 | import OptionSelector from "./option-selector" 5 | 6 | const ProductDisplay = ({ region, product }) => { 7 | return product ? ( 8 | 9 | 10 | {product.title} 20 | 21 | 22 | 32 | {product.description} 33 | 34 | 35 | 36 | ) : null 37 | } 38 | 39 | export default ProductDisplay 40 | -------------------------------------------------------------------------------- /src/components/product-selection/product-display/info.js: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from "@theme-ui/components" 2 | import { formatVariantPrice } from "medusa-react" 3 | import React from "react" 4 | 5 | const Info = ({ product, region }) => { 6 | return ( 7 | 16 | 21 | 29 | {product?.collection?.title || ""} 30 | 31 | 38 | {product.title} 39 | 40 | 47 | {`${formatVariantPrice({ 48 | variant: product.variants[0], 49 | region, 50 | })}`} 51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | export default Info 58 | -------------------------------------------------------------------------------- /src/components/product-selection/product-display/option-selector.js: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Select, Text } from "@theme-ui/components" 2 | import React, { useContext, useEffect, useMemo, useState } from "react" 3 | import ProductContext from "../../../context/product-context" 4 | 5 | const OptionSelector = ({ product }) => { 6 | const { quantity, updateQuantity, selectVariant } = useContext(ProductContext) 7 | const [options, setOptions] = useState([]) 8 | const [selection, setSelection] = useState(JSON.stringify({})) 9 | 10 | useEffect(() => { 11 | const opts = [] 12 | for (const option of product.options) { 13 | const opt = { 14 | title: option.title, 15 | id: option.id, 16 | values: [...new Set(option.values.map(v => v.value))], 17 | } 18 | opts.push(opt) 19 | } 20 | setOptions(opts) 21 | 22 | const select = {} 23 | for (const opt of opts) { 24 | select[opt.id] = opt.values[0] 25 | } 26 | setSelection(JSON.stringify(select)) 27 | }, [product]) 28 | 29 | const handleQuantity = e => { 30 | const quant = JSON.parse(e.target.value) 31 | updateQuantity(quant) 32 | } 33 | 34 | const handleSelect = e => { 35 | const pair = JSON.parse(e.target.value) 36 | const tmp = JSON.parse(selection) 37 | tmp[pair.option] = pair.value 38 | setSelection(JSON.stringify(tmp)) 39 | } 40 | 41 | const createVariantSet = (options, variants) => { 42 | const set = [] 43 | for (const variant of variants) { 44 | const optionSet = {} 45 | for (const option of variant.options) { 46 | const { id } = options.find(o => o.id === option.option_id) 47 | optionSet[id] = option.value 48 | } 49 | optionSet["variant"] = variant 50 | set.push(optionSet) 51 | } 52 | return set 53 | } 54 | 55 | const variantSet = useMemo(() => { 56 | if (product?.options && product?.variants) { 57 | return createVariantSet(product.options, product.variants) 58 | } else { 59 | return [] 60 | } 61 | }, [product]) 62 | 63 | useEffect(() => { 64 | const select = JSON.parse(selection) 65 | for (const variant of variantSet) { 66 | const keys = Object.keys(variant).filter(k => k !== "variant") 67 | let count = 0 68 | for (const key of keys) { 69 | count = select[key] === variant[key] ? count + 1 : 0 70 | } 71 | 72 | if (count === keys.length) { 73 | selectVariant(variant.variant) 74 | } 75 | } 76 | }, [selection]) 77 | 78 | return ( 79 | 84 | {options.map((o, i) => { 85 | return ( 86 | 95 | {o.title} 96 | 136 | 137 | ) 138 | })} 139 | 147 | Quantity 148 | 186 | 187 | 188 | ) 189 | } 190 | 191 | export default OptionSelector 192 | -------------------------------------------------------------------------------- /src/components/region-selector/region-selector/index.js: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Select, Text } from "@theme-ui/components" 2 | import { useRouter } from "next/router" 3 | import React from "react" 4 | 5 | const RegionSelector = ({ selected, regions, handleRegionChange }) => { 6 | const router = useRouter() 7 | 8 | const handleChange = e => { 9 | const countryCode = e.currentTarget.value 10 | const reg = regions.find(r => { 11 | return r.countries.some(c => c.iso_2 === countryCode) 12 | }) 13 | 14 | handleRegionChange(reg.id, countryCode) 15 | router.push(`/${router.query.handle}`) 16 | } 17 | 18 | return ( 19 | 25 | 32 | Country: 33 | 34 | 77 | 78 | ) 79 | } 80 | 81 | export default RegionSelector 82 | -------------------------------------------------------------------------------- /src/components/seo/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Helmet from "react-helmet" 3 | 4 | const SEO = ({ title }) => { 5 | return ( 6 | 22 | ) 23 | } 24 | 25 | export default SEO 26 | -------------------------------------------------------------------------------- /src/components/shipping/forms/contact.js: -------------------------------------------------------------------------------- 1 | import { Box, Text } from "@theme-ui/components" 2 | import React from "react" 3 | import Field from "./field" 4 | import FieldSplitter from "./field-splitter" 5 | 6 | const Contact = ({ formik, summarize = false }) => { 7 | return ( 8 | 9 | 16 | Contact 17 | 18 | 28 | } 29 | right={ 30 | 37 | } 38 | /> 39 | 46 | 53 | 54 | ) 55 | } 56 | 57 | export default Contact 58 | -------------------------------------------------------------------------------- /src/components/shipping/forms/delivery.js: -------------------------------------------------------------------------------- 1 | import { Box, Text } from "@theme-ui/components" 2 | import React, { useEffect, useState } from "react" 3 | import Field from "./field" 4 | import FieldSplitter from "./field-splitter" 5 | import SelectShipping from "./select-shipping" 6 | 7 | const Delivery = ({ formik, region, country, setLoading }) => { 8 | const [fullCountry, setFullCountry] = useState("") 9 | useEffect(() => { 10 | formik.setFieldValue("delivery.country_code", country) 11 | }, [country]) 12 | 13 | useEffect(() => { 14 | setFullCountry(region.countries.find(c => c.iso_2 === country).display_name) 15 | }, [country, region]) 16 | 17 | return ( 18 | 19 | 26 | Delivery address 27 | 28 | <> 29 | 36 | 45 | } 46 | right={ 47 | 54 | } 55 | /> 56 | 57 | 58 | Shipping method 59 | 60 | 69 | 70 | 71 | ) 72 | } 73 | 74 | export default Delivery 75 | -------------------------------------------------------------------------------- /src/components/shipping/forms/field-splitter.js: -------------------------------------------------------------------------------- 1 | import { Flex, Box } from "@theme-ui/components" 2 | import React from "react" 3 | 4 | const FieldSplitter = ({ left, right }) => { 5 | return ( 6 | 12 | 18 | {left} 19 | 20 | 26 | {right} 27 | 28 | 29 | ) 30 | } 31 | 32 | export default FieldSplitter 33 | -------------------------------------------------------------------------------- /src/components/shipping/forms/field.js: -------------------------------------------------------------------------------- 1 | import { Flex, Input } from "@theme-ui/components" 2 | import React, { useEffect, useState } from "react" 3 | 4 | const Field = ({ formik, value, name, set, placeholder, disabled }) => { 5 | const [error, setError] = useState(false) 6 | 7 | useEffect(() => { 8 | if (formik.errors[set]?.[name] && formik.touched[set]?.[name]) { 9 | setError(true) 10 | } else { 11 | setError(false) 12 | } 13 | }, [formik.errors, formik.touched, set, name]) 14 | return ( 15 | 22 | 37 | 38 | ) 39 | } 40 | 41 | export default Field 42 | -------------------------------------------------------------------------------- /src/components/shipping/forms/index.js: -------------------------------------------------------------------------------- 1 | import { Box, Button, Divider, Text } from "@theme-ui/components" 2 | import { useFormik } from "formik" 3 | import { useCart } from "medusa-react" 4 | import React, { useState } from "react" 5 | import * as Yup from "yup" 6 | import Contact from "./contact" 7 | import Delivery from "./delivery" 8 | 9 | const Forms = ({ country, region, nextStep, setLoading }) => { 10 | const { updateCart, addShippingMethod, cart } = useCart() 11 | 12 | const [isValid, setIsValid] = useState({ 13 | contact: false, 14 | delivery: false, 15 | }) 16 | 17 | const handleSubmit = e => { 18 | e.preventDefault() 19 | formik.submitForm() 20 | } 21 | 22 | const formik = useFormik({ 23 | initialValues: { 24 | contact: { 25 | first_name: cart?.shipping_address?.first_name || "", 26 | last_name: cart?.shipping_address?.last_name || "", 27 | email: cart?.email || "", 28 | phone: cart?.shipping_address?.phone || "", 29 | }, 30 | delivery: { 31 | address_1: cart?.shipping_address?.address_1 || "", 32 | postal_code: cart?.shipping_address?.postal_code || "", 33 | city: cart?.shipping_address?.city || "", 34 | country_code: cart?.shipping_address?.country_code || "", 35 | shipping_option: cart?.shipping_methods?.[0]?.shipping_option_id || "", 36 | }, 37 | }, 38 | validationSchema: Yup.object({ 39 | contact: Yup.object({ 40 | first_name: Yup.string().required("Required"), 41 | last_name: Yup.string().required("Required"), 42 | email: Yup.string() 43 | .email("Please provide a valid email address") 44 | .required("Required"), 45 | phone: Yup.string().optional(), 46 | }), 47 | delivery: Yup.object({ 48 | address_1: Yup.string().required("Required"), 49 | postal_code: Yup.string().required("Required"), 50 | city: Yup.string().required("Required"), 51 | country_code: Yup.string().required("Required"), 52 | shipping_option: Yup.string().required("Required"), 53 | }), 54 | }), 55 | onSubmit: async values => { 56 | setLoading(true) 57 | setIsValid({ delivery: true, contact: true }) 58 | 59 | const { delivery, contact } = values 60 | 61 | return updateCart 62 | .mutateAsync({ 63 | email: contact.email, 64 | shipping_address: { 65 | first_name: contact.first_name, 66 | last_name: contact.last_name, 67 | address_1: delivery.address_1, 68 | country_code: delivery.country_code, 69 | postal_code: delivery.postal_code, 70 | province: delivery.province, 71 | city: delivery.city, 72 | phone: contact.phone, 73 | }, 74 | }) 75 | .then(() => { 76 | return addShippingMethod.mutateAsync({ 77 | option_id: delivery.shipping_option, 78 | }) 79 | }) 80 | .finally(() => { 81 | setLoading(false) 82 | nextStep() 83 | }) 84 | }, 85 | }) 86 | 87 | return ( 88 | 89 | Shipping and info 90 | 91 | 92 | 93 | 94 | 95 | 104 | 105 | 106 | 107 | <> 108 | 109 | 112 | 113 | 114 | 115 | ) 116 | } 117 | 118 | export default Forms 119 | -------------------------------------------------------------------------------- /src/components/shipping/forms/select-shipping.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | import { Box, Flex, Text } from "@theme-ui/components" 3 | import { formatAmount, useCart, useCartShippingOptions } from "medusa-react" 4 | 5 | const ShippingOption = ({ selected, option, region, onClick }) => { 6 | return ( 7 | 24 | {option && region && ( 25 | <> 26 | 27 | 38 | {selected && ( 39 | 47 | )} 48 | 49 | {option.name} 50 | 51 | 52 | {formatAmount({ 53 | amount: option.amount, 54 | region, 55 | })} 56 | 57 | 58 | )} 59 | 60 | ) 61 | } 62 | 63 | const SelectShipping = ({ formik, name, set, region }) => { 64 | const { cart } = useCart() 65 | const { shipping_options } = useCartShippingOptions(cart.id) 66 | 67 | const handleClick = async id => { 68 | const alreadySelected = cart?.shipping_methods?.some( 69 | so => id === so.shipping_option_id 70 | ) 71 | 72 | if (alreadySelected) { 73 | return 74 | } 75 | 76 | formik.setFieldValue(`${set}.${name}`, id) 77 | } 78 | 79 | useEffect(() => { 80 | if (shipping_options?.length) { 81 | handleClick(shipping_options[0].id) 82 | } 83 | }, [shipping_options]) 84 | 85 | return ( 86 | 87 | {shipping_options?.map(s => ( 88 | handleClick(s.id)} 91 | selected={s.id === formik.values[set][name]} 92 | option={s} 93 | region={region} 94 | /> 95 | ))} 96 | 97 | ) 98 | } 99 | 100 | export default SelectShipping 101 | -------------------------------------------------------------------------------- /src/components/shipping/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { Flex } from "@theme-ui/components" 3 | import Spinner from "../spinner/spinner" 4 | import Forms from "./forms" 5 | 6 | const ShippingAndInfo = ({ country, region, nextStep }) => { 7 | const [loading, setLoading] = useState(false) 8 | 9 | return ( 10 | 17 | {loading && ( 18 | 31 | 32 | 33 | )} 34 | 40 | 41 | ) 42 | } 43 | 44 | export default ShippingAndInfo 45 | -------------------------------------------------------------------------------- /src/components/spinner/spinner.js: -------------------------------------------------------------------------------- 1 | import { Box } from "@theme-ui/components" 2 | import React from "react" 3 | 4 | const Spinner = ({ done = false }) => { 5 | if (done) { 6 | return ( 7 | 13 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | const inside = { 33 | boxSizing: "border-box", 34 | display: "block", 35 | position: "absolute", 36 | width: "64px", 37 | height: "64px", 38 | margin: "8px", 39 | border: "3px solid #454545", 40 | borderRadius: "50%", 41 | animation: "lds-ring 2.2s cubic-bezier(0.5, 0, 0.5, 1) infinite", 42 | borderColor: "#454545 transparent transparent transparent", 43 | ":nth-of-type(1)": { 44 | animationDelay: "-0.45s", 45 | }, 46 | ":nth-of-type(2)": { 47 | animationDelay: "-0.3s", 48 | }, 49 | ":nth-of-type(3)": { 50 | animationDelay: "-0.15s", 51 | }, 52 | "@keyframes lds-ring": { 53 | from: { 54 | transform: "rotate(0deg)", 55 | }, 56 | to: { 57 | transform: "rotate(360deg)", 58 | }, 59 | }, 60 | } 61 | 62 | return ( 63 | 71 | 72 | 73 | 74 | 75 | ) 76 | } 77 | 78 | export default Spinner 79 | -------------------------------------------------------------------------------- /src/components/steps/index.js: -------------------------------------------------------------------------------- 1 | import { Flex } from "@theme-ui/components" 2 | import React, { useEffect, useState } from "react" 3 | import Payment from "./payment" 4 | import Product from "./product" 5 | import Shipping from "./shipping" 6 | 7 | const Steps = ({ product, regions, country, region }) => { 8 | const [activeStep, setActiveStep] = useState("product") 9 | 10 | // When region change, we reset the checkout flow 11 | useEffect(() => { 12 | setActiveStep("product") 13 | }, [region]) 14 | 15 | return ( 16 | 17 | 24 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export default Steps 36 | -------------------------------------------------------------------------------- /src/components/steps/order-confirmation.js: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from "@theme-ui/components" 2 | import moment from "moment" 3 | import React from "react" 4 | 5 | const OrderConfirmation = ({ order }) => { 6 | const customerName = 7 | !order.customer.first_name || !order.customer.last_name 8 | ? `${order.shipping_address.first_name} ${order.shipping_address.last_name}` 9 | : `${order.customer.first_name} ${order.customer.last_name}` 10 | 11 | return ( 12 | 17 | 27 | 28 | Thank you, {customerName}!
29 | We take care of it. 30 |
31 | 32 | Your order has been placed. We are working to get it settled. Check 33 | your email for order confirmation. 34 | 35 |
36 | 37 | 43 | 44 | 45 | Order number: 46 | 47 | 48 | {order.display_id} 49 | 50 | 51 | 52 | 53 | Date: 54 | 55 | 56 | {moment(order.created_at).format("LLLL")} 57 | 58 | 59 | 60 | 61 | We have sent an order confirmation to{" "} 62 | 63 | {order.email} 64 | 65 | 66 | 67 | 68 | 69 | Read confirmation in browser 70 | 71 | 72 | 73 | 74 |
75 | ) 76 | } 77 | 78 | export default OrderConfirmation 79 | -------------------------------------------------------------------------------- /src/components/steps/payment.js: -------------------------------------------------------------------------------- 1 | import { Box, Card, Flex, Text } from "@theme-ui/components" 2 | import { useCart } from "medusa-react" 3 | import { useRouter } from "next/router" 4 | import React, { useEffect, useState } from "react" 5 | import PaymentDetails from "../payment/payment" 6 | import Review from "../payment/review" 7 | import Total from "../payment/total" 8 | import Spinner from "../spinner/spinner" 9 | 10 | const DeliveryReview = ({ delivery, displayCountry }) => ( 11 | 19 | 20 | Delivery 21 | 22 | {delivery.address_1} 23 | {`${delivery.postal_code}, ${delivery.city}`} 24 | {displayCountry} 25 | 26 | ) 27 | 28 | const Payment = ({ region, country, activeStep }) => { 29 | const [loading, setLoading] = useState(false) 30 | const router = useRouter() 31 | const { cart, pay, completeCheckout } = useCart() 32 | 33 | const [fullCountry, setFullCountry] = useState("") 34 | 35 | const submitPayment = async () => { 36 | // set Stripe as payment provider and navigate to confirmation page to complete order 37 | pay.mutate( 38 | { provider_id: "stripe" }, 39 | { onSuccess: () => router.push(`/completing?cid=${cart.id}`) } 40 | ) 41 | } 42 | 43 | useEffect(() => { 44 | if (activeStep === "payment") { 45 | setFullCountry( 46 | region.countries.find(c => c.iso_2 === country).display_name 47 | ) 48 | } 49 | }, [country, region, activeStep]) 50 | 51 | return ( 52 | 53 | {activeStep === "payment" ? ( 54 | 55 | 62 | {(pay.isLoading || loading) && ( 63 | 76 | 77 | 78 | )} 79 | Payment 80 | 81 | 82 | 86 | 92 | 93 | Payment method 94 | 95 | 99 | 100 | 101 | 102 | 103 | ) : ( 104 | Payment 105 | )} 106 | 107 | ) 108 | } 109 | 110 | export default Payment 111 | -------------------------------------------------------------------------------- /src/components/steps/product.js: -------------------------------------------------------------------------------- 1 | import { Card, Flex } from "@theme-ui/components" 2 | import { useCart } from "medusa-react" 3 | import Image from "next/image" 4 | import React, { useState } from "react" 5 | import ProductSelection from "../product-selection" 6 | import Spinner from "../spinner/spinner" 7 | 8 | const Product = ({ 9 | product, 10 | regions, 11 | country, 12 | region, 13 | activeStep, 14 | setActiveStep, 15 | }) => { 16 | const [loading, setLoading] = useState(false) 17 | const { cart } = useCart() 18 | 19 | let triggerStyles = {} 20 | 21 | if (cart?.id) { 22 | triggerStyles.color = "darkgrey" 23 | triggerStyles.cursor = "pointer" 24 | } 25 | 26 | return ( 27 | 28 | {loading && ( 29 | 42 | 43 | 44 | )} 45 | {activeStep === "product" ? ( 46 | 47 | setActiveStep("shipping")} 53 | setLoading={setLoading} 54 | /> 55 | 56 | ) : ( 57 | setActiveStep("product")} 60 | sx={triggerStyles} 61 | > 62 | Product 63 | {cart?.id && ( 64 | 65 | )} 66 | 67 | )} 68 | 69 | ) 70 | } 71 | 72 | export default Product 73 | -------------------------------------------------------------------------------- /src/components/steps/shipping.js: -------------------------------------------------------------------------------- 1 | import { Card, Flex } from "@theme-ui/components" 2 | import { useCart } from "medusa-react" 3 | import Image from "next/image" 4 | import React from "react" 5 | import ShippingAndInfo from "../shipping" 6 | 7 | const Shipping = ({ 8 | region, 9 | country, 10 | activeStep, 11 | setActiveStep, 12 | setLoading, 13 | }) => { 14 | const { cart } = useCart() 15 | 16 | const hasShipping = cart.shipping_address && cart?.shipping_methods?.length 17 | 18 | let triggerStyles = {} 19 | 20 | if (hasShipping) { 21 | triggerStyles.color = "darkgrey" 22 | } 23 | 24 | // Cart not initialized yet 25 | if (!cart?.items?.length) { 26 | triggerStyles.pointerEvents = "none" 27 | } else { 28 | triggerStyles.cursor = "pointer" 29 | } 30 | 31 | return ( 32 | 33 | {activeStep === "shipping" ? ( 34 | 35 | setActiveStep("payment")} 40 | /> 41 | 42 | ) : ( 43 | setActiveStep("shipping")} 46 | sx={triggerStyles} 47 | > 48 | Shipping and info 49 | {hasShipping && ( 50 | 51 | )} 52 | 53 | )} 54 | 55 | ) 56 | } 57 | 58 | export default Shipping 59 | -------------------------------------------------------------------------------- /src/context/product-context.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from "react" 2 | 3 | const actions = { 4 | SET_VARIANT: "SET_VARIANT", 5 | UPDATE_QUANTITY: "UPDATE_QUANTITY", 6 | } 7 | 8 | export const defaultProductContext = { 9 | variantId: null, 10 | quantity: 1, 11 | selectVariant: _ => {}, 12 | updateQuantity: () => {}, 13 | dispatch: () => {}, 14 | } 15 | 16 | const ProductContext = React.createContext(defaultProductContext) 17 | export default ProductContext 18 | 19 | const reducer = (state, action) => { 20 | switch (action.type) { 21 | case actions.UPDATE_QUANTITY: 22 | return { 23 | ...state, 24 | quantity: action.payload, 25 | } 26 | case actions.SET_VARIANT: 27 | return { 28 | ...state, 29 | variant: action.payload, 30 | } 31 | default: 32 | break 33 | } 34 | } 35 | 36 | export const ProductProvider = ({ children }) => { 37 | const [state, dispatch] = useReducer(reducer, defaultProductContext) 38 | 39 | const selectVariant = id => { 40 | dispatch({ type: actions.SET_VARIANT, payload: id }) 41 | } 42 | 43 | const updateQuantity = quantity => { 44 | dispatch({ type: actions.UPDATE_QUANTITY, payload: quantity }) 45 | } 46 | 47 | return ( 48 | 56 | {children} 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/src/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /src/fonts/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medusajs/medusa-express-nextjs/7939a8d6b6e677f25ba592b86935d281c2ef1cc3/src/fonts/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /src/fonts/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Inter"; 3 | src: url("./Inter-Regular.ttf") format("truetype"); 4 | font-weight: normal; 5 | font-style: normal; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: "Inter"; 11 | src: url("./Inter-SemiBold.ttf") format("truetype"); 12 | font-weight: 600; 13 | font-style: normal; 14 | font-display: swap; 15 | } 16 | -------------------------------------------------------------------------------- /src/icons/logo-name.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/icons/medusa-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/[handle].js: -------------------------------------------------------------------------------- 1 | import Head from "next/head" 2 | import React, { useState } from "react" 3 | import Layout from "../components/layout" 4 | import Steps from "../components/steps" 5 | import { client } from "../utils/client" 6 | 7 | const ProductPage = ({ product, regions }) => { 8 | const [region, setRegion] = useState(regions?.[0] || null) 9 | const [country, setCountry] = useState(region?.countries?.[0].iso_2 || "") 10 | 11 | const handleRegionChange = (regId, countryCode) => { 12 | const selected = regions.find(r => r.id === regId) 13 | setCountry(countryCode) 14 | setRegion(selected) 15 | } 16 | 17 | return ( 18 | <> 19 | 24 | 25 | Medusa Express - {product.title} 26 | 27 | 28 | 34 | 35 | 36 | ) 37 | } 38 | 39 | export async function getStaticPaths() { 40 | const { products } = await client.products.list() 41 | 42 | const paths = products 43 | .map(product => ({ 44 | params: { handle: product.handle }, 45 | })) 46 | .filter(p => !!p.params.handle) 47 | 48 | return { paths, fallback: false } 49 | } 50 | 51 | export async function getStaticProps({ params }) { 52 | const response = await client.products.list({ handle: params.handle }) 53 | const { regions } = await client.regions.list() 54 | 55 | // handles are unique, so we'll always only be fetching a single product 56 | const [product] = response.products 57 | 58 | // Pass post data to the page via props 59 | return { props: { product, regions } } 60 | } 61 | 62 | export default ProductPage 63 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import { CartProvider, MedusaProvider } from "medusa-react" 2 | import Head from "next/head" 3 | import React from "react" 4 | import { QueryClient } from "react-query" 5 | import { ThemeProvider } from "theme-ui" 6 | import { ProductProvider } from "../context/product-context" 7 | import "../fonts/index.css" 8 | import theme from "../theme" 9 | 10 | const BACKEND_URL = 11 | process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || "http://localhost:9000" 12 | 13 | // Your react-query's query client config 14 | const queryClient = new QueryClient({ 15 | defaultOptions: { 16 | queries: { 17 | refetchOnWindowFocus: false, 18 | staleTime: 30000, 19 | retry: 1, 20 | }, 21 | }, 22 | }) 23 | 24 | const App = ({ Component, pageProps }) => { 25 | return ( 26 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | export default App 45 | -------------------------------------------------------------------------------- /src/pages/completed.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head" 2 | import React from "react" 3 | import CompletedLayout from "../components/layout/completed-layout" 4 | 5 | const CompletedPage = () => { 6 | return ( 7 | <> 8 | 9 | Order completed! 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default CompletedPage 18 | -------------------------------------------------------------------------------- /src/pages/completing.js: -------------------------------------------------------------------------------- 1 | import { useCompleteCart } from "medusa-react" 2 | import Head from "next/head" 3 | import { useRouter } from "next/router" 4 | import React, { useEffect } from "react" 5 | import { Card, Flex } from "theme-ui" 6 | import Layout from "../components/layout/layout" 7 | import Spinner from "../components/spinner/spinner" 8 | 9 | const Completing = () => { 10 | const router = useRouter() 11 | 12 | const completeCartMutation = useCompleteCart(router.query.cid) 13 | 14 | const completeCart = async () => { 15 | const { data, type } = await completeCartMutation.mutateAsync() 16 | 17 | if (type === "order") { 18 | router.push(`/completed?oid=${data.id}`) 19 | } else { 20 | router.push(`/`) 21 | } 22 | } 23 | 24 | useEffect(() => { 25 | completeCart() 26 | }, []) 27 | 28 | return ( 29 | 30 | 31 | Submitting order... 32 | 33 | 34 | 35 | 42 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | 63 | export default Completing 64 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head" 2 | import Image from "next/image" 3 | import { useRouter } from "next/router" 4 | import * as React from "react" 5 | import { Button, Card, Flex, Text } from "theme-ui" 6 | import CodeSnippet from "../components/code-snippet" 7 | import Layout from "../components/layout/layout" 8 | import { client } from "../utils/client" 9 | 10 | const IndexPage = ({ product }) => { 11 | const router = useRouter() 12 | 13 | return ( 14 |
15 | 16 | Medusa Express 17 | 18 | 19 | 20 | 21 | 22 | 33 | 34 | 35 | 36 | Welcome! 37 | 38 | 39 | Medusa Express is a drop-in storefront for your{" "} 40 | 41 | Medusa 42 | {" "} 43 | store, that automatically creates pages for the products in your 44 | catalog, each of them optimized to make the purchasing experience 45 | as frictionless as possible, by bundling the checkout flow 46 | alongside the product. 47 | 48 | 57 | 66 | Read more 67 | 68 | 75 | 79 | Get your own in only a couple of minutes 80 | 81 | 82 | 83 | npx create-medusa-app@latest 84 | 85 | 86 | 87 | 94 | 98 | Built with: 99 | 100 | 106 | Medusa: Commerce engine 107 | 108 | 114 | Next.js: React framework 115 | 116 | 122 | Stripe: Payment provider 123 | 124 | 130 | Medusa React: Hooks for Medusa 131 | 132 | 133 | 134 | 141 | 142 | 143 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 |
159 | ) 160 | } 161 | 162 | export async function getStaticProps({ params }) { 163 | const response = await client.products.list({ limit: 1 }) 164 | 165 | const [product, ...rest] = response.products 166 | 167 | return { props: { product } } 168 | } 169 | 170 | export default IndexPage 171 | -------------------------------------------------------------------------------- /src/theme/index.js: -------------------------------------------------------------------------------- 1 | const theme = { 2 | fonts: { 3 | body: "Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Ubuntu, sans-serif", 4 | heading: 5 | "Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Ubuntu, sans-serif", 6 | }, 7 | fontWeights: { 8 | body: 400, 9 | heading: 700, 10 | }, 11 | breakpoints: ["720px"], 12 | colors: { 13 | primary: "#111827", 14 | medusaGreen: "#56FBB1", 15 | medusa100: "#454B54", 16 | deepBlue: "#0A3149", 17 | ui: "#F7F7FA", 18 | cool: "#EEF0F5", 19 | background: "#F7F7FA", 20 | salmon: "#FF9B9B", 21 | placeholder: "#BBBBBB", 22 | grey: "#E5E7EB", 23 | darkGrey: "#6B7280", 24 | }, 25 | layout: { 26 | stepContainer: { 27 | width: "100%", 28 | height: "100%", 29 | mb: "8px", 30 | }, 31 | }, 32 | cards: { 33 | accordionTrigger: { 34 | bg: "white", 35 | display: "flex", 36 | justifyContent: "space-between", 37 | alignItems: "center", 38 | width: "375px", 39 | borderRadius: "8px", 40 | transition: "all .2s linear", 41 | fontFamily: "Inter", 42 | fontWeight: "600", 43 | px: "24px", 44 | py: "16px", 45 | }, 46 | container: { 47 | bg: "white", 48 | width: "375px", 49 | px: "24px", 50 | py: "16px", 51 | height: "auto", 52 | borderRadius: "8px", 53 | justifyContent: "center", 54 | transition: "all .2s linear", 55 | }, 56 | }, 57 | buttons: { 58 | cta: { 59 | bg: "primary", 60 | color: "white", 61 | fontWeight: "500", 62 | fontSize: "14px", 63 | height: "40px", 64 | display: "flex", 65 | alignItems: "center", 66 | justifyContent: "center", 67 | width: "100%", 68 | cursor: "pointer", 69 | "&:disabled": { 70 | opacity: 0.5, 71 | cursor: "default", 72 | }, 73 | }, 74 | incrementor: { 75 | bg: "transparent", 76 | color: "primary", 77 | flexGrow: "1", 78 | height: "33px", 79 | border: "none", 80 | borderRadius: "0 4px 4px 0", 81 | "&:hover": { 82 | bg: "ui", 83 | }, 84 | }, 85 | decrementor: { 86 | bg: "transparent", 87 | color: "primary", 88 | flexGrow: "1", 89 | height: "33px", 90 | border: "none", 91 | borderRadius: "4px 0 0 4px", 92 | "&:hover": { 93 | bg: "ui", 94 | }, 95 | }, 96 | edit: { 97 | bg: "transparent", 98 | color: "primary", 99 | cursor: "pointer", 100 | fontSize: "14px", 101 | textDecoration: "underline", 102 | padding: "0", 103 | }, 104 | }, 105 | box: { 106 | paymentField: { 107 | bg: "background", 108 | padding: "12px", 109 | fontSize: "1.1em", 110 | borderRadius: "5px", 111 | marginBottom: "20px", 112 | }, 113 | }, 114 | text: { 115 | fz_s: { 116 | fontSize: "10px", 117 | }, 118 | header3: { 119 | fontSize: "16px", 120 | fontWeight: "600", 121 | }, 122 | summary: { 123 | py: ".1em", 124 | fontSize: "12px", 125 | color: "darkGrey", 126 | fontFamily: "Inter", 127 | fontWeight: 300, 128 | }, 129 | landingpageText: { 130 | py: ".1em", 131 | fontSize: "14px", 132 | lineHeight: "24px", 133 | color: "#111827", 134 | fontFamily: "Inter", 135 | mb: "8px", 136 | fontWeight: 300, 137 | "& a": { 138 | fontWeight: 500, 139 | textDecoration: "none", 140 | color: "#3B82F6", 141 | "&:hover": { 142 | color: "primary", 143 | }, 144 | }, 145 | }, 146 | landingpageLink: { 147 | fontSize: "14px", 148 | lineHeight: "24px", 149 | color: "#3B82F6", 150 | fontFamily: "Inter", 151 | mb: "4px", 152 | fontWeight: 500, 153 | textDecoration: "none", 154 | "&:hover": { 155 | color: "primary", 156 | }, 157 | }, 158 | termsLink: { 159 | textDecoration: "none", 160 | color: "medusa100", 161 | }, 162 | confirmationHeading: { 163 | lineHeight: "1.8em", 164 | }, 165 | confirmationText: { 166 | fontSize: "0.8em", 167 | lineHeight: "1.5em", 168 | fontWeight: "300", 169 | }, 170 | subheading: { 171 | fontSize: "12px", 172 | fontWeight: 600, 173 | color: "black", 174 | }, 175 | }, 176 | 177 | forms: { 178 | select: { 179 | bg: "cool", 180 | border: "none", 181 | }, 182 | input: { 183 | bg: "cool", 184 | border: "none", 185 | }, 186 | field: { 187 | border: "1px solid grey", 188 | "::placeholder": { 189 | color: "darkGrey", 190 | }, 191 | ":-ms-input-placeholder": { 192 | color: "darkGrey", 193 | }, 194 | "::-ms-input-placeholder": { 195 | color: "darkGrey", 196 | }, 197 | outline: "none", 198 | transition: "all .2s linear", 199 | }, 200 | }, 201 | styles: { 202 | root: { 203 | fontFamily: "body", 204 | fontWeight: "body", 205 | background: "ui", 206 | }, 207 | }, 208 | } 209 | 210 | export default theme 211 | -------------------------------------------------------------------------------- /src/utils/client.js: -------------------------------------------------------------------------------- 1 | const Medusa = require("@medusajs/medusa-js").default 2 | 3 | const BACKEND_URL = 4 | process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || "http://localhost:9000" 5 | 6 | const client = new Medusa({ baseUrl: BACKEND_URL }) 7 | 8 | module.exports.client = client 9 | -------------------------------------------------------------------------------- /src/utils/get-from.js: -------------------------------------------------------------------------------- 1 | export const getFrom = ( 2 | variants = [], 3 | { currency_code = "eur", tax_rate = 0 } 4 | ) => { 5 | const prices = [] 6 | 7 | for (const variant of variants) { 8 | const price = variant.prices.find(p => p.currency_code === currency_code) 9 | if (price) { 10 | prices.push(price.amount) 11 | } 12 | } 13 | const min = Math.min(...prices) 14 | 15 | return `${(min / 100) * (1 + tax_rate / 100)} ${currency_code.toUpperCase()}` 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/is-equal.js: -------------------------------------------------------------------------------- 1 | export const objectsEqual = (o1, o2) => 2 | Object.keys(o1).length === Object.keys(o2).length && 3 | Object.keys(o1).every(p => o1[p] === o2[p]) 4 | -------------------------------------------------------------------------------- /src/utils/validator.js: -------------------------------------------------------------------------------- 1 | import * as Yup from "yup" 2 | 3 | export const AddressSchema = Yup.object({ 4 | first_name: Yup.string().required("Required"), 5 | last_name: Yup.string().required("Required"), 6 | company: Yup.string().optional(), 7 | address_1: Yup.string().required("Required"), 8 | address_2: Yup.string().optional(), 9 | postal_code: Yup.number().required("Required"), 10 | city: Yup.string().required("Required"), 11 | country_code: Yup.string().required("Required"), 12 | }) 13 | 14 | export const ContactSchema = Yup.object({ 15 | email: Yup.string().email("Not a valid email").required("Required"), 16 | }) 17 | 18 | export const ShippingSchema = Yup.object({ 19 | option_id: Yup.string().required("You must select a shipping option"), 20 | }) 21 | 22 | export const Validator = Yup.object({ 23 | contact: ContactSchema, 24 | address: AddressSchema, 25 | shipping: ShippingSchema, 26 | }) 27 | 28 | export const DiscountSchema = Yup.string().required("Required") 29 | --------------------------------------------------------------------------------