├── .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 |
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 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 | [](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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/src/icons/medusa-small.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------