├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── components ├── Button.js ├── CartLink.js ├── Image.js ├── Inventory.js ├── ListItem.js ├── QuantityPicker.js ├── ViewInventory.js ├── formComponents │ ├── AddInventory.js │ ├── ConfirmSignUp.js │ ├── SignIn.js │ └── SignUp.js ├── heroComponents │ ├── Center.js │ ├── DisplayMedium.js │ ├── DisplaySmall.js │ ├── Footer.js │ ├── Showcase.js │ ├── Spacer.js │ └── Tag.js └── index.js ├── context └── mainContext.js ├── ecommerce.config.js ├── example-images ├── 1.png ├── 2.png ├── 3.png ├── 4.png └── 5.png ├── layouts └── layout.js ├── package.json ├── pages ├── _app.js ├── admin.js ├── cart.js ├── categories.js ├── category │ └── [name].js ├── checkout.js ├── index.js └── product │ └── [name].js ├── postcss.config.js ├── public ├── favicon.ico ├── fonts │ ├── Eina-Light.otf │ ├── Eina-SemiBold.otf │ ├── Eina-SemiBold.ttf │ ├── Eina.otf │ ├── Eina.ttf │ └── fonts.css ├── logo.png └── products │ ├── chair1.png │ ├── chair10.png │ ├── chair2.png │ ├── chair3.png │ ├── chair4.png │ ├── chair5.png │ ├── chair6.png │ ├── chair7.png │ ├── chair8.png │ ├── chair9.png │ ├── couch1.png │ ├── couch10.png │ ├── couch11.png │ ├── couch12.png │ ├── couch13.png │ ├── couch14.png │ ├── couch15.png │ ├── couch2.png │ ├── couch3.png │ ├── couch4.png │ ├── couch5.png │ ├── couch6.png │ ├── couch7.png │ ├── couch8.png │ └── couch9.png ├── serverless.yml ├── snippets └── lambda.js ├── styles └── globals.css ├── tailwind.config.js ├── theme.js ├── utils ├── categoryProvider.js ├── currencyProvider.js ├── helpers.js ├── inventory.js ├── inventoryByCategory.js ├── inventoryForCategory.js └── inventoryProvider.js └── yarn.lock /.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 35 | 36 | # serverless 37 | .serverless 38 | .serverless_nextjs -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | package-lock.json 4 | public 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Jamstack ECommerce Next 2 | 3 | Jamstack ECommerce Next provides a way to quickly get up and running with a fully configurable ECommerce site using Next.js. 4 | 5 | Out of the box, the site uses completely static data coming from a provider at `providers/inventoryProvider.js`. You can update this provider to fetch data from any real API by changing the call in the `getInventory` function. 6 | 7 | ![Home](example-images/1.png) 8 | 9 | ### Live preview 10 | 11 | Click [here](https://www.jamstackecommerce.dev/) to see a live preview. 12 | 13 |
14 | Other Jamstack ECommerce pages 15 | 16 | ### Category view 17 | ![Category view](example-images/2.png) 18 | 19 | ### Item view 20 | ![Item view](example-images/3.png) 21 | 22 | ### Cart view 23 | ![Cart view](example-images/4.png) 24 | 25 | ### Admin panel 26 | ![Admin panel](example-images/5.png) 27 |
28 | 29 | ### Getting started 30 | 31 | 1. Clone the project 32 | 33 | ```sh 34 | $ git clone https://github.com/jamstack-cms/jamstack-ecommerce.git 35 | ``` 36 | 37 | 2. Install the dependencies: 38 | 39 | ```sh 40 | $ yarn 41 | 42 | # or 43 | 44 | $ npm install 45 | ``` 46 | 47 | 3. Run the project 48 | 49 | ```sh 50 | $ npm run dev 51 | 52 | # or to build 53 | 54 | $ npm run build 55 | ``` 56 | 57 | ## Deploy to Vercel 58 | 59 | Use the [Vercel CLI](https://vercel.com/download) 60 | 61 | ```sh 62 | vercel 63 | ``` 64 | 65 | ## Deploy to AWS 66 | 67 | ```sh 68 | npx serverless 69 | ``` 70 | 71 | ## About the project 72 | 73 | ### Tailwind 74 | 75 | This project is styled using Tailwind. To learn more how this works, check out the Tailwind documentation [here](https://tailwindcss.com/docs). 76 | 77 | ### Components 78 | 79 | The main files, components, and images you may want to change / modify are: 80 | 81 | __Logo__ - public/logo.png 82 | __Button, ListItem, etc..__ - components 83 | __Form components__ - components/formComponents 84 | __Context (state)__ - context/mainContext.js 85 | __Pages (admin, cart, checkout, index)__ - pages 86 | __Templates (category view, single item view, inventory views)__ - templates 87 | 88 | ### How it works 89 | 90 | As it is set up, inventory is fetched from a local hard coded array of inventory items. This can easily be configured to instead be fetched from a remote source like Shopify or another CMS or data source by changing the inventory provider. 91 | 92 | #### Configuring inventory provider 93 | 94 | Update __utils/inventoryProvider.js__ with your own inventory provider. 95 | 96 | #### Download images at build time 97 | 98 | If you change the provider to fetch images from a remote source, you may choose to also download the images locally at build time to improve performance. Here is an example of some code that should work for this use case: 99 | 100 | ```javascript 101 | import fs from 'fs' 102 | import axios from 'axios' 103 | import path from 'path' 104 | 105 | function getImageKey(url) { 106 | const split = url.split('/') 107 | const key = split[split.length - 1] 108 | const keyItems = key.split('?') 109 | const imageKey = keyItems[0] 110 | return imageKey 111 | } 112 | 113 | function getPathName(url, pathName = 'downloads') { 114 | let reqPath = path.join(__dirname, '..') 115 | let key = getImageKey(url) 116 | key = key.replace(/%/g, "") 117 | const rawPath = `${reqPath}/public/${pathName}/${key}` 118 | return rawPath 119 | } 120 | 121 | async function downloadImage (url) { 122 | return new Promise(async (resolve, reject) => { 123 | const path = getPathName(url) 124 | const writer = fs.createWriteStream(path) 125 | const response = await axios({ 126 | url, 127 | method: 'GET', 128 | responseType: 'stream' 129 | }) 130 | response.data.pipe(writer) 131 | writer.on('finish', resolve) 132 | writer.on('error', reject) 133 | }) 134 | } 135 | 136 | export default downloadImage 137 | ``` 138 | 139 | You can use this function to map over the inventory data after fetching and replace the image paths with a reference to the location of the downloaded images, probably would look something like this: 140 | 141 | ```javascript 142 | await Promise.all( 143 | inventory.map(async (item, index) => { 144 | try { 145 | const relativeUrl = `../downloads/${item.image}` 146 | if (!fs.existsSync(`${__dirname}/public/downloads/${item.image}`)) { 147 | await downloadImage(image) 148 | } 149 | inventory[index].image = relativeUrl 150 | } catch (err) { 151 | console.log('error downloading image: ', err) 152 | } 153 | }) 154 | ) 155 | ``` 156 | 157 | ### Updating with Auth / Admin panel 158 | 159 | 1. Update __pages/admin.js__ with sign up, sign, in, sign out, and confirm sign in methods. 160 | 161 | 2. Update __components/ViewInventory.js__ with methods to interact with the actual inventory API. 162 | 163 | 3. Update __components/formComponents/AddInventory.js__ with methods to add item to actual inventory API. 164 | 165 | ### Roadmap 166 | 167 | - Full product and category search 168 | - Auto dropdown navigation for large number of categories 169 | - Ability to add more / more configurable metadata to item details 170 | - Themeing + dark mode 171 | - Optional user account / profiles out of the box 172 | - Make Admin Panel responsive 173 | - Have an idea or a request? Submit [an issue](https://github.com/jamstack-cms/jamstack-ecommerce/issues) or [a pull request](https://github.com/jamstack-cms/jamstack-ecommerce/pulls)! 174 | 175 | ### Other considerations 176 | 177 | #### Server-side processing of payments 178 | 179 | To see an example of how to process payments server-side with stripe, check out the [Lambda function in the snippets folder](https://github.com/jamstack-cms/jamstack-ecommerce/blob/next/snippets/lambda.js). 180 | 181 | Also, consider verifying totals by passing in an array of IDs into the function, calculating the total on the server, then comparing the totals to check and make sure they match. -------------------------------------------------------------------------------- /components/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Button({ title, onClick, full = false }) { 4 | let classNames = "text-sm font-bold tracking-wider bg-transparent hover:bg-black text-black font-semibold hover:text-white py-4 px-12 border-2 border-black hover:border-transparent" 5 | 6 | if (full) { 7 | classNames = `${classNames} w-full` 8 | } 9 | return ( 10 | 15 | ) 16 | } -------------------------------------------------------------------------------- /components/CartLink.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { ContextProviderComponent, SiteContext } from '../context/mainContext' 3 | import { FaShoppingCart, FaCircle } from 'react-icons/fa'; 4 | import Link from "next/link" 5 | import { colors } from '../theme' 6 | const { primary } = colors 7 | 8 | function CartLink(props) { 9 | const [renderClientSideComponent, setRenderClientSideComponent] = useState(false) 10 | useEffect(() => { 11 | setRenderClientSideComponent(true) 12 | }, []) 13 | let { context: { numberOfItemsInCart = 0 }} = props 14 | return ( 15 |
16 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | { 26 | renderClientSideComponent && numberOfItemsInCart > Number(0) && ( 27 | 28 | ) 29 | } 30 |
31 |
32 |
33 | ) 34 | } 35 | 36 | function CartLinkWithContext(props) { 37 | return ( 38 | 39 | 40 | { 41 | context => 42 | } 43 | 44 | 45 | ) 46 | } 47 | 48 | export default CartLinkWithContext -------------------------------------------------------------------------------- /components/Image.js: -------------------------------------------------------------------------------- 1 | const ImageComponent = ({ src, ...props}) => { 2 | return 3 | } 4 | 5 | export default ImageComponent 6 | -------------------------------------------------------------------------------- /components/Inventory.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AddInventory from '../components/formComponents/AddInventory' 3 | import ViewInventory from '../components/ViewInventory' 4 | 5 | class Inventory extends React.Component { 6 | state = { 7 | viewState: 'view' 8 | } 9 | toggleViewState(viewState) { 10 | this.setState(() => ({ viewState })) 11 | } 12 | render() { 13 | return ( 14 |
15 |
16 |

this.toggleViewState('view')}>View Inventory

17 |

this.toggleViewState('add')}>Add Item

18 |
19 | { 20 | this.state.viewState === 'view' ? ( 21 | 22 | ) : () 23 | } 24 | 27 |
28 | ) 29 | } 30 | } 31 | 32 | export default Inventory -------------------------------------------------------------------------------- /components/ListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | import DENOMINATION from '../utils/currencyProvider' 4 | import Image from './Image' 5 | 6 | const ListItem = ({ link, title, imageSrc, price }) => ( 7 |
13 | 14 | 15 |
16 |
17 | {title} 18 |
19 |
20 |
21 | 22 |
23 |

{title}

24 |

{`${DENOMINATION}${price}`}

25 |
26 |
27 | ) 28 | 29 | export default ListItem -------------------------------------------------------------------------------- /components/QuantityPicker.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function QuantityPicker({ 4 | increment, decrement, numberOfitems, hideQuantityLabel 5 | }) { 6 | return ( 7 |
8 | { 9 | !hideQuantityLabel && ( 10 |
QUANTITY
11 | ) 12 | } 13 | 23 |

{numberOfitems}

27 | 36 |
37 | ) 38 | } -------------------------------------------------------------------------------- /components/ViewInventory.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { fetchInventory } from '../utils/inventoryProvider' 3 | import DENOMINATION from '../utils/currencyProvider' 4 | import Image from '../components/Image' 5 | import Link from 'next/link' 6 | import { slugify } from '../utils/helpers' 7 | import { FaTimes } from 'react-icons/fa' 8 | 9 | class ViewInventory extends React.Component { 10 | state = { 11 | inventory: [], 12 | currentItem: {}, 13 | editingIndex: [] 14 | } 15 | componentDidMount() { 16 | this.fetchInventory() 17 | } 18 | fetchInventory = async() => { 19 | const inventory = await fetchInventory() 20 | this.setState({ inventory }) 21 | } 22 | editItem = (item, index) => { 23 | const editingIndex = index 24 | this.setState({ editingIndex, currentItem: item }) 25 | } 26 | saveItem = async index => { 27 | const inventory = [...this.state.inventory] 28 | inventory[index] = this.state.currentItem 29 | // update item in database 30 | this.setState({ editingIndex: null, inventory }) 31 | } 32 | deleteItem = async index => { 33 | const inventory = [...this.state.inventory.slice(0, index), ...this.state.inventory.slice(index + 1)] 34 | this.setState({ inventory }) 35 | } 36 | onChange = event => { 37 | const currentItem = { 38 | ...this.state.currentItem, 39 | [event.target.name]: event.target.value 40 | } 41 | 42 | this.setState({ currentItem }) 43 | } 44 | render() { 45 | const { inventory, currentItem, editingIndex } = this.state 46 | return ( 47 |
48 |

Inventory

49 | { 50 | inventory.map((item, index) => { 51 | const isEditing = editingIndex === index 52 | if (isEditing) { 53 | return ( 54 |
55 |
56 | 57 | 58 | {item.name} 59 | 60 | 61 | this.onChange(e, index)} 63 | className="ml-8 shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" 64 | value={currentItem.name} 65 | placeholder="Item name" 66 | name="name" 67 | /> 68 |
69 |

In stock:

70 | 77 | 84 |
85 |
this.saveItem(index)} className="m-0 ml-10 text-gray-900 text-s cursor-pointer"> 86 |

Save

87 |
88 |
89 |
90 | ) 91 | } 92 | return ( 93 |
94 |
95 | 96 | 97 | {item.name} 98 | 99 | 100 | 101 | 102 |

103 | {item.name} 104 |

105 |
106 | 107 |
108 |

In stock: {item.currentInventory}

109 |

110 | {DENOMINATION + item.price} 111 |

112 |
113 |
114 | this.deleteItem(index)} /> 115 |

this.editItem(item, index)} className="text-sm ml-10 m-0">Edit

116 |
117 |
118 |
119 | ) 120 | }) 121 | } 122 |
123 | ) 124 | } 125 | } 126 | 127 | export default ViewInventory -------------------------------------------------------------------------------- /components/formComponents/AddInventory.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const initialState = { 4 | name: '', brand: '', price: '', categories: [], image: '', description: '', currentInventory: '' 5 | } 6 | 7 | class AddInventory extends React.Component { 8 | state = initialState 9 | clearForm = () => { 10 | this.setState(() => (initialState)) 11 | } 12 | onChange = (e) => { 13 | this.setState({ [e.target.name]: e.target.value}) 14 | } 15 | onImageChange = async (e) => { 16 | const file = e.target.files[0]; 17 | this.setState({ image: file }) 18 | // const storageUrl = await Storage.put('example.png', file, { 19 | // contentType: 'image/png' 20 | // }) 21 | // this.setState({ image: storageUrl }) 22 | } 23 | addItem = async () => { 24 | const { name, brand, price, categories, image, description, currentInventory } = this.state 25 | if (!name || !brand || !price || !categories.length || !description || !currentInventory || !image) return 26 | // add to database 27 | this.clearForm() 28 | } 29 | render() { 30 | const { 31 | name, brand, price, categories, image, description, currentInventory 32 | } = this.state 33 | return ( 34 |
35 |

Add Item

36 |
37 |
38 |
39 |
40 | 43 | 46 |
47 |
48 | 51 | 54 |
55 |
56 | 59 | 62 |
63 |
64 | 67 | this.onImageChange(e)} 70 | /> 71 |
72 |
73 | 76 | 79 |
80 |
81 | 84 | 87 |
88 |
89 | 92 | 95 |
96 |
97 | 100 | 101 | Clear Form 102 | 103 |
104 |
105 |
106 |
107 |
108 | ) 109 | } 110 | } 111 | 112 | export default AddInventory -------------------------------------------------------------------------------- /components/formComponents/ConfirmSignUp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | class ConfirmSignUp extends React.Component { 4 | state = { 5 | username: '', authcode: '' 6 | } 7 | onChange = (e) => { 8 | this.setState({ [e.target.name]: e.target.value}) 9 | } 10 | render() { 11 | return ( 12 |
13 |

Sign Up

14 |
15 |
16 |
17 |
18 | 21 | 24 |
25 |
26 | 29 | 32 |
33 |
34 | 37 | 38 | Forgot Password? 39 | 40 |
41 |
42 |
43 |
44 |
45 | ) 46 | } 47 | } 48 | 49 | export default ConfirmSignUp -------------------------------------------------------------------------------- /components/formComponents/SignIn.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | class SignIn extends React.Component { 4 | state = { 5 | username: '', password: '' 6 | } 7 | onChange = (e) => { 8 | this.setState({ [e.target.name]: e.target.value}) 9 | } 10 | render() { 11 | return ( 12 |
13 |

Sign In

14 |
15 |
16 |
17 |
18 | 21 | 24 |
25 |
26 | 29 | 32 |
33 |
34 | 37 | 38 | Forgot Password? 39 | 40 |
41 |
42 |
43 |
44 |
45 | ) 46 | } 47 | } 48 | 49 | export default SignIn 50 | -------------------------------------------------------------------------------- /components/formComponents/SignUp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | class SignUp extends React.Component { 4 | state = { 5 | username: '', email: '', password: '' 6 | } 7 | onChange = (e) => { 8 | this.setState({ [e.target.name]: e.target.value}) 9 | } 10 | render() { 11 | return ( 12 |
13 |

Sign Up

14 |
15 |
16 |
17 |
18 | 21 | 24 |
25 |
26 | 29 | 32 |
33 |
34 | 37 | 40 |
41 |
42 | 45 | this.props.toggleFormState('signIn')}> 46 | Already signed up? 47 | 48 |
49 |
50 |
51 |
52 |
53 | ) 54 | } 55 | } 56 | 57 | export default SignUp -------------------------------------------------------------------------------- /components/heroComponents/Center.js: -------------------------------------------------------------------------------- 1 | import { Button } from '../'; 2 | import { useRouter } from 'next/router' 3 | 4 | const Center = ({ price, title, link }) => { 5 | const router = useRouter() 6 | function navigate() { 7 | router.push(link) 8 | } 9 | return ( 10 |
11 |

{title}

12 |

FROM ${price}

13 |
18 | ) 19 | } 20 | 21 | export default Center -------------------------------------------------------------------------------- /components/heroComponents/DisplayMedium.js: -------------------------------------------------------------------------------- 1 | import Image from '../Image' 2 | import Link from 'next/link' 3 | 4 | const DisplayMedium = ({ imageSrc, title, subtitle, link }) => { 5 | return ( 6 |
9 | 10 | 11 |
12 | {title} 13 |
14 |
15 |

{title}

16 |

{subtitle}

17 |
18 |
19 | 20 |
21 | ) 22 | } 23 | 24 | export default DisplayMedium; -------------------------------------------------------------------------------- /components/heroComponents/DisplaySmall.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { getTrimmedString } from '../../utils/helpers' 3 | import Image from '../Image' 4 | 5 | const DisplaySmall = ({ link, title, subtitle, imageSrc }) => ( 6 |
9 | 10 | 11 |
12 | {title} 13 |
14 |
15 |

{title}

16 |

{getTrimmedString(subtitle, 150)}

17 |
18 |
19 | 20 |
21 | ) 22 | 23 | export default DisplaySmall -------------------------------------------------------------------------------- /components/heroComponents/Footer.js: -------------------------------------------------------------------------------- 1 | const Footer = ({ designer }) => { 2 | return ( 3 |
4 |

Design by

5 |

{designer}

6 |
7 | ) 8 | } 9 | 10 | export default Footer -------------------------------------------------------------------------------- /components/heroComponents/Showcase.js: -------------------------------------------------------------------------------- 1 | import Image from '../Image' 2 | 3 | const Showcase = ({ imageSrc }) => { 4 | return ( 5 |
6 | Showcase item 7 |
8 | ) 9 | } 10 | 11 | export default Showcase -------------------------------------------------------------------------------- /components/heroComponents/Spacer.js: -------------------------------------------------------------------------------- 1 | const Spacer = ({ width }) => ( 2 |
3 | ) 4 | 5 | export default Spacer -------------------------------------------------------------------------------- /components/heroComponents/Tag.js: -------------------------------------------------------------------------------- 1 | const Tag = ({ category, year }) => { 2 | return ( 3 |
4 |

{category}

5 | { year &&

{year}

} 6 |
7 | ) 8 | } 9 | 10 | export default Tag -------------------------------------------------------------------------------- /components/index.js: -------------------------------------------------------------------------------- 1 | import Tag from './heroComponents/Tag' 2 | import Center from './heroComponents/Center' 3 | import Footer from './heroComponents/Footer' 4 | import Showcase from './heroComponents/Showcase' 5 | import DisplaySmall from './heroComponents/DisplaySmall' 6 | import DisplayMedium from './heroComponents/DisplayMedium' 7 | import Spacer from './heroComponents/Spacer' 8 | import Button from './Button' 9 | import Image from './Image' 10 | 11 | export { 12 | Tag, 13 | Center, 14 | Footer, 15 | Button, 16 | Image, 17 | Showcase, 18 | DisplaySmall, 19 | DisplayMedium, 20 | Spacer 21 | } -------------------------------------------------------------------------------- /context/mainContext.js: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify' 2 | import React from 'react' 3 | const STORAGE_KEY = 'NEXT_ECOMMERCE_STARTER_' 4 | 5 | const initialState = { 6 | cart: [], 7 | numberOfItemsInCart: 0, 8 | total: 0 9 | } 10 | 11 | const SiteContext = React.createContext() 12 | 13 | function calculateTotal(cart) { 14 | const total = cart.reduce((acc, next) => { 15 | const quantity = next.quantity 16 | acc = acc + JSON.parse(next.price) * quantity 17 | return acc 18 | }, 0) 19 | return total 20 | } 21 | 22 | class ContextProviderComponent extends React.Component { 23 | componentDidMount() { 24 | if (typeof window !== 'undefined') { 25 | const storageState = window.localStorage.getItem(STORAGE_KEY) 26 | if (!storageState) { 27 | window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initialState)) 28 | } 29 | } 30 | } 31 | 32 | setItemQuantity = (item) => { 33 | const storageState = JSON.parse(window.localStorage.getItem(STORAGE_KEY)) 34 | const { cart } = storageState 35 | const index = cart.findIndex(cartItem => cartItem.id === item.id) 36 | cart[index].quantity = item.quantity 37 | window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ 38 | cart, numberOfItemsInCart: cart.length, total: calculateTotal(cart) 39 | })) 40 | this.forceUpdate() 41 | } 42 | 43 | addToCart = item => { 44 | const storageState = JSON.parse(window.localStorage.getItem(STORAGE_KEY)) 45 | const { cart } = storageState 46 | if (cart.length) { 47 | const index = cart.findIndex(cartItem => cartItem.id === item.id) 48 | if (index >= Number(0)) { 49 | /* If this item is already in the cart, update the quantity */ 50 | cart[index].quantity = cart[index].quantity + item.quantity 51 | } else { 52 | /* If this item is not yet in the cart, add it */ 53 | cart.push(item) 54 | } 55 | } else { 56 | /* If no items in the cart, add the first item. */ 57 | cart.push(item) 58 | } 59 | 60 | window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ 61 | cart, numberOfItemsInCart: cart.length, total: calculateTotal(cart) 62 | })) 63 | toast("Successfully added item to cart!", { 64 | position: toast.POSITION.TOP_LEFT 65 | }) 66 | this.forceUpdate() 67 | } 68 | 69 | removeFromCart = (item) => { 70 | const storageState = JSON.parse(window.localStorage.getItem(STORAGE_KEY)) 71 | let { cart } = storageState 72 | cart = cart.filter(c => c.id !== item.id) 73 | 74 | window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ 75 | cart, numberOfItemsInCart: cart.length, total: calculateTotal(cart) 76 | })) 77 | this.forceUpdate() 78 | } 79 | 80 | clearCart = () => { 81 | window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initialState)) 82 | this.forceUpdate() 83 | } 84 | 85 | render() { 86 | let state = initialState 87 | if (typeof window !== 'undefined') { 88 | const storageState = window.localStorage.getItem(STORAGE_KEY) 89 | if (storageState) { 90 | state = JSON.parse(storageState) 91 | } 92 | } 93 | 94 | return ( 95 | 102 | {this.props.children} 103 | 104 | ) 105 | } 106 | } 107 | 108 | export { 109 | SiteContext, 110 | ContextProviderComponent 111 | } -------------------------------------------------------------------------------- /ecommerce.config.js: -------------------------------------------------------------------------------- 1 | // number of categories to show in the navigation by default 2 | const navItemLength = 5 3 | 4 | export { 5 | navItemLength 6 | } -------------------------------------------------------------------------------- /example-images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/example-images/1.png -------------------------------------------------------------------------------- /example-images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/example-images/2.png -------------------------------------------------------------------------------- /example-images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/example-images/3.png -------------------------------------------------------------------------------- /example-images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/example-images/4.png -------------------------------------------------------------------------------- /example-images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/example-images/5.png -------------------------------------------------------------------------------- /layouts/layout.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { slugify } from '../utils/helpers' 3 | import 'react-toastify/dist/ReactToastify.css' 4 | import { ToastContainer } from 'react-toastify' 5 | import { navItemLength } from '../ecommerce.config' 6 | 7 | export default function Layout({ children, categories }) { 8 | if (categories.length > navItemLength) { 9 | categories = categories.slice(0, navItemLength) 10 | } 11 | return ( 12 |
13 | 68 |
69 |
{children}
70 |
71 |
72 |
79 | Copyright © 2021 JAMstack Ecommerce. All rights reserved. 80 |
84 | 85 | 86 |

