├── .gitignore ├── LICENSE ├── README.md ├── components ├── BackToProductButton.js ├── CartTable.js ├── CheckOutButton.js ├── Footer.js ├── Layout.js ├── Nav.js ├── PageTitle.js ├── Price.js ├── ProductCard.js ├── ProductDetails.js ├── ProductForm.js ├── ProductImage.js ├── ProductInfo.js ├── ProductListings.js ├── ProductSection.js ├── SEO.js └── StoreHeading.js ├── context └── Store.js ├── jsconfig.json ├── lib └── shopify.js ├── next.config.js ├── package.json ├── pages ├── _app.js ├── _document.js ├── cart.js ├── index.js └── products │ └── [product].js ├── postcss.config.js ├── public ├── favicon.ico ├── icon.svg ├── icons │ ├── apple-icon.png │ ├── icon-144x144.png │ ├── icon-150x150.png │ ├── icon-16x16.png │ ├── icon-192x192.png │ ├── icon-32x32.png │ ├── icon-512x512.png │ └── icon-70x70.png ├── images │ ├── demo-store.gif │ ├── desktop-lighthouse.png │ ├── main.jpg │ ├── mobile-lighthouse.png │ ├── responsive-cart.gif │ └── responsive-main.gif └── manifest.json ├── styles └── globals.css ├── tailwind.config.js ├── utils └── helpers.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 | # pwa 37 | **/public/precache.*.*.js 38 | **/public/sw.js 39 | **/public/workbox-*.js 40 | **/public/worker-*.js 41 | **/public/precache.*.*.js.map 42 | **/public/sw.js.map 43 | **/public/workbox-*.js.map 44 | **/public/worker-*.js.map 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bilal Tahir 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js + Tailwind CSS + Shopify Starter 2 | 3 |
4 | 5 |

6 | Live Demo • 7 | See more starters • 8 | Follow me on Twitter 9 |