Admins

87 |
88 | 89 |
90 |
91 |
92 | 93 |
94 | ) 95 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jamstack-ecommerce", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@stripe/react-stripe-js": "^1.1.2", 12 | "@stripe/stripe-js": "^1.11.0", 13 | "autoprefixer": "^10.1.0", 14 | "next": "10.0.4", 15 | "postcss": "^8.2.2", 16 | "react": "17.0.1", 17 | "react-dom": "17.0.1", 18 | "react-icons": "^4.1.0", 19 | "react-toastify": "^6.2.0", 20 | "tailwindcss": "^2.0.2", 21 | "uuid": "^8.3.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import Layout from '../layouts/layout' 3 | import fetchCategories from '../utils/categoryProvider' 4 | 5 | function Ecommerce({ Component, pageProps, categories }) { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | Ecommerce.getInitialProps = async () => { 14 | const categories = await fetchCategories() 15 | return { 16 | categories 17 | } 18 | } 19 | 20 | export default Ecommerce -------------------------------------------------------------------------------- /pages/admin.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SignUp from '../components/formComponents/SignUp' 3 | import ConfirmSignUp from '../components/formComponents/ConfirmSignUp' 4 | import SignIn from '../components/formComponents/SignIn' 5 | import Inventory from '../components/Inventory' 6 | 7 | class Admin extends React.Component { 8 | state = { formState: 'signUp', isAdmin: false } 9 | toggleFormState = (formState) => { 10 | this.setState(() => ({ formState })) 11 | } 12 | async componentDidMount() { 13 | // check and update signed in state 14 | } 15 | signUp = async (form) => { 16 | const { username, email, password } = form 17 | // sign up 18 | this.setState({ formState: 'confirmSignUp' }) 19 | } 20 | confirmSignUp = async (form) => { 21 | const { username, authcode } = form 22 | // confirm sign up 23 | this.setState({ formState: 'signIn' }) 24 | } 25 | signIn = async (form) => { 26 | const { username, password } = form 27 | // signIn 28 | this.setState({ formState: 'signedIn', isAdmin: true }) 29 | } 30 | signOut = async() => { 31 | // sign out 32 | this.setState({ formState: 'signUp' }) 33 | } 34 | 35 | render() { 36 | const { formState, isAdmin } = this.state 37 | const renderForm = (formState, state) => { 38 | switch(formState) { 39 | case 'signUp': 40 | return 41 | case 'confirmSignUp': 42 | return 43 | case 'signIn': 44 | return 45 | case 'signedIn': 46 | return isAdmin ? :

Not an admin

47 | default: 48 | return null 49 | } 50 | } 51 | 52 | return ( 53 |
54 |
55 |
56 |

Admin Panel

57 |
58 | { 59 | renderForm(formState) 60 | } 61 |
62 |
63 | ) 64 | } 65 | } 66 | 67 | export default Admin -------------------------------------------------------------------------------- /pages/cart.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { useState, useEffect } from 'react' 3 | import { FaTimes, FaLongArrowAltRight } from 'react-icons/fa' 4 | import { SiteContext, ContextProviderComponent } from '../context/mainContext' 5 | import DENOMINATION from '../utils/currencyProvider' 6 | import { slugify } from '../utils/helpers' 7 | import QuantityPicker from '../components/QuantityPicker' 8 | import Image from '../components/Image' 9 | import Head from 'next/head' 10 | import CartLink from '../components/CartLink' 11 | 12 | const Cart = ({ context }) => { 13 | const [renderClientSideComponent, setRenderClientSideComponent] = useState(false) 14 | useEffect(() => { 15 | setRenderClientSideComponent(true) 16 | }, []) 17 | const { 18 | numberOfItemsInCart, cart, removeFromCart, total, setItemQuantity 19 | } = context 20 | const cartEmpty = numberOfItemsInCart === Number(0) 21 | 22 | function increment(item) { 23 | item.quantity = item.quantity + 1 24 | setItemQuantity(item) 25 | } 26 | 27 | function decrement(item) { 28 | if (item.quantity === 1) return 29 | item.quantity = item.quantity - 1 30 | setItemQuantity(item) 31 | } 32 | 33 | if (!renderClientSideComponent) return null 34 | 35 | return ( 36 | <> 37 | 38 |
39 | 40 | Jamstack ECommerce - Cart 41 | 42 | 43 | 44 |
48 |
49 |

Your Cart

50 |
51 | 52 | { 53 | cartEmpty ? ( 54 |

No items in cart.

55 | ) : ( 56 |
57 |
58 | { 59 | cart.map((item) => { 60 | return ( 61 |
62 |
63 | 64 | 65 | {item.name} 66 | 67 | 68 | 69 | 70 |

73 | {item.name} 74 |

75 |
76 | 77 |
78 | increment(item)} 81 | decrement={() => decrement(item)} 82 | /> 83 |
84 |
85 |

86 | {DENOMINATION + item.price} 87 |

88 |
89 |
removeFromCart(item)} className=" 90 | m-0 ml-10 text-gray-900 text-s cursor-pointer 91 | "> 92 | 93 |
94 |
95 | 96 |
97 | 98 | 99 | {item.name} 100 | 101 | 102 |
103 | 104 | 105 |

108 | {item.name} 109 |

110 |
111 | 112 |
113 | increment(item)} 117 | decrement={() => decrement(item)} 118 | /> 119 |
120 |
121 |

122 | {DENOMINATION + item.price} 123 |

124 |
125 |
126 |
removeFromCart(item)} className=" 127 | m-0 ml-10 text-gray-900 text-s cursor-pointer mr-2 128 | "> 129 | 130 |
131 |
132 |
133 | ) 134 | }) 135 | } 136 |
137 |
138 | ) 139 | } 140 |
141 |