10 |
11 | 12 | This is a fully functional eCommerce store that uses Next.js + Tailwind CSS in the front end and leverages the Shopify Storefront API to interact with your Shopify backend. You can see a Live Demo [here](https://doggystickers.vercel.app/ "Shopify store"). 13 | 14 | We use GraphQL to query our Shopify data and store the cart information in localStorage to persist user session. Finally - we use Shopify Checkout to let the user 15 | purchase the items. You can see this play out in the example store. Yes - the store is functional and you can buy the stickers. :smiley: 16 | 17 | ## High Performance 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
DesktopMobile
33 | 34 | ## Mobile Responsive 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |
ListingsCart
50 | 51 | 52 | ## The Tech 53 | 54 | * Next.js + Tailwind CSS 55 | * GraphQL 56 | * localStorage to persist user session 57 | * Shopify 58 | * Vercel 59 | * Font Awesome Icons 60 | * Josefin Sans Google Font 61 | 62 | ## How to use 63 | 64 | By default, the store is set to query and show all products in one collection. 65 | You can extend this to query multiple collections or your whole store. 66 | 67 | #### A note on pagination in the GraphQL queries 68 | 69 | The graphQL queries are all hardcoded to pull the maximum number of products/variants/images which 70 | is set to 250 by Shopify. I did this to keep things simple. Pagination would have made the queries complicated 71 | and 250 items is enough for most use cases. 72 | 73 | If you require pagination you will have to keep track of the [cursor](https://youtu.be/S37WsC8GzSA "graphql pagination") field and keep querying the data until you fetch all items. 74 | 75 | ### Setup Environment variables 76 | 77 | Create a .env.local file in the root directory. You need to add these 4 variables: 78 | 79 | ``` 80 | NEXT_PUBLIC_SHOPIFY_STORE_FRONT_ACCESS_TOKEN= 81 | NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN= 82 | NEXT_PUBLIC_SHOPIFY_COLLECTION= 83 | NEXT_PUBLIC_LOCAL_STORAGE_NAME= 84 | ``` 85 | 86 | The NEXT_PUBLIC_SHOPIFY_STORE_FRONT_ACCESS_TOKEN and NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN (it will be something like DOMAIN_NAME.myshopify.com) are needed to access 87 | the Shopify Storefront API (make sure you have set it up in your Shopify store. See [docs](https://shopify.dev/docs/storefront-api/getting-started "Shopify store") for more information. 88 | 89 | NEXT_PUBLIC_SHOPIFY_COLLECTION is the name of the collection you want to pull in and NEXT_PUBLIC_LOCAL_STORAGE_NAME is the name of the key 90 | your users will store their cart information under. The exact name isn't that important although I suggest you make it unique so 91 | it is less likely to clash with other stored objects. Something like yourStoreNameShopifyStore where yourStoreName is your shopify store name will suffice. 92 | 93 | ### Installation 94 | 95 | Change into the project directory and run the following command: 96 | 97 | ``` 98 | yarn && yarn dev 99 | ``` 100 | 101 | ### Update Site Metadata 102 | 103 | You can update your site metadata in the next.config.js file. 104 | 105 | ``` 106 | env: { 107 | siteTitle: 'Your Company', 108 | siteDescription: 'Your company description.', 109 | siteKeywords: 'your company keywords', 110 | siteUrl: 'https://doggystickers.xyz', 111 | siteImagePreviewUrl: '/images/main.jpg', 112 | twitterHandle: '@your_handle' 113 | } 114 | ``` 115 | 116 | ### Update Colors 117 | 118 | You can update the color palette in tailwind.config.js file. 119 | 120 | ``` 121 | colors: { 122 | palette: { 123 | lighter: '', 124 | light: '', 125 | primary: '', 126 | dark: '', 127 | }, 128 | }, 129 | ``` 130 | ### Update Progressive Web App (PWA) data 131 | 132 | Update the manifest.json file and the icons under the public/images/icons folder. 133 | 134 | You can use free tools online such as https://realfavicongenerator.net/ to quickly generate all the different icon sizes and favicon.ico file. 135 | 136 | ### Deployment 137 | 138 | You can deploy this using any number of services. Vercel and Netlify are the ones I prefer and very easy to setup and sync with your Github repo. 139 | 140 | ### Credit 141 | 142 | The store was inspired by the awesome [Gatsby Swag Store](https://github.com/gatsbyjs/store.gatsbyjs.org "gatsby store") as well 143 | as countless other devs much more capable than me who put out their awesome work for free. 144 | 145 | ### License 146 | 147 | I have open sourced this code under the MIT License in the hope that if this helps people navigate their way around JAMStack eCommerce stores 148 | as the Gatsby Swag Store did for me when I first started out. 149 | 150 | ### Buy Me Coffee! :coffee: 151 | 152 | If you did find this useful and want to show your appreciation you can buy me a [coffee](https://www.buymeacoffee.com/neum "coffee") :smiley: 153 | 154 | You can also buy some Doggy Stickers from the [store](https://doggystickers.vercel.app/ "store")! :dog: 155 | -------------------------------------------------------------------------------- /components/BackToProductButton.js: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | import { faArrowLeft } from '@fortawesome/free-solid-svg-icons' 3 | import Link from 'next/link' 4 | 5 | function BackToProductButton() { 6 | return ( 7 | 8 | 13 | 14 | Back To All Products 15 | 16 | 17 | ) 18 | } 19 | 20 | export default BackToProductButton 21 | -------------------------------------------------------------------------------- /components/CartTable.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useUpdateCartQuantityContext } from '@/context/Store' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faTimes } from '@fortawesome/free-solid-svg-icons' 5 | import Link from 'next/link' 6 | import Price from '@/components/Price' 7 | import { getCartSubTotal } from '@/utils/helpers' 8 | 9 | function CartTable({ cart }) { 10 | const updateCartQuantity = useUpdateCartQuantityContext() 11 | const [cartItems, setCartItems] = useState([]) 12 | const [subtotal, setSubtotal] = useState(0) 13 | 14 | useEffect(() => { 15 | setCartItems(cart) 16 | setSubtotal(getCartSubTotal(cart)) 17 | }, [cart]) 18 | 19 | function updateItem(id, quantity) { 20 | updateCartQuantity(id, quantity) 21 | } 22 | 23 | return ( 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {cartItems.map(item => ( 36 | 37 | 51 | 64 | 71 | 80 | 81 | ))} 82 | { 83 | subtotal === 0 ? 84 | null 85 | : 86 | 87 | 88 | 89 | 96 | 97 | 98 | } 99 | 100 |
ProductQuantityPriceRemove
38 | {item.productImage.altText} 45 | 46 | 47 | {item.productTitle}, {item.variantTitle} 48 | 49 | 50 | 52 | updateItem(item.variantId, e.target.value)} 61 | className="text-gray-900 form-input border border-gray-300 w-16 rounded-sm focus:border-palette-light focus:ring-palette-light" 62 | /> 63 | 65 | 70 | 72 | 79 |
Subtotal 90 | 95 |
101 |
102 | ) 103 | } 104 | 105 | export default CartTable 106 | -------------------------------------------------------------------------------- /components/CheckOutButton.js: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | import { faArrowRight } from '@fortawesome/free-solid-svg-icons' 3 | 4 | function CheckOutButton({ webUrl }) { 5 | return ( 6 | 12 | Check Out 13 | 14 | 15 | ) 16 | } 17 | 18 | export default CheckOutButton 19 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | import { faHeart } from '@fortawesome/free-solid-svg-icons' 3 | 4 | function Footer() { 5 | return ( 6 | 17 | ) 18 | } 19 | 20 | export default Footer 21 | -------------------------------------------------------------------------------- /components/Layout.js: -------------------------------------------------------------------------------- 1 | import { CartProvider } from '@/context/Store' 2 | import Nav from '@/components/Nav' 3 | import Footer from '@/components/Footer' 4 | 5 | function Layout({ children }) { 6 | 7 | return ( 8 | 9 |
10 |
18 |
19 | ) 20 | } 21 | 22 | export default Layout 23 | -------------------------------------------------------------------------------- /components/Nav.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import Link from 'next/link' 3 | import { useCartContext } from '@/context/Store' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { faShoppingCart } from '@fortawesome/free-solid-svg-icons' 6 | 7 | function Nav() { 8 | const cart = useCartContext()[0] 9 | const [cartItems, setCartItems] = useState(0) 10 | 11 | useEffect(() => { 12 | let numItems = 0 13 | cart.forEach(item => { 14 | numItems += item.variantQuantity 15 | }) 16 | setCartItems(numItems) 17 | }, [cart]) 18 | 19 | return ( 20 |
21 |
22 | 23 | 24 |

25 | logo 26 | 27 | {process.env.siteTitle} 28 | 29 |

30 |
31 | 32 |
33 | 37 | 38 | 39 | { 40 | cartItems === 0 ? 41 | null 42 | : 43 |
46 | {cartItems} 47 |
48 | } 49 |
50 | 51 |
52 |
53 |
54 | ) 55 | } 56 | 57 | export default Nav 58 | -------------------------------------------------------------------------------- /components/PageTitle.js: -------------------------------------------------------------------------------- 1 | function PageTitle({ text }) { 2 | return ( 3 |

4 | {text} 5 |

6 | ) 7 | } 8 | 9 | export default PageTitle 10 | -------------------------------------------------------------------------------- /components/Price.js: -------------------------------------------------------------------------------- 1 | function Price({currency, num, numSize }) { 2 | return ( 3 | <> 4 | {currency}{num} 5 | 6 | ) 7 | } 8 | 9 | export default Price 10 | -------------------------------------------------------------------------------- /components/ProductCard.js: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import Price from '@/components/Price' 4 | 5 | function ProductCard({ product }) { 6 | const handle = product.node.handle 7 | const title = product.node.title 8 | const description = product.node.description 9 | const price = product.node.variants.edges[0].node.price 10 | 11 | const imageNode = product.node.images.edges[0].node 12 | 13 | return ( 14 | 18 | 19 |
20 | {imageNode.altText} 26 |
27 |
28 |
29 | {title} 30 |
31 |
32 | {description} 33 |
34 |
38 | 43 |
44 |
45 |
46 | 47 | ) 48 | } 49 | 50 | export default ProductCard 51 | -------------------------------------------------------------------------------- /components/ProductDetails.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import BackToProductButton from '@/components/BackToProductButton' 3 | import ProductInfo from '@/components/ProductInfo' 4 | import ProductForm from '@/components/ProductForm' 5 | 6 | function ProductDetails({ productData }) { 7 | const [variantPrice, setVariantPrice] = useState(productData.variants.edges[0].node.price) 8 | 9 | return ( 10 |
11 | 12 | 17 | 24 |
25 | ) 26 | } 27 | 28 | export default ProductDetails 29 | -------------------------------------------------------------------------------- /components/ProductForm.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faShoppingCart } from '@fortawesome/free-solid-svg-icons' 4 | import { useCartContext, useAddToCartContext } from '@/context/Store' 5 | 6 | function ProductForm({ title, handle, variants, setVariantPrice, mainImg }) { 7 | const [quantity, setQuantity] = useState(1) 8 | const [variantId, setVariantId] = useState(variants[0].node.id) 9 | const [variant, setVariant] = useState(variants[0]) 10 | const isLoading = useCartContext()[2] 11 | const addToCart = useAddToCartContext() 12 | 13 | const atcBtnStyle = isLoading ? 14 | `pt-3 pb-2 bg-palette-primary text-white w-full mt-2 rounded-sm font-primary font-semibold text-xl flex 15 | justify-center items-baseline hover:bg-palette-dark opacity-25 cursor-none` 16 | : 17 | `pt-3 pb-2 bg-palette-primary text-white w-full mt-2 rounded-sm font-primary font-semibold text-xl flex 18 | justify-center items-baseline hover:bg-palette-dark` 19 | 20 | function handleSizeChange(e) { 21 | setVariantId(e) 22 | // send back size change 23 | const selectedVariant = variants.filter(v => v.node.id === e).pop() 24 | setVariantPrice(selectedVariant.node.price) 25 | 26 | // update variant 27 | setVariant(selectedVariant) 28 | } 29 | 30 | async function handleAddToCart() { 31 | const varId = variant.node.id 32 | // update store context 33 | if (quantity !== '') { 34 | addToCart({ 35 | productTitle: title, 36 | productHandle: handle, 37 | productImage: mainImg, 38 | variantId: varId, 39 | variantPrice: variant.node.price, 40 | variantTitle: variant.node.title, 41 | variantQuantity: quantity 42 | }) 43 | } 44 | } 45 | 46 | function updateQuantity(e) { 47 | if (e === '') { 48 | setQuantity('') 49 | } else { 50 | setQuantity(Math.floor(e)) 51 | } 52 | } 53 | 54 | return ( 55 |
56 |
57 |
58 | 59 | updateQuantity(e.target.value)} 68 | className="text-gray-900 form-input border border-gray-300 w-16 rounded-sm focus:border-palette-light focus:ring-palette-light" 69 | /> 70 |
71 |
72 | 73 | 92 |
93 |
94 | 102 |
103 | ) 104 | } 105 | 106 | export default ProductForm 107 | -------------------------------------------------------------------------------- /components/ProductImage.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react' 2 | import Image from 'next/image' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons' 5 | 6 | function ProductImage({ images }) { 7 | const [mainImg, setMainImg] = useState(images[0].node) 8 | const ref = useRef() 9 | 10 | function scroll(scrollOffset) { 11 | ref.current.scrollLeft += scrollOffset 12 | } 13 | 14 | return ( 15 |
16 |
17 | {mainImg.altText} 23 |
24 |
25 | 32 |
37 | { 38 | images.map((imgItem, index) => ( 39 | 51 | )) 52 | } 53 |
54 | 61 |
62 |
63 | ) 64 | } 65 | 66 | export default ProductImage 67 | -------------------------------------------------------------------------------- /components/ProductInfo.js: -------------------------------------------------------------------------------- 1 | import Price from '@/components/Price' 2 | 3 | function ProductInfo({ title, description, price }) { 4 | return ( 5 |
6 |

7 | {title} 8 |

9 |

10 | {description} 11 |

12 |
13 | 18 |
19 |
20 | ) 21 | } 22 | 23 | export default ProductInfo 24 | -------------------------------------------------------------------------------- /components/ProductListings.js: -------------------------------------------------------------------------------- 1 | import ProductCard from '@/components/ProductCard' 2 | 3 | function ProductListings({ products }) { 4 | return ( 5 |
6 | { 7 | products.map((product, index) => ( 8 | 9 | )) 10 | } 11 |
12 | ) 13 | } 14 | 15 | export default ProductListings 16 | -------------------------------------------------------------------------------- /components/ProductSection.js: -------------------------------------------------------------------------------- 1 | import ProductImage from '@/components/ProductImage' 2 | import ProductDetails from '@/components/ProductDetails' 3 | 4 | function ProductSection({ productData }) { 5 | return ( 6 |
7 | 8 | 9 |
10 | ) 11 | } 12 | 13 | export default ProductSection 14 | -------------------------------------------------------------------------------- /components/SEO.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | function SEO({ title }) { 4 | // customize meta properties 5 | // you can pass them as an argument like title in case you want to change for each page 6 | const description = process.env.siteDescription 7 | const keywords = process.env.siteKeywords 8 | const siteURL = process.env.siteUrl 9 | const twitterHandle = process.env.twitterHandle 10 | const imagePreview = `${siteURL}/${process.env.siteImagePreviewUrl}` 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | {/* Twitter */} 19 | 20 | 21 | 22 | {/* Open Graph */} 23 | 24 | 25 | 26 | 27 | 28 | {title} 29 | 30 | 31 | 38 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export default SEO 52 | -------------------------------------------------------------------------------- /components/StoreHeading.js: -------------------------------------------------------------------------------- 1 | import PageTitle from '@/components/PageTitle' 2 | 3 | function StoreHeading() { 4 | return ( 5 |
6 | 7 |

8 | Times are tough. Liven up your home with some cute Doggy Stickers. 🐶 9 |

10 |
11 | ) 12 | } 13 | 14 | export default StoreHeading 15 | -------------------------------------------------------------------------------- /context/Store.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect } from 'react' 2 | import { createShopifyCheckout, updateShopifyCheckout, setLocalData, saveLocalData } from '@/utils/helpers' 3 | 4 | const CartContext = createContext() 5 | const AddToCartContext = createContext() 6 | const UpdateCartQuantityContext = createContext() 7 | 8 | export function useCartContext() { 9 | return useContext(CartContext) 10 | } 11 | 12 | export function useAddToCartContext() { 13 | return useContext(AddToCartContext) 14 | } 15 | 16 | export function useUpdateCartQuantityContext() { 17 | return useContext(UpdateCartQuantityContext) 18 | } 19 | 20 | export function CartProvider({ children }) { 21 | const [cart, setCart] = useState([]) 22 | const [checkoutId, setCheckoutId] = useState('') 23 | const [checkoutUrl, setCheckoutUrl] = useState('') 24 | const [isLoading, setisLoading] = useState(false) 25 | 26 | useEffect(() => { 27 | setLocalData(setCart, setCheckoutId, setCheckoutUrl) 28 | }, []) 29 | 30 | useEffect(() => { 31 | // do this to make sure multiple tabs are always in sync 32 | const onReceiveMessage = (e) => { 33 | console.log(e) 34 | setLocalData(setCart, setCheckoutId, setCheckoutUrl) 35 | } 36 | 37 | window.addEventListener("storage", onReceiveMessage); 38 | return () => { 39 | window.removeEventListener("storage", onReceiveMessage); 40 | } 41 | }, []) 42 | 43 | async function addToCart(newItem) { 44 | setisLoading(true) 45 | // empty cart 46 | if (cart.length === 0) { 47 | setCart([ 48 | ...cart, 49 | newItem 50 | ]) 51 | 52 | const response = await createShopifyCheckout(newItem) 53 | setCheckoutId(response.id) 54 | setCheckoutUrl(response.webUrl) 55 | saveLocalData(newItem, response.id, response.webUrl) 56 | 57 | } else { 58 | let newCart = [...cart] 59 | let itemAdded = false 60 | // loop through all cart items to check if variant 61 | // already exists and update quantity 62 | newCart.map(item => { 63 | if (item.variantId === newItem.variantId) { 64 | item.variantQuantity += newItem.variantQuantity 65 | itemAdded = true 66 | } 67 | }) 68 | 69 | let newCartWithItem = [...newCart] 70 | if (itemAdded) { 71 | } else { 72 | // if its a new item than add it to the end 73 | newCartWithItem = [...newCart, newItem] 74 | } 75 | 76 | setCart(newCartWithItem) 77 | await updateShopifyCheckout(newCartWithItem, checkoutId) 78 | saveLocalData(newCartWithItem, checkoutId, checkoutUrl) 79 | } 80 | setisLoading(false) 81 | } 82 | 83 | async function updateCartItemQuantity(id, quantity) { 84 | setisLoading(true) 85 | let newQuantity = Math.floor(quantity) 86 | if (quantity === '') { 87 | newQuantity = '' 88 | } 89 | let newCart = [...cart] 90 | newCart.forEach(item => { 91 | if (item.variantId === id) { 92 | item.variantQuantity = newQuantity 93 | } 94 | }) 95 | 96 | // take out zeroes items 97 | newCart = newCart.filter(i => i.variantQuantity !== 0) 98 | setCart(newCart) 99 | 100 | await updateShopifyCheckout(newCart, checkoutId) 101 | saveLocalData(newCart, checkoutId, checkoutUrl) 102 | setisLoading(false) 103 | } 104 | 105 | return ( 106 | 107 | 108 | 109 | {children} 110 | 111 | 112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/components/*": ["components/*"], 6 | "@/lib/*": ["lib/*"], 7 | "@/styles/*": ["styles/*"], 8 | "@/context/*": ["context/*"], 9 | "@/utils/*": ["utils/*"], 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/shopify.js: -------------------------------------------------------------------------------- 1 | const domain = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN 2 | const storefrontAccessToken = process.env.NEXT_PUBLIC_SHOPIFY_STORE_FRONT_ACCESS_TOKEN 3 | const collection = process.env.NEXT_PUBLIC_SHOPIFY_COLLECTION 4 | 5 | async function callShopify(query) { 6 | const fetchUrl = `https://${domain}/api/2021-01/graphql.json`; 7 | 8 | const fetchOptions = { 9 | endpoint: fetchUrl, 10 | method: "POST", 11 | headers: { 12 | "X-Shopify-Storefront-Access-Token": storefrontAccessToken, 13 | "Accept": "application/json", 14 | "Content-Type": "application/json", 15 | }, 16 | body: JSON.stringify({ query }), 17 | }; 18 | 19 | try { 20 | const data = await fetch(fetchUrl, fetchOptions).then((response) => 21 | response.json(), 22 | ); 23 | return data; 24 | } catch (error) { 25 | throw new Error("Could not fetch products!"); 26 | } 27 | } 28 | 29 | export async function getAllProductsInCollection() { 30 | const query = 31 | `{ 32 | collectionByHandle(handle: "${collection}") { 33 | id 34 | title 35 | products(first: 250) { 36 | edges { 37 | node { 38 | id 39 | title 40 | description 41 | handle 42 | images(first: 250) { 43 | edges { 44 | node { 45 | id 46 | originalSrc 47 | height 48 | width 49 | altText 50 | } 51 | } 52 | } 53 | variants(first: 250) { 54 | edges { 55 | node { 56 | id 57 | title 58 | price 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }` 67 | ; 68 | 69 | const response = await callShopify(query); 70 | 71 | const allProducts = response.data.collectionByHandle.products.edges 72 | ? response.data.collectionByHandle.products.edges 73 | : []; 74 | 75 | return allProducts; 76 | } 77 | 78 | export async function getProductSlugs() { 79 | const query = 80 | `{ 81 | collectionByHandle(handle: "${collection}") { 82 | products(first: 250) { 83 | edges { 84 | node { 85 | handle 86 | } 87 | } 88 | } 89 | } 90 | }` 91 | ; 92 | 93 | const response = await callShopify(query); 94 | 95 | const slugs = response.data.collectionByHandle.products.edges 96 | ? response.data.collectionByHandle.products.edges 97 | : []; 98 | 99 | return slugs; 100 | } 101 | 102 | export async function getProduct(handle) { 103 | const query = 104 | `{ 105 | productByHandle(handle: "${handle}") { 106 | id 107 | title 108 | handle 109 | description 110 | images(first: 250) { 111 | edges { 112 | node { 113 | id 114 | originalSrc 115 | height 116 | width 117 | altText 118 | } 119 | } 120 | } 121 | variants(first: 250) { 122 | edges { 123 | node { 124 | id 125 | title 126 | price 127 | } 128 | } 129 | } 130 | } 131 | }` 132 | ; 133 | 134 | const response = await callShopify(query); 135 | 136 | const product = response.data.productByHandle 137 | ? response.data.productByHandle 138 | : []; 139 | 140 | return product; 141 | } 142 | 143 | export async function createCheckout(id, quantity) { 144 | const query = 145 | `mutation 146 | { 147 | checkoutCreate(input: { 148 | lineItems: [{ variantId: "${id}", quantity: ${quantity} }] 149 | }) { 150 | checkout { 151 | id 152 | webUrl 153 | lineItems(first: 250) { 154 | edges { 155 | node { 156 | id 157 | title 158 | quantity 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | ` 166 | ; 167 | 168 | const response = await callShopify(query); 169 | 170 | const checkout = response.data.checkoutCreate.checkout 171 | ? response.data.checkoutCreate.checkout 172 | : []; 173 | 174 | return checkout; 175 | } 176 | 177 | export async function updateCheckout(id, lineItems) { 178 | const formattedLineItems = lineItems.map(item => { 179 | return `{ 180 | variantId: "${item.variantId}", 181 | quantity:${item.quantity} 182 | }` 183 | }) 184 | 185 | const query = 186 | `mutation 187 | { 188 | checkoutLineItemsReplace(lineItems: [${formattedLineItems}], checkoutId: "${id}") { 189 | checkout { 190 | id 191 | webUrl 192 | lineItems(first: 250) { 193 | edges { 194 | node { 195 | id 196 | title 197 | quantity 198 | } 199 | } 200 | } 201 | } 202 | } 203 | } 204 | ` 205 | ; 206 | 207 | const response = await callShopify(query); 208 | 209 | const checkout = response.data.checkoutLineItemsReplace.checkout 210 | ? response.data.checkoutLineItemsReplace.checkout 211 | : []; 212 | 213 | return checkout; 214 | } 215 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withPWA = require('next-pwa'); 2 | 3 | module.exports = withPWA({ 4 | future: { webpack5: true }, 5 | pwa: { 6 | dest: 'public', 7 | disable: process.env.NODE_ENV === 'development', 8 | }, 9 | env: { 10 | siteTitle: 'Doggy Stickers', 11 | siteDescription: 'Get some Doggy Stickers!', 12 | siteKeywords: 'dog, stickers, fun', 13 | siteUrl: 'https://www.doggystickers.xyz', 14 | siteImagePreviewUrl: '/images/main.jpg', 15 | twitterHandle: '@deepwhitman' 16 | }, 17 | images: { 18 | domains: ['cdn.shopify.com'], 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-tailwindcss", 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 | "@fortawesome/fontawesome-svg-core": "^1.2.34", 12 | "@fortawesome/free-brands-svg-icons": "^5.15.2", 13 | "@fortawesome/free-solid-svg-icons": "^5.15.2", 14 | "@fortawesome/react-fontawesome": "^0.1.14", 15 | "@tailwindcss/forms": "^0.3.2", 16 | "autoprefixer": "^10.0.4", 17 | "next": "latest", 18 | "next-pwa": "^5.0.5", 19 | "postcss": "^8.1.10", 20 | "react": "^17.0.1", 21 | "react-dom": "^17.0.1", 22 | "tailwindcss": "^2.0.2", 23 | "webpack": "^5.24.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import Layout from '@/components/Layout' 2 | import SEO from '@/components/SEO' 3 | import '@/styles/globals.css' 4 | 5 | function MyApp({ Component, pageProps }) { 6 | 7 | return ( 8 | 9 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default MyApp 18 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx) { 5 | const initialProps = await Document.getInitialProps(ctx) 6 | return { ...initialProps } 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ) 19 | } 20 | } 21 | 22 | export default MyDocument -------------------------------------------------------------------------------- /pages/cart.js: -------------------------------------------------------------------------------- 1 | import SEO from '@/components/SEO' 2 | import PageTitle from '@/components/PageTitle' 3 | import CartTable from '@/components/CartTable' 4 | import CheckOutButton from '@/components/CheckOutButton' 5 | import BackToProductButton from '@/components/BackToProductButton' 6 | import { useCartContext } from '@/context/Store' 7 | 8 | function CartPage() { 9 | const pageTitle = `Cart | ${process.env.siteTitle}` 10 | const [cart, checkoutUrl] = useCartContext() 11 | 12 | return ( 13 |
14 | 15 | 16 | 19 |
20 | 21 | 22 |
23 | 24 |
25 | ) 26 | } 27 | 28 | export default CartPage 29 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import StoreHeading from '@/components/StoreHeading' 2 | import ProductListings from '@/components/ProductListings' 3 | import { getAllProductsInCollection } from '@/lib/shopify' 4 | 5 | function IndexPage({ products }) { 6 | 7 | return ( 8 |
9 | 10 | 11 |
12 | ) 13 | } 14 | 15 | export async function getStaticProps() { 16 | const products = await getAllProductsInCollection() 17 | 18 | return { 19 | props: { 20 | products 21 | }, 22 | } 23 | } 24 | 25 | export default IndexPage 26 | -------------------------------------------------------------------------------- /pages/products/[product].js: -------------------------------------------------------------------------------- 1 | import { getProductSlugs, getProduct } from '@/lib/shopify' 2 | import ProductSection from '@/components/ProductSection' 3 | 4 | function ProductPage({ productData }) { 5 | 6 | return ( 7 |
8 | 9 |
10 | ) 11 | } 12 | 13 | export async function getStaticPaths() { 14 | const productSlugs = await getProductSlugs() 15 | 16 | const paths = productSlugs.map((slug) => { 17 | const product = String(slug.node.handle) 18 | return { 19 | params: { product } 20 | } 21 | }) 22 | 23 | return { 24 | paths, 25 | fallback: false, 26 | } 27 | } 28 | 29 | export async function getStaticProps({ params }) { 30 | const productData = await getProduct(params.product) 31 | 32 | return { 33 | props: { 34 | productData, 35 | }, 36 | } 37 | } 38 | 39 | export default ProductPage 40 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/apple-icon.png -------------------------------------------------------------------------------- /public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/icons/icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-150x150.png -------------------------------------------------------------------------------- /public/icons/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-16x16.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-32x32.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/icons/icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/icons/icon-70x70.png -------------------------------------------------------------------------------- /public/images/demo-store.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/images/demo-store.gif -------------------------------------------------------------------------------- /public/images/desktop-lighthouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/images/desktop-lighthouse.png -------------------------------------------------------------------------------- /public/images/main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/images/main.jpg -------------------------------------------------------------------------------- /public/images/mobile-lighthouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/images/mobile-lighthouse.png -------------------------------------------------------------------------------- /public/images/responsive-cart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/images/responsive-cart.gif -------------------------------------------------------------------------------- /public/images/responsive-main.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilmerdev0127/nextjs-shopify-starter/261662009a5684c9de0e962f0903781f44cdaeaa/public/images/responsive-main.gif -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Doggy Stickers", 3 | "short_name": "Get some Doggy Stickers!", 4 | "theme_color": "#ffffff", 5 | "background_color": "#EF4444", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "icons/icon-70x70.png", 13 | "sizes": "70x70", 14 | "type": "image/png", 15 | "purpose": "any maskable" 16 | }, 17 | { 18 | "src": "icons/icon-144x144.png", 19 | "sizes": "144x144", 20 | "type": "image/png", 21 | "purpose": "any maskable" 22 | }, 23 | { 24 | "src": "icons/icon-150x150.png", 25 | "sizes": "150x150", 26 | "type": "image/png", 27 | "purpose": "any maskable" 28 | }, 29 | { 30 | "src": "icons/icon-192x192.png", 31 | "sizes": "192x192", 32 | "type": "image/png", 33 | "purpose": "any maskable" 34 | }, 35 | { 36 | "src": "icons/icon-512x512.png", 37 | "sizes": "512x512", 38 | "type": "image/png", 39 | "purpose": "any maskable" 40 | } 41 | ], 42 | "splash_pages": null 43 | } 44 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@300;400;600;700&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | .triangle { 8 | 9 | } 10 | 11 | .triangle::before { 12 | content: ''; 13 | height: 100%; 14 | position: absolute; 15 | top: 0; 16 | left: 0em; 17 | border: 1em solid transparent; 18 | border-left-color: #ffffff; 19 | } 20 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: { 6 | height: theme => ({ 7 | '112': '28rem', 8 | '120': '30rem', 9 | }), 10 | minHeight: theme => ({ 11 | '80': '20rem', 12 | }), 13 | colors: { 14 | palette: { 15 | lighter: '#F5F3FF', 16 | light: '#DDD6FE', 17 | primary: '#5B21B6', 18 | dark: '#4C1D95', 19 | }, 20 | }, 21 | fontFamily: { 22 | primary: ['"Josefin Sans"'] 23 | } 24 | }, 25 | }, 26 | variants: { 27 | extend: {}, 28 | }, 29 | plugins: [ 30 | require("@tailwindcss/forms")({ 31 | strategy: 'class', 32 | }), 33 | ], 34 | } 35 | -------------------------------------------------------------------------------- /utils/helpers.js: -------------------------------------------------------------------------------- 1 | import { createCheckout, updateCheckout } from '@/lib/shopify' 2 | 3 | export function saveLocalData(cart, checkoutId, checkoutUrl) { 4 | localStorage.setItem(process.env.NEXT_PUBLIC_LOCAL_STORAGE_NAME, JSON.stringify([cart, checkoutId, checkoutUrl])) 5 | } 6 | 7 | function getLocalData() { 8 | return JSON.parse(localStorage.getItem(process.env.NEXT_PUBLIC_LOCAL_STORAGE_NAME)) 9 | } 10 | 11 | export function setLocalData(setCart, setCheckoutId, setCheckoutUrl) { 12 | const localData = getLocalData() 13 | 14 | if (localData) { 15 | if (Array.isArray(localData[0])) { 16 | setCart([...localData[0]]) 17 | } 18 | else { 19 | setCart([localData[0]]) 20 | } 21 | setCheckoutId(localData[1]) 22 | setCheckoutUrl(localData[2]) 23 | } 24 | } 25 | 26 | export async function createShopifyCheckout(newItem) { 27 | const data = await createCheckout( newItem['variantId'], newItem['variantQuantity']) 28 | return data 29 | } 30 | 31 | export async function updateShopifyCheckout(updatedCart, checkoutId) { 32 | const lineItems = updatedCart.map(item => { 33 | return { 34 | variantId: item['variantId'], 35 | quantity: item['variantQuantity'] 36 | } 37 | }) 38 | await updateCheckout(checkoutId, lineItems) 39 | } 40 | 41 | export function getCartSubTotal(cart) { 42 | if (cart.length === 0) { 43 | return 0 44 | } 45 | else { 46 | let totalPrice = 0 47 | cart.forEach(item => totalPrice += parseInt(item.variantQuantity) * parseFloat(item.variantPrice)) 48 | return Math.round(totalPrice * 100) / 100 49 | } 50 | } --------------------------------------------------------------------------------