Total

142 |

{DENOMINATION + total}

143 |
144 | {!cartEmpty && ( 145 | 146 | 147 |
148 |

Proceed to check out

149 | 150 |
151 |
152 | 153 | )} 154 |
155 |
156 | 157 | ) 158 | } 159 | 160 | function CartWithContext(props) { 161 | return ( 162 | 163 | 164 | { 165 | context => 166 | } 167 | 168 | 169 | ) 170 | } 171 | 172 | 173 | export default CartWithContext -------------------------------------------------------------------------------- /pages/categories.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { titleIfy , slugify } from '../utils/helpers' 3 | import { DisplayMedium } from '../components' 4 | import CartLink from '../components/CartLink' 5 | import { fetchInventory } from '../utils/inventoryProvider' 6 | 7 | function Categories ({ categories = [] }) { 8 | return ( 9 | <> 10 |
11 | 12 | 13 | Jamstack ECommerce - All Categories 14 | 15 | 16 | 17 |
20 |

All categories

21 |
22 |
23 | 24 | {/*
*/} 25 |
27 | { 28 | categories.map((category, index) => ( 29 | 36 | )) 37 | } 38 |
39 |
40 |
41 | 42 | ) 43 | } 44 | 45 | export async function getStaticProps() { 46 | const inventory = await fetchInventory() 47 | const inventoryCategories = inventory.reduce((acc, next) => { 48 | const categories = next.categories 49 | categories.forEach(c => { 50 | const index = acc.findIndex(item => item.name === c) 51 | if (index !== -1) { 52 | const item = acc[index] 53 | item.itemCount = item.itemCount + 1 54 | acc[index] = item 55 | } else { 56 | const item = { 57 | name: c, 58 | image: next.image, 59 | itemCount: 1 60 | } 61 | acc.push(item) 62 | } 63 | }) 64 | return acc 65 | }, []) 66 | 67 | return { 68 | props: { 69 | categories: inventoryCategories 70 | } 71 | } 72 | } 73 | 74 | export default Categories -------------------------------------------------------------------------------- /pages/category/[name].js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import ListItem from '../../components/ListItem' 3 | import { titleIfy, slugify } from '../../utils/helpers' 4 | import fetchCategories from '../../utils/categoryProvider' 5 | import inventoryForCategory from '../../utils/inventoryForCategory' 6 | import CartLink from '../../components/CartLink' 7 | 8 | const Category = (props) => { 9 | const { inventory, title } = props 10 | return ( 11 | <> 12 | 13 | 14 | Jamstack ECommerce - {title} 15 | 16 | 17 | 18 |
19 |
20 |
21 |

{titleIfy(title)}

22 |
23 | 24 |
25 |
26 | { 27 | inventory.map((item, index) => { 28 | return ( 29 | 36 | ) 37 | }) 38 | } 39 |
40 |
41 |
42 |
43 | 44 | ) 45 | } 46 | 47 | export async function getStaticPaths () { 48 | const categories = await fetchCategories() 49 | const paths = categories.map(category => { 50 | return { params: { name: slugify(category) }} 51 | }) 52 | return { 53 | paths, 54 | fallback: false 55 | } 56 | } 57 | 58 | export async function getStaticProps ({ params }) { 59 | const category = params.name.replace(/-/g," ") 60 | const inventory = await inventoryForCategory(category) 61 | return { 62 | props: { 63 | inventory, 64 | title: category 65 | } 66 | } 67 | } 68 | 69 | export default Category -------------------------------------------------------------------------------- /pages/checkout.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Head from 'next/head' 3 | import { SiteContext, ContextProviderComponent } from "../context/mainContext" 4 | import DENOMINATION from "../utils/currencyProvider" 5 | import { FaLongArrowAltLeft } from "react-icons/fa" 6 | import Link from "next/link" 7 | import Image from "../components/Image" 8 | import { v4 as uuid } from "uuid" 9 | 10 | import { 11 | CardElement, 12 | Elements, 13 | useStripe, 14 | useElements, 15 | } from "@stripe/react-stripe-js" 16 | import { loadStripe } from "@stripe/stripe-js" 17 | 18 | // Make sure to call `loadStripe` outside of a component’s render to avoid 19 | // recreating the `Stripe` object on every render. 20 | const stripePromise = loadStripe("xxx-xxx-xxx") 21 | 22 | function CheckoutWithContext(props) { 23 | return ( 24 | 25 | 26 | {context => ( 27 | 28 | 29 | 30 | )} 31 | 32 | 33 | ) 34 | } 35 | 36 | const calculateShipping = () => { 37 | return 0 38 | } 39 | 40 | const Input = ({ onChange, value, name, placeholder }) => ( 41 | 49 | ) 50 | 51 | const Checkout = ({ context }) => { 52 | const [errorMessage, setErrorMessage] = useState(null) 53 | const [orderCompleted, setOrderCompleted] = useState(false) 54 | const [input, setInput] = useState({ 55 | name: "", 56 | email: "", 57 | street: "", 58 | city: "", 59 | postal_code: "", 60 | state: "", 61 | }) 62 | 63 | const stripe = useStripe() 64 | const elements = useElements() 65 | 66 | const onChange = e => { 67 | setErrorMessage(null) 68 | setInput({ ...input, [e.target.name]: e.target.value }) 69 | } 70 | 71 | const handleSubmit = async event => { 72 | event.preventDefault() 73 | const { name, email, street, city, postal_code, state } = input 74 | const { total, clearCart } = context 75 | 76 | if (!stripe || !elements) { 77 | // Stripe.js has not loaded yet. Make sure to disable 78 | // form submission until Stripe.js has loaded. 79 | return 80 | } 81 | 82 | // Validate input 83 | if (!street || !city || !postal_code || !state) { 84 | setErrorMessage("Please fill in the form!") 85 | return 86 | } 87 | 88 | // Get a reference to a mounted CardElement. Elements knows how 89 | // to find your CardElement because there can only ever be one of 90 | // each type of element. 91 | const cardElement = elements.getElement(CardElement) 92 | 93 | // Use your card Element with other Stripe.js APIs 94 | const { error, paymentMethod } = await stripe.createPaymentMethod({ 95 | type: "card", 96 | card: cardElement, 97 | billing_details: { name: name }, 98 | }) 99 | 100 | if (error) { 101 | setErrorMessage(error.message) 102 | return 103 | } 104 | 105 | const order = { 106 | email, 107 | amount: total, 108 | address: state, // should this be {street, city, postal_code, state} ? 109 | payment_method_id: paymentMethod.id, 110 | receipt_email: "customer@example.com", 111 | id: uuid(), 112 | } 113 | // TODO call API 114 | setOrderCompleted(true) 115 | clearCart() 116 | } 117 | 118 | const { numberOfItemsInCart, cart, total } = context 119 | const cartEmpty = numberOfItemsInCart === Number(0) 120 | 121 | if (orderCompleted) { 122 | return ( 123 |
124 |

Thanks! Your order has been successfully processed.

125 |
126 | ) 127 | } 128 | 129 | return ( 130 |
131 | 132 | Jamstack ECommerce - Checkout 133 | 134 | 135 | 136 |
142 |
143 |

Checkout

144 | 145 | 146 |
147 | 148 |

Edit Cart

149 |
150 |
151 | 152 |
153 | 154 | {cartEmpty ? ( 155 |

No items in cart.

156 | ) : ( 157 |
158 |
159 | {cart.map((item, index) => { 160 | return ( 161 |
162 |
163 | {item.name} 168 |

169 | {item.name} 170 |

171 |
172 |

173 | {DENOMINATION + item.price} 174 |

175 |
176 |
177 |
178 | ) 179 | })} 180 |
181 |
182 |
183 |
184 |
185 | {errorMessage ? {errorMessage} : ""} 186 | 192 | 193 | 199 | 205 | 211 | 217 | 223 | 232 | 233 |
234 |
235 |
236 |
237 |

Subtotal

238 |

239 | {DENOMINATION + total} 240 |

241 |
242 |
243 |

Shipping

244 |

245 | FREE SHIPPING 246 |

247 |
248 |
249 |

Total

250 |

251 | {DENOMINATION + (total + calculateShipping())} 252 |

253 |
254 | 263 |
264 |
265 |
266 | )} 267 |
268 |
269 | ) 270 | } 271 | 272 | export default CheckoutWithContext -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { Center, Footer, Tag, Showcase, DisplaySmall, DisplayMedium } from '../components' 3 | import { titleIfy, slugify } from '../utils/helpers' 4 | import { fetchInventory } from '../utils/inventoryProvider' 5 | import CartLink from '../components/CartLink' 6 | 7 | const Home = ({ inventoryData = [], categories: categoryData = [] }) => { 8 | const inventory = inventoryData.slice(0, 4) 9 | const categories = categoryData.slice(0, 2) 10 | 11 | return ( 12 | <> 13 | 14 |
15 | 16 | Jamstack ECommerce 17 | 18 | 19 | 20 |
23 |
24 | 28 |
33 |
36 |
37 |
38 | 41 |
44 |
45 |
46 |
47 |
52 | 58 | 64 |
65 |
66 |

Trending Now

67 |

Find the perfect piece or accessory to finish off your favorite room in the house.

68 |
69 |
70 | 76 | 77 | 83 | 84 | 90 | 91 | 97 |
98 | 99 | ) 100 | } 101 | 102 | export async function getStaticProps() { 103 | const inventory = await fetchInventory() 104 | 105 | const inventoryCategorized = inventory.reduce((acc, next) => { 106 | const categories = next.categories 107 | categories.forEach(c => { 108 | const index = acc.findIndex(item => item.name === c) 109 | if (index !== -1) { 110 | const item = acc[index] 111 | item.itemCount = item.itemCount + 1 112 | acc[index] = item 113 | } else { 114 | const item = { 115 | name: c, 116 | image: next.image, 117 | itemCount: 1 118 | } 119 | acc.push(item) 120 | } 121 | }) 122 | return acc 123 | }, []) 124 | 125 | return { 126 | props: { 127 | inventoryData: inventory, 128 | categories: inventoryCategorized 129 | } 130 | } 131 | } 132 | 133 | export default Home -------------------------------------------------------------------------------- /pages/product/[name].js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Head from 'next/head' 3 | import Button from '../../components/Button' 4 | import Image from '../../components/Image' 5 | import QuantityPicker from '../../components/QuantityPicker' 6 | import { fetchInventory } from '../../utils/inventoryProvider' 7 | import { slugify } from '../../utils/helpers' 8 | import CartLink from '../../components/CartLink' 9 | import { SiteContext, ContextProviderComponent } from '../../context/mainContext' 10 | 11 | const ItemView = (props) => { 12 | const [numberOfitems, updateNumberOfItems] = useState(1) 13 | const { product } = props 14 | const { price, image, name, description } = product 15 | const { context: { addToCart }} = props 16 | 17 | function addItemToCart (product) { 18 | product["quantity"] = numberOfitems 19 | addToCart(product) 20 | } 21 | 22 | function increment() { 23 | updateNumberOfItems(numberOfitems + 1) 24 | } 25 | 26 | function decrement() { 27 | if (numberOfitems === 1) return 28 | updateNumberOfItems(numberOfitems - 1) 29 | } 30 | 31 | return ( 32 | <> 33 | 34 | 35 | Jamstack ECommerce - {name} 36 | 37 | 38 | 39 |
44 |
45 |
46 | Inventory item 47 |
48 |
49 |
50 |

{name}

53 |

${price}

54 |

{description}

55 |
56 | 61 |
62 |
68 |
69 | 70 | ) 71 | } 72 | 73 | export async function getStaticPaths () { 74 | const inventory = await fetchInventory() 75 | const paths = inventory.map(item => { 76 | return { params: { name: slugify(item.name) }} 77 | }) 78 | return { 79 | paths, 80 | fallback: false 81 | } 82 | } 83 | 84 | export async function getStaticProps ({ params }) { 85 | const name = params.name.replace(/-/g," ") 86 | const inventory = await fetchInventory() 87 | const product = inventory.find(item => slugify(item.name) === slugify(name)) 88 | 89 | return { 90 | props: { 91 | product, 92 | } 93 | } 94 | } 95 | 96 | function ItemViewWithContext(props) { 97 | return ( 98 | 99 | 100 | { 101 | context => 102 | } 103 | 104 | 105 | ) 106 | } 107 | 108 | export default ItemViewWithContext -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/Eina-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/fonts/Eina-Light.otf -------------------------------------------------------------------------------- /public/fonts/Eina-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/fonts/Eina-SemiBold.otf -------------------------------------------------------------------------------- /public/fonts/Eina-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/fonts/Eina-SemiBold.ttf -------------------------------------------------------------------------------- /public/fonts/Eina.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/fonts/Eina.otf -------------------------------------------------------------------------------- /public/fonts/Eina.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/fonts/Eina.ttf -------------------------------------------------------------------------------- /public/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Eina"; 3 | src: url("Eina.otf"); 4 | } 5 | 6 | @font-face { 7 | font-family: "Eina Bold"; 8 | src: url("Eina-SemiBold.otf"); 9 | } 10 | 11 | @font-face { 12 | font-family: "Eina Light"; 13 | src: url("Eina-Light.otf"); 14 | } -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/logo.png -------------------------------------------------------------------------------- /public/products/chair1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair1.png -------------------------------------------------------------------------------- /public/products/chair10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair10.png -------------------------------------------------------------------------------- /public/products/chair2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair2.png -------------------------------------------------------------------------------- /public/products/chair3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair3.png -------------------------------------------------------------------------------- /public/products/chair4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair4.png -------------------------------------------------------------------------------- /public/products/chair5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair5.png -------------------------------------------------------------------------------- /public/products/chair6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair6.png -------------------------------------------------------------------------------- /public/products/chair7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair7.png -------------------------------------------------------------------------------- /public/products/chair8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair8.png -------------------------------------------------------------------------------- /public/products/chair9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/chair9.png -------------------------------------------------------------------------------- /public/products/couch1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch1.png -------------------------------------------------------------------------------- /public/products/couch10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch10.png -------------------------------------------------------------------------------- /public/products/couch11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch11.png -------------------------------------------------------------------------------- /public/products/couch12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch12.png -------------------------------------------------------------------------------- /public/products/couch13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch13.png -------------------------------------------------------------------------------- /public/products/couch14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch14.png -------------------------------------------------------------------------------- /public/products/couch15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch15.png -------------------------------------------------------------------------------- /public/products/couch2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch2.png -------------------------------------------------------------------------------- /public/products/couch3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch3.png -------------------------------------------------------------------------------- /public/products/couch4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch4.png -------------------------------------------------------------------------------- /public/products/couch5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch5.png -------------------------------------------------------------------------------- /public/products/couch6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch6.png -------------------------------------------------------------------------------- /public/products/couch7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch7.png -------------------------------------------------------------------------------- /public/products/couch8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch8.png -------------------------------------------------------------------------------- /public/products/couch9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamstack-cms/jamstack-ecommerce/64e01be0aea19f09973e8c0ad60129f88278830d/public/products/couch9.png -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | nextEcommerce: 2 | component: "@sls-next/serverless-component@1.18.0" 3 | # inputs: 4 | # domain: "yourdomain.com" -------------------------------------------------------------------------------- /snippets/lambda.js: -------------------------------------------------------------------------------- 1 | // https://stripe.com/docs/payments/without-card-authentication 2 | const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY) //"API_KEY" 3 | 4 | exports.handler = async event => { 5 | if (!event.body || event.httpMethod !== "POST") { 6 | return { 7 | statusCode: 400, 8 | headers, 9 | body: JSON.stringify({ 10 | status: "invalid http method", 11 | }), 12 | } 13 | } 14 | 15 | const order = JSON.parse(event.body) 16 | 17 | const calculateOrderAmount = items => { 18 | // Replace this constant with a calculation of the order's amount 19 | // You should always calculate the order total on the server to prevent 20 | // people from directly manipulating the amount on the client 21 | return 1400 22 | } 23 | 24 | try { 25 | const intent = await stripe.paymentIntents.create({ 26 | amount: calculateOrderAmount(order.items), 27 | currency: "usd", 28 | payment_method: order.payment_method_id, 29 | 30 | // A PaymentIntent can be confirmed some time after creation, 31 | // but here we want to confirm (collect payment) immediately. 32 | confirm: true, 33 | 34 | // If the payment requires any follow-up actions from the 35 | // customer, like two-factor authentication, Stripe will error 36 | // and you will need to prompt them for a new payment method. 37 | error_on_requires_action: true, 38 | }) 39 | 40 | if (intent.status === "succeeded") { 41 | // This creates a new Customer and attaches the PaymentMethod in one API call. 42 | const customer = await stripe.customers.create({ 43 | payment_method: intent.payment_method, 44 | email: order.email, 45 | address: order.address, 46 | }) 47 | // Handle post-payment fulfillment 48 | console.log(`Created Payment: ${intent.id} for Customer: ${customer.id}`) 49 | // Now ship those goodies 50 | await inventoryAPI.ship(order) 51 | } else { 52 | // Any other status would be unexpected, so error 53 | console.log({ error: "Unexpected status " + intent.status }) 54 | } 55 | } catch (e) { 56 | if (e.type === "StripeCardError") { 57 | // Display error to customer 58 | console.log({ error: e.message }) 59 | } else { 60 | // Something else happened 61 | console.log({ error: e.type }) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | /* ./styles/globals.css */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | @font-face { 7 | font-family: "Eina"; 8 | src: url("/fonts/Eina.otf"); 9 | } 10 | 11 | 12 | @font-face { 13 | font-family: "Eina Light"; 14 | src: url("/fonts/Eina-Light.otf"); 15 | } 16 | 17 | @font-face { 18 | font-family: "Eina SemiBold"; 19 | src: url("/fonts/Eina-SemiBold.otf"); 20 | } 21 | 22 | @layer base { 23 | p, h1, h2, h3, h4, h5 { 24 | color: rgba(0, 0, 0, .8); 25 | font-family: Eina; 26 | } 27 | } 28 | 29 | .Toastify__progress-bar--default { 30 | background: #89bdf9 !important; 31 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // purge: ['./pages/**/*.js', './components/**/*.js'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: { 6 | spacing: { 7 | "72": "18rem", 8 | "80": "20rem", 9 | "88": "22rem", 10 | "96": "24rem", 11 | "104": "26rem", 12 | "112": "28rem", 13 | "120": "30rem", 14 | "124": "31rem", 15 | "128": "32rem", 16 | "132": "33rem", 17 | "136": "34rem", 18 | "140": "35rem", 19 | "144": "36rem", 20 | "fw": "1440px", 21 | }, 22 | fontSize: { 23 | 'xxs': '.6rem', 24 | 'smaller': '.95rem' 25 | }, 26 | fontFamily: { 27 | 'light': ['Eina Light'], 28 | 'semibold': ['Eina SemiBold'], 29 | }, 30 | screens: { 31 | 'mobile': '600px', 32 | 'c_large': '1200px', 33 | 'desktop': '1440px' 34 | }, 35 | width: { 36 | '28': '7rem', 37 | 'c_large': '1200px', 38 | "38":"10rem", 39 | "48":"12rem", 40 | "52":"13rem", 41 | "56":"14rem", 42 | "60":"15rem", 43 | "64": "16rem", 44 | "68": "17rem", 45 | "72": "18rem", 46 | "80": "20rem", 47 | "88": "22rem", 48 | "96": "24rem", 49 | "104": "26rem", 50 | "112": "28rem", 51 | "120": "30rem", 52 | "124": "31rem", 53 | "128": "32rem", 54 | "132": "33rem", 55 | "136": "34rem", 56 | "140": "35rem", 57 | "144": "36rem", 58 | 'flex-half': "calc((100%/2) - 15px)", 59 | 'flex-fourth': "calc((100% / 4) - 20px)" 60 | }, 61 | inset: { 62 | 'flexiblemargin': "calc((100vw - 1420px) / 2)", 63 | '100': '100px', 64 | '200': '200px', 65 | '250': '250px', 66 | '300': '300px', 67 | '400': '400px', 68 | '20': '20px', 69 | '22': '22px', 70 | '24': '24px', 71 | '26': '26px', 72 | '28': '28px', 73 | '30': '30px', 74 | '35': '35px', 75 | '40': '40px', 76 | '45': '45px', 77 | '45': '45px', 78 | '46': '46px', 79 | '47': '47px', 80 | '48': '48px', 81 | '49': '49px', 82 | '50': '50px', 83 | '51': '51px', 84 | '52':'52px', 85 | '53': '53px', 86 | '54':'54px', 87 | '55': '55px', 88 | '60': '60px' 89 | }, 90 | height: { 91 | 'hero': '500px', 92 | "48":"12rem", 93 | "52":"13rem", 94 | "56":"14rem", 95 | "60":"15rem", 96 | "64": "16rem", 97 | "68":"17rem", 98 | "72": "18rem", 99 | "80": "20rem", 100 | "88": "22rem", 101 | "96": "24rem", 102 | "104": "26rem", 103 | "112": "28rem", 104 | "120": "30rem", 105 | "124": "31rem", 106 | "128": "32rem", 107 | "132": "33rem", 108 | "136": "34rem", 109 | "140": "35rem", 110 | "144": "36rem", 111 | }, 112 | padding: { 113 | ".5": ".125rem" 114 | }, 115 | maxWidth: { 116 | "48":"12rem", 117 | "52":"13rem", 118 | "56":"14rem", 119 | "60":"15rem", 120 | "64": "16rem", 121 | "68":"17rem", 122 | "72": "18rem", 123 | "80": "20rem", 124 | "88": "22rem", 125 | "96": "24rem", 126 | "104": "26rem", 127 | "112": "28rem", 128 | "120": "30rem", 129 | "124": "31rem", 130 | "128": "32rem", 131 | "132": "33rem", 132 | "136": "34rem", 133 | "140": "35rem", 134 | "144": "36rem", 135 | "fw": "1440px", 136 | 'c_large': '1200px' 137 | }, 138 | maxHeight: { 139 | "36":"9rem", 140 | "40":"10rem", 141 | "44":"11rem", 142 | "48":"12rem", 143 | "52":"13rem", 144 | "56":"14rem", 145 | "60":"15rem", 146 | "64": "16rem", 147 | "68":"17rem", 148 | "72": "18rem", 149 | "80": "20rem", 150 | "88": "22rem", 151 | "96": "24rem", 152 | "104": "26rem", 153 | "112": "28rem", 154 | "120": "30rem", 155 | "124": "31rem", 156 | "128": "32rem", 157 | "132": "33rem", 158 | "136": "34rem", 159 | "140": "35rem", 160 | "144": "36rem", 161 | "fw": "1440px" 162 | }, 163 | zIndex: { 164 | '-2': '-2', 165 | '-4': '-4', 166 | '-6': '-6', 167 | '-12': '-12', 168 | }, 169 | backgroundColor: { 170 | 'primary': '#89bdf9', 171 | 'light': '#f5f5f5', 172 | 'light-200': '#f0f0f0', 173 | 'light-300': '#e8e8e8' 174 | }, 175 | lineHeight: { 176 | 'large': '54px' 177 | } 178 | }, 179 | }, 180 | plugins: [], 181 | } -------------------------------------------------------------------------------- /theme.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | primary: '#89bdf9' 3 | } 4 | 5 | export { 6 | colors 7 | } -------------------------------------------------------------------------------- /utils/categoryProvider.js: -------------------------------------------------------------------------------- 1 | import inventory from './inventory' 2 | 3 | async function fetchCategories () { 4 | const categories = inventory.reduce((acc, next) => { 5 | next.categories.map(category => { 6 | if (acc.includes(category)) return 7 | acc.push(category) 8 | }) 9 | return acc 10 | }, []) 11 | return Promise.resolve(categories) 12 | } 13 | 14 | export default fetchCategories -------------------------------------------------------------------------------- /utils/currencyProvider.js: -------------------------------------------------------------------------------- 1 | const DENOMINATION = '$' 2 | 3 | export default DENOMINATION -------------------------------------------------------------------------------- /utils/helpers.js: -------------------------------------------------------------------------------- 1 | function slugify(string) { 2 | const a = 'àáäâãåăæąçćčđďèéěėëêęğǵḧìíïîįłḿǹńňñòóöôœøṕŕřßşśšșťțùúüûǘůűūųẃẍÿýźžż·/_,:;' 3 | const b = 'aaaaaaaaacccddeeeeeeegghiiiiilmnnnnooooooprrsssssttuuuuuuuuuwxyyzzz------' 4 | const p = new RegExp(a.split('').join('|'), 'g') 5 | 6 | return string.toString().toLowerCase() 7 | .replace(/\s+/g, '-') // Replace spaces with - 8 | .replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters 9 | .replace(/&/g, '-and-') // Replace & with 'and' 10 | .replace(/[^\w-]+/g, '') // Remove all non-word characters 11 | .replace(/--+/g, '-') // Replace multiple - with single - 12 | .replace(/^-+/, '') // Trim - from start of text 13 | .replace(/-+$/, '') // Trim - from end of text 14 | } 15 | 16 | function titleIfy(slug) { 17 | var words = slug.split('-') 18 | for (var i = 0; i < words.length; i++) { 19 | var word = words[i] 20 | words[i] = word.charAt(0).toUpperCase() + word.slice(1) 21 | } 22 | return words.join(' ') 23 | } 24 | 25 | function getTrimmedString(string, length = 8) { 26 | if (string.length <= length) { 27 | return string 28 | } else { 29 | return string.substring(0, length) + '...' 30 | } 31 | } 32 | 33 | export { 34 | slugify, titleIfy, getTrimmedString 35 | } -------------------------------------------------------------------------------- /utils/inventory.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | 3 | let inventory = [ 4 | { 5 | categories: ['new arrivals'], name: 'Timber Gray Sofa 2.0', price: '1000', image: '/products/couch1.png', description: 'Stay a while. The Timber charme chocolat sofa is set atop an oak trim and flaunts fluffy leather back and seat cushions. Over time, this brown leather sofa’s full-aniline upholstery will develop a worn-in vintage look. Snuggle up with your cutie (animal or human) and dive into a bowl of popcorn. This sofa is really hard to leave. Natural color variations, wrinkles and creases are part of the unique characteristics of this leather. It will develop a relaxed vintage look with regular use.', brand: 'Jason Bourne', currentInventory: 4 }, 6 | { 7 | categories: ['sofas', 'living room'], name: 'Carmel Brown Sofa', price: '1000', image: '/products/couch5.png', description: 'Stay a while. The Timber charme chocolat sofa is set atop an oak trim and flaunts fluffy leather back and seat cushions. Over time, this brown leather sofa’s full-aniline upholstery will develop a worn-in vintage look. Snuggle up with your cutie (animal or human) and dive into a bowl of popcorn. This sofa is really hard to leave. Natural color variations, wrinkles and creases are part of the unique characteristics of this leather. It will develop a relaxed vintage look with regular use.' , brand: 'Jason Bourne' , currentInventory: 2 }, 8 | { 9 | categories: ['new arrivals', 'sofas'], name: 'Mod Leather Sofa', price: '800', image: '/products/couch6.png', description: 'Easy to love. The Sven in birch ivory looks cozy and refined, like a sweater that a fancy lady wears on a coastal vacation. This ivory loveseat has a tufted bench seat, loose back pillows and bolsters, solid walnut legs, and is ready to make your apartment the adult oasis you dream of. Nestle it with plants, an ottoman, an accent chair, or 8 dogs. Your call.', brand: 'Jason Bourne', currentInventory: 8 }, 10 | { 11 | categories: ['new arrivals', 'sofas'], name: 'Thetis Gray Love Seat', price: '900', image: '/products/couch7.png', description: 'You know your dad’s incredible vintage bomber jacket? The Nirvana dakota tan leather sofa is that jacket, but in couch form. With super-plush down-filled cushions, a corner-blocked wooden frame, and a leather patina that only gets better with age, the Nirvana will have you looking cool and feeling peaceful every time you take a seat. Looks pretty great with a sheepskin throw, if we may say so. With use, this leather will become softer and more wrinkled and the cushions will take on a lived-in look, like your favorite leather jacket.' , brand: 'Jason Bourne', currentInventory: 10}, 12 | { 13 | categories: ['on sale', 'sofas'], name: 'Sven Tan Matte', price: '1200', image: '/products/couch8.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne' , currentInventory: 7 }, 14 | { 15 | categories: ['on sale', 'sofas'], name: 'Otis Malt Sofa', price: '500', image: '/products/couch9.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.' , brand: 'Jason Bourne', currentInventory: 13}, 16 | { 17 | categories: ['on sale', 'sofas'], name: 'Ceni Brown 3 Seater', price: '650', image: '/products/couch10.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.' , brand: 'Jason Bourne', currentInventory: 9}, 18 | { 19 | categories: ['sofas', 'living room'], name: 'Jameson Jack Lounger', price: '1230', image: '/products/couch11.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 24 }, 20 | 21 | { 22 | categories: ['sofas'], name: 'Galaxy Blue Sofa', price: '800', image: '/products/couch2.png', description: 'Easy to love. The Sven in birch ivory looks cozy and refined, like a sweater that a fancy lady wears on a coastal vacation. This ivory loveseat has a tufted bench seat, loose back pillows and bolsters, solid walnut legs, and is ready to make your apartment the adult oasis you dream of. Nestle it with plants, an ottoman, an accent chair, or 8 dogs. Your call.', brand: 'Jason Bourne', currentInventory: 43 }, 23 | { 24 | categories: ['new arrivals', 'sofas'], name: 'Markus Green Love Seat', price: '900', image: '/products/couch3.png', description: 'You know your dad’s incredible vintage bomber jacket? The Nirvana dakota tan leather sofa is that jacket, but in couch form. With super-plush down-filled cushions, a corner-blocked wooden frame, and a leather patina that only gets better with age, the Nirvana will have you looking cool and feeling peaceful every time you take a seat. Looks pretty great with a sheepskin throw, if we may say so. With use, this leather will become softer and more wrinkled and the cushions will take on a lived-in look, like your favorite leather jacket.', brand: 'Jason Bourne' , currentInventory: 2}, 25 | { 26 | categories: ['on sale', 'sofas'], name: 'Dabit Matte Black', price: '1200', image: '/products/couch4.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', currentInventory: 14 }, 27 | 28 | { 29 | categories: ['on sale', 'chairs'], name: 'Embrace Blue', price: '300', image: '/products/chair1.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.' , brand: 'Jason Bourne', currentInventory: 12 }, 30 | { 31 | categories: ['on sale', 'chairs'], name: 'Nord Lounger', price: '825', image: '/products/chair2.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.' , brand: 'Jason Bourne', currentInventory: 13}, 32 | { 33 | categories: ['on sale', 'chairs'], name: 'Ceni Matte Oranve', price: '720', image: '/products/chair3.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.' , brand: 'Jason Bourne', currentInventory: 33}, 34 | { 35 | categories: ['on sale', 'chairs'], name: 'Abisko Green Recliner', price: '2000', image: '/products/chair4.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 23 }, 36 | { categories: ['on sale', 'chairs'], name: 'Denim on Denim Single', price: '1100', image: '/products/chair5.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.' , brand: 'Jason Bourne', currentInventory: 13}, 37 | { categories: ['on sale', 'chairs'], name: 'Levo Tan Lounge Chair', price: '600', image: '/products/chair6.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 15 }, 38 | 39 | { categories: ['on sale', 'chairs'], name: 'Anime Tint Recliner', price: '775', image: '/products/chair7.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 44 }, 40 | { categories: ['on sale', 'chairs'], name: 'Josh Jones Red Chair', price: '1200', image: '/products/chair8.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 17 }, 41 | { categories: ['on sale', 'chairs'], name: 'Black Sand Lounge', price: '1600', image: '/products/chair9.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 28 }, 42 | { categories: ['on sale', 'chairs'], name: 'Mint Beige Workchair', price: '550', image: '/products/chair10.png', description: 'You don’t have to go outside to be rugged. The Cigar rawhide sofa features a sturdy corner-blocked wooden frame and raw seams for that Malboro-person look. This brown leather sofa is cozy in a cottage, cabin, or a condo. And the leather (the leather!) becomes more beautiful with use: subtle character markings such as insect bites, healed scars, and grain variation reflects a real vintage. Saddle up and pass the remote.', brand: 'Jason Bourne', currentInventory: 31 }, // { 43 | ] 44 | 45 | inventory.map(i => { 46 | i.id = uuid() 47 | return i 48 | }) 49 | 50 | export default inventory -------------------------------------------------------------------------------- /utils/inventoryByCategory.js: -------------------------------------------------------------------------------- 1 | function inventoryByCategory (inventory) { 2 | return inventory.reduce((acc, next) => { 3 | const categories = next.categories 4 | categories.forEach(c => { 5 | if (acc[c]) { 6 | acc[c].items.push(next) 7 | } else { 8 | acc[c] = {} 9 | acc[c].items = [] 10 | acc[c].items.push(next) 11 | } 12 | }) 13 | return acc 14 | }, {}) 15 | } 16 | 17 | export { 18 | inventoryByCategory 19 | } -------------------------------------------------------------------------------- /utils/inventoryForCategory.js: -------------------------------------------------------------------------------- 1 | import { fetchInventory } from './inventoryProvider' 2 | import { inventoryByCategory } from './inventoryByCategory' 3 | 4 | async function inventoryForCategory (category) { 5 | const inventory = await fetchInventory() 6 | const byCategory = inventoryByCategory(inventory) 7 | return byCategory[category].items 8 | } 9 | 10 | export default inventoryForCategory -------------------------------------------------------------------------------- /utils/inventoryProvider.js: -------------------------------------------------------------------------------- 1 | import inventory from './inventory' 2 | 3 | /* 4 | Inventory items should adhere to the following schema: 5 | type Product { 6 | id: ID! 7 | categories: [String]! 8 | price: Float! 9 | name: String! 10 | image: String! 11 | description: String! 12 | currentInventory: Int! 13 | brand: String 14 | sku: ID 15 | } 16 | */ 17 | 18 | async function fetchInventory() { 19 | // const inventory = API.get(apiUrl) 20 | return Promise.resolve(inventory) 21 | } 22 | 23 | export { 24 | fetchInventory, inventory as staticInventory 25 | } --------------------------------------------------------------------------------