├── .prettierrc ├── .prettierignore ├── static ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── default-og-image.jpg └── safari-pinned-tab.svg ├── gatsby-ssr.js ├── .gitignore ├── lint-staged.config.js ├── src ├── pages │ ├── products │ │ ├── index.module.css │ │ ├── index.jsx │ │ ├── vendor │ │ │ └── {ShopifyProduct.vendor}.jsx.example │ │ └── {ShopifyProduct.productType} │ │ │ ├── index.jsx │ │ │ ├── product-page.module.css │ │ │ └── {ShopifyProduct.handle}.jsx │ ├── 404.module.css │ ├── 404.jsx │ ├── index.module.css │ ├── index.jsx │ ├── cart.module.css │ ├── cart.jsx │ ├── search-page.module.css │ └── search.jsx ├── components │ ├── progress.jsx │ ├── product-listing.module.css │ ├── more-button.jsx │ ├── layout.jsx │ ├── add-to-cart.module.css │ ├── cart-button.jsx │ ├── skip-nav.module.css │ ├── product-listing.jsx │ ├── currency-field.module.css │ ├── more-button.module.css │ ├── add-to-cart.jsx │ ├── currency-field.jsx │ ├── line-item.module.css │ ├── skip-nav.jsx │ ├── toast.jsx │ ├── product-card.module.css │ ├── navigation.module.css │ ├── cart-button.module.css │ ├── progress.module.css │ ├── numeric-input.jsx │ ├── navigation.jsx │ ├── footer.module.css │ ├── filters.module.css │ ├── toast.module.css │ ├── footer.jsx │ ├── header.module.css │ ├── check-filter.jsx │ ├── header.jsx │ ├── check-filter.module.css │ ├── numeric-input.module.css │ ├── seo.jsx │ ├── filters.jsx │ ├── product-card.jsx │ └── line-item.jsx ├── styles │ ├── global.css │ ├── variables.css │ └── reset.css ├── icons │ ├── cross.jsx │ ├── sort.jsx │ ├── search.jsx │ ├── logo.jsx │ ├── cart.jsx │ ├── delete.jsx │ └── filter.jsx ├── context │ ├── search-provider.jsx │ └── store-context.jsx └── utils │ ├── format-price.js │ ├── search.js │ └── hooks.js ├── .env.example ├── gatsby-browser.js ├── LICENSE ├── gatsby-config.js ├── package.json ├── example └── README.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | public 3 | static 4 | node_modules 5 | .vscode 6 | .idea -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gatsbyjs/gatsby-starter-shopify/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | exports.onRenderBody = ({ setHtmlAttributes }) => { 2 | setHtmlAttributes({ lang: "en" }) 3 | } -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gatsbyjs/gatsby-starter-shopify/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gatsbyjs/gatsby-starter-shopify/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gatsbyjs/gatsby-starter-shopify/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/default-og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gatsbyjs/gatsby-starter-shopify/HEAD/static/default-og-image.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .cache/ 3 | public 4 | .idea 5 | .vscode 6 | .DS_Store 7 | .env 8 | *.log 9 | gatsby-node.old.js -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.{md,mdx,json,yaml,js,jsx}": [ 3 | `prettier "**/*.{md,mdx,json,yaml,js,jsx}" --write`, 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/products/index.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: var(--text-display); 3 | font-weight: var(--bold); 4 | margin: 0; 5 | padding: var(--space-2xl) var(--size-gutter-raw) var(--space-2xl); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/progress.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { spinner } from "./progress.module.css" 3 | 4 | export function Spinner(props) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # See how to get these values here: https://gatsby.dev/shopify-api-keys 2 | GATSBY_STOREFRONT_ACCESS_TOKEN=XXX 3 | GATSBY_SHOPIFY_STORE_URL=your-url.myshopify.com 4 | SHOPIFY_SHOP_PASSWORD=shppa_xxxxx 5 | GOOGLE_ANALYTICS_ID=XXX 6 | -------------------------------------------------------------------------------- /src/components/product-listing.module.css: -------------------------------------------------------------------------------- 1 | .listingContainerStyle { 2 | display: grid; 3 | grid-template-columns: var(--product-grid); 4 | place-items: center; 5 | gap: var(--size-gutter-raw); 6 | padding: 0 var(--size-gutter-raw); 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: var(--font-body); 3 | color: var(--text-color); 4 | } 5 | 6 | .gatsby-image-wrapper { 7 | margin: auto; 8 | } 9 | 10 | .gatsby-image-wrapper [data-main-image] { 11 | border-radius: var(--radius-sm); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/more-button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Link } from "gatsby" 3 | import { moreButton } from "./more-button.module.css" 4 | 5 | export function MoreButton({ className, ...props }) { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { StoreProvider } from "./src/context/store-context" 3 | import "./src/styles/reset.css" 4 | import "./src/styles/variables.css" 5 | import "./src/styles/global.css" 6 | 7 | export const wrapRootElement = ({ element }) => ( 8 | {element} 9 | ) 10 | -------------------------------------------------------------------------------- /src/components/layout.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { SkipNavContent, SkipNavLink } from "./skip-nav" 3 | import { Header } from "./header" 4 | import { Footer } from "./footer" 5 | 6 | export function Layout({ children }) { 7 | return ( 8 |
9 | 10 |
11 | {children} 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/404.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | margin-left: auto; 4 | margin-right: auto; 5 | padding-left: var(--size-gutter-raw); 6 | padding-right: var(--size-gutter-raw); 7 | margin-top: var(--space-2xl); 8 | } 9 | 10 | .heading { 11 | line-height: var(--dense); 12 | font-size: var(--text-display); 13 | font-weight: var(--bold); 14 | } 15 | 16 | .paragraph { 17 | font-size: var(--text-lg); 18 | margin-top: var(--space-2xl); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/add-to-cart.module.css: -------------------------------------------------------------------------------- 1 | .addToCart { 2 | display: flex; 3 | flex-direction: row; 4 | color: var(--text-color-inverted); 5 | background-color: var(--primary); 6 | align-self: flex-end; 7 | padding: var(--space-sm) var(--space-xl); 8 | border-radius: var(--radius-md); 9 | font-weight: var(--bold); 10 | align-items: center; 11 | height: var(--size-input); 12 | justify-content: center; 13 | transition: var(--transition); 14 | } 15 | 16 | .addToCart:hover { 17 | box-shadow: var(--shadow); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/cart-button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Link } from "gatsby" 3 | import CartIcon from "../icons/cart" 4 | import { cartButton, badge } from "./cart-button.module.css" 5 | 6 | export function CartButton({ quantity }) { 7 | return ( 8 | 13 | 14 | {quantity > 0 &&
{quantity}
} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/skip-nav.module.css: -------------------------------------------------------------------------------- 1 | .navLink { 2 | background-color: var(--primary); 3 | border-bottom-right-radius: var(--radius-sm); 4 | clip: rect(1px, 1px, 1px, 1px); 5 | color: var(--text-color-inverted); 6 | font-size: var(--text-sm); 7 | font-weight: var(--medium); 8 | left: 0; 9 | margin: 0; 10 | overflow: hidden; 11 | padding: 0; 12 | padding: var(--space-lg); 13 | position: absolute; 14 | top: 0; 15 | } 16 | 17 | .navLink:focus { 18 | clip: auto; 19 | height: auto; 20 | width: auto; 21 | z-index: 20; 22 | } 23 | -------------------------------------------------------------------------------- /src/icons/cross.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function CrossIcon(props) { 4 | return ( 5 | 13 | 19 | 20 | ) 21 | } 22 | 23 | export default CrossIcon 24 | -------------------------------------------------------------------------------- /src/icons/sort.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SortIcon(props) { 4 | return ( 5 | 13 | {"Sort"} 14 | 20 | 21 | ) 22 | } 23 | 24 | export default SortIcon 25 | -------------------------------------------------------------------------------- /src/components/product-listing.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ProductCard } from "./product-card" 3 | import { listingContainerStyle } from "./product-listing.module.css" 4 | 5 | // To optimize LCP we mark the first product card as eager so the image gets loaded faster 6 | export function ProductListing({ products = [] }) { 7 | return ( 8 |
9 | {products.map((p, index) => ( 10 | 11 | ))} 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/context/search-provider.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { createClient, Provider as UrlqProvider } from "urql" 3 | 4 | export const urqlClient = createClient({ 5 | url: `https://${process.env.GATSBY_SHOPIFY_STORE_URL}/api/2021-01/graphql.json`, 6 | fetchOptions: { 7 | headers: { 8 | "X-Shopify-Storefront-Access-Token": 9 | process.env.GATSBY_STOREFRONT_ACCESS_TOKEN, 10 | }, 11 | }, 12 | }) 13 | 14 | export function SearchProvider({ children }) { 15 | return {children} 16 | } 17 | -------------------------------------------------------------------------------- /src/components/currency-field.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | display: block; 3 | grid-area: 1/1; 4 | background: none; 5 | text-align: right; 6 | padding: var(--space-md); 7 | } 8 | 9 | .symbolAfter .input { 10 | text-align: left; 11 | } 12 | 13 | .symbolAfter .currencySymbol { 14 | text-align: right; 15 | } 16 | 17 | .currencySymbol { 18 | grid-area: 1/1; 19 | color: var(--text-color-secondary); 20 | padding: var(--space-md); 21 | } 22 | 23 | .wrap { 24 | display: grid; 25 | background-color: var(--input-background); 26 | border-radius: var(--radius-md); 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/404.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Layout } from "../components/layout" 3 | import { heading, paragraph, container } from "./404.module.css" 4 | import { Seo } from "../components/seo" 5 | 6 | export default function NotFoundPage() { 7 | return ( 8 | 9 |
10 |

Page Not Found

11 |

12 | Sorry, we couldn't find what you were looking for 13 |

14 |
15 |
16 | ) 17 | } 18 | 19 | export const Head = () => 20 | -------------------------------------------------------------------------------- /src/components/more-button.module.css: -------------------------------------------------------------------------------- 1 | .moreButton { 2 | align-items: center; 3 | background: var(--primary); 4 | border-radius: var(--radius-md); 5 | color: var(--text-color-inverted); 6 | display: flex; 7 | font-size: var(--text-md); 8 | font-weight: var(--semibold); 9 | height: var(--size-input); 10 | justify-content: center; 11 | line-height: var(--solid); 12 | margin: var(--space-3xl) auto var(--space-md); 13 | padding: var(--space-sm) var(--space-xl); 14 | width: max-content; 15 | transition: var(--transition); 16 | } 17 | 18 | .moreButton:hover { 19 | box-shadow: var(--shadow); 20 | } 21 | -------------------------------------------------------------------------------- /src/icons/search.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SearchIcon(props) { 4 | return ( 5 | 13 | {"Search"} 14 | 15 | 21 | 22 | ) 23 | } 24 | 25 | export default SearchIcon 26 | -------------------------------------------------------------------------------- /src/icons/logo.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function Logo(props) { 4 | return ( 5 | 13 | Logo 14 | 18 | 19 | ) 20 | } 21 | 22 | export default Logo 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The BSD Zero Clause License (0BSD) 2 | 3 | Copyright (c) 2021 Gatsby Inc. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /src/icons/cart.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function CartIcon(props) { 4 | return ( 5 | 13 | {"Cart"} 14 | 19 | 24 | 25 | ) 26 | } 27 | 28 | export default CartIcon 29 | -------------------------------------------------------------------------------- /src/components/add-to-cart.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { StoreContext } from "../context/store-context" 3 | import { addToCart as addToCartStyle } from "./add-to-cart.module.css" 4 | 5 | export function AddToCart({ variantId, quantity, available, ...props }) { 6 | const { addVariantToCart, loading } = React.useContext(StoreContext) 7 | 8 | function addToCart(e) { 9 | e.preventDefault() 10 | addVariantToCart(variantId, quantity) 11 | } 12 | 13 | return ( 14 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/currency-field.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as React from "react" 3 | import { 4 | input, 5 | currencySymbol, 6 | wrap, 7 | symbolAfter, 8 | } from "./currency-field.module.css" 9 | 10 | export function CurrencyField({ 11 | symbol, 12 | symbolAtEnd, 13 | style, 14 | className, 15 | ...props 16 | }) { 17 | return ( 18 | 24 | {symbol} 25 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/line-item.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: var(--text-2xl); 3 | font-weight: var(--semibold); 4 | line-height: var(--solid); 5 | padding-bottom: var(--space-md); 6 | } 7 | 8 | .variant { 9 | font-size: var(--text-md); 10 | text-transform: capitalize; 11 | padding-bottom: var(--space-sm); 12 | } 13 | 14 | .remove button { 15 | font-size: var(--text-md); 16 | 17 | display: inline-flex; 18 | align-items: center; 19 | } 20 | 21 | .remove button svg { 22 | margin-right: var(--space-md); 23 | } 24 | 25 | .totals { 26 | text-align: right; 27 | } 28 | 29 | .priceColumn, 30 | .totals { 31 | display: none; 32 | } 33 | 34 | @media (min-width: 640px) { 35 | .priceColumn, 36 | .totals { 37 | display: block; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/skip-nav.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { navLink } from "./skip-nav.module.css" 3 | 4 | const defaultId = `skip-to-content` 5 | 6 | export function SkipNavLink({ 7 | children = `Skip to content`, 8 | contentId, 9 | ...props 10 | }) { 11 | const id = contentId || defaultId 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | 20 | /** 21 | * Wrap the main content of a page with this, thus also the
tag 22 | */ 23 | export function SkipNavContent({ children, id: idProp, ...props }) { 24 | const id = idProp || defaultId 25 | 26 | return ( 27 |
28 | {children} 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/toast.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { toastWrapper, hiding, showing } from "./toast.module.css" 3 | 4 | export function Toast({ show, duration = 1000, className, ...props }) { 5 | const [visible, setVisible] = React.useState(show) 6 | const [animation, setAnimation] = React.useState("") 7 | 8 | React.useEffect(() => { 9 | if (show) { 10 | setVisible(true) 11 | } 12 | const timeout = setTimeout(() => { 13 | setAnimation("") 14 | setVisible(show) 15 | }, duration) 16 | setAnimation(show ? showing : hiding) 17 | return () => clearTimeout(timeout) 18 | }, [show, duration]) 19 | 20 | return visible ? ( 21 |
25 | ) : null 26 | } 27 | -------------------------------------------------------------------------------- /src/components/product-card.module.css: -------------------------------------------------------------------------------- 1 | .productCardStyle { 2 | max-width: 400px; 3 | cursor: pointer; 4 | text-decoration: none; 5 | padding-bottom: var(--space-md); 6 | } 7 | 8 | .productImageStyle { 9 | margin-bottom: var(--space-md); 10 | } 11 | 12 | .productDetailsStyle { 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | justify-content: center; 17 | font-weight: var(--semibold); 18 | } 19 | 20 | .productVendorStyle { 21 | font-size: var(--text-sm); 22 | color: var(--text-color-secondary); 23 | } 24 | 25 | .productHeadingStyle { 26 | width: 100%; 27 | font-size: var(--text-lg); 28 | text-align: center; 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | line-height: var(--dense); 32 | } 33 | 34 | .productPrice { 35 | color: var(--text-color-secondary); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/navigation.module.css: -------------------------------------------------------------------------------- 1 | .navStyle { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | overflow-x: auto; 6 | white-space: nowrap; 7 | font-weight: var(--medium); 8 | } 9 | 10 | .navLink { 11 | cursor: pointer; 12 | text-decoration: none; 13 | height: var(--size-input); 14 | display: flex; 15 | color: var(--text-color-secondary); 16 | align-items: center; 17 | padding-left: var(--space-md); 18 | padding-right: var(--space-md); 19 | } 20 | 21 | .navLink:hover { 22 | color: var(--text-color); 23 | } 24 | 25 | .activeLink, 26 | .navLink[aria-active="page"] { 27 | color: var(--primary); 28 | text-decoration: underline; 29 | text-decoration-thickness: 2px; 30 | text-underline-offset: 4px; 31 | } 32 | 33 | .activeLink:hover, 34 | .navLink[aria-active="page"]:hover { 35 | color: var(--primary); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/format-price.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a currency according to the user's locale 3 | * @param {string} currency The ISO currency code 4 | * @param {number} value The amount to format 5 | * @returns 6 | */ 7 | export const formatPrice = (currency, value) => 8 | Intl.NumberFormat("en-US", { 9 | currency, 10 | minimumFractionDigits: 2, 11 | style: "currency", 12 | }).format(value) 13 | 14 | export const getCurrencySymbol = (currency, locale = undefined) => { 15 | if (!currency) { 16 | return 17 | } 18 | 19 | const formatter = Intl.NumberFormat(locale, { 20 | currency, 21 | style: "currency", 22 | }) 23 | const parts = formatter.formatToParts(100) 24 | const { value: symbol } = parts.find((part) => part.type === "currency") 25 | const formatted = formatter.format(100) 26 | const symbolAtEnd = formatted.endsWith(symbol) 27 | return { symbol, symbolAtEnd } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/cart-button.module.css: -------------------------------------------------------------------------------- 1 | .cartButton { 2 | color: var(--text-color-secondary); 3 | grid-area: cartButton; 4 | width: var(--size-input); 5 | height: var(--size-input); 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | position: relative; 10 | align-self: center; 11 | } 12 | 13 | .cartButton:hover { 14 | color: var(--text-color); 15 | } 16 | 17 | .badge { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | background-color: var(--primary); 22 | box-shadow: 0 0 0 2px white; 23 | color: var(--text-color-inverted); 24 | font-size: var(--text-xs); 25 | font-weight: var(--bold); 26 | border-radius: var(--radius-rounded); 27 | position: absolute; 28 | bottom: 4px; 29 | right: 4px; 30 | height: 16px; 31 | min-width: 16px; 32 | padding: 0 var(--space-sm); 33 | } 34 | 35 | .cartButton[aria-current="page"] { 36 | color: var(--primary); 37 | } 38 | -------------------------------------------------------------------------------- /src/icons/delete.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function DeleteIcon(props) { 4 | return ( 5 | 13 | 14 | 15 | 21 | 26 | 31 | 32 | ) 33 | } 34 | 35 | export default DeleteIcon 36 | -------------------------------------------------------------------------------- /src/components/progress.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | text-indent: -9999em; 3 | width: 1em; 4 | height: 1em; 5 | border-radius: 50%; 6 | background: currentColor; 7 | background: linear-gradient( 8 | to right, 9 | currentColor 10%, 10 | rgba(255, 255, 255, 0) 42% 11 | ); 12 | position: relative; 13 | animation: spin 1s infinite linear; 14 | transform: translateZ(0); 15 | } 16 | .spinner:before { 17 | width: 50%; 18 | height: 50%; 19 | background: currentColor; 20 | border-radius: 100% 0 0 0; 21 | position: absolute; 22 | top: 0; 23 | left: 0; 24 | content: ""; 25 | } 26 | .spinner:after { 27 | background: var(--background); 28 | width: 75%; 29 | height: 75%; 30 | border-radius: 50%; 31 | content: ""; 32 | margin: auto; 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | bottom: 0; 37 | right: 0; 38 | } 39 | 40 | @keyframes spin { 41 | 0% { 42 | transform: rotate(0deg); 43 | } 44 | 100% { 45 | transform: rotate(360deg); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding-left: var(--size-gutter-raw); 3 | padding-right: var(--size-gutter-raw); 4 | padding-top: var(--space-lg); 5 | padding-bottom: var(--space-3xl); 6 | max-width: 76ch; 7 | } 8 | 9 | .intro { 10 | padding-bottom: var(--space-lg); 11 | line-height: var(--dense); 12 | font-size: var(--text-display); 13 | color: var(--text-color-secondary); 14 | } 15 | 16 | .callOut { 17 | padding-bottom: var(--space-lg); 18 | font-size: var(--text-display); 19 | font-weight: var(--bold); 20 | line-height: var(--dense); 21 | line-height: var(--dense); 22 | letter-spacing: var(--tight); 23 | color: var(--text-color-secondary); 24 | } 25 | 26 | .callOut > strong { 27 | text-decoration: underline; 28 | } 29 | 30 | .callToAction { 31 | font-size: var(--text-prose); 32 | line-height: var(--dense); 33 | line-height: var(--dense); 34 | letter-spacing: var(--tight); 35 | color: var(--text-color-secondary); 36 | } 37 | 38 | .deployButton { 39 | margin-top: var(--space-lg); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/products/index.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql } from "gatsby" 3 | import { Layout } from "../../components/layout" 4 | import { ProductListing } from "../../components/product-listing" 5 | import { Seo } from "../../components/seo" 6 | import { MoreButton } from "../../components/more-button" 7 | import { title } from "./index.module.css" 8 | 9 | export default function Products({ data: { products } }) { 10 | return ( 11 | 12 |

Products

13 | 14 | {products.pageInfo.hasNextPage && ( 15 | More products 16 | )} 17 |
18 | ) 19 | } 20 | 21 | export const Head = () => 22 | 23 | export const query = graphql` 24 | { 25 | products: allShopifyProduct(sort: { publishedAt: ASC }, limit: 24) { 26 | nodes { 27 | ...ProductCard 28 | } 29 | pageInfo { 30 | hasNextPage 31 | } 32 | } 33 | } 34 | ` 35 | -------------------------------------------------------------------------------- /src/components/numeric-input.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { MdArrowDropDown, MdArrowDropUp } from "react-icons/md" 3 | import { wrap, increment, decrement, input } from "./numeric-input.module.css" 4 | export function NumericInput({ 5 | onIncrement, 6 | onDecrement, 7 | className, 8 | disabled, 9 | ...props 10 | }) { 11 | return ( 12 |
13 | 19 | 28 | 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/navigation.jsx: -------------------------------------------------------------------------------- 1 | import { graphql, useStaticQuery, Link } from "gatsby" 2 | import * as React from "react" 3 | import slugify from "@sindresorhus/slugify" 4 | import { navStyle, navLink, activeLink } from "./navigation.module.css" 5 | 6 | export function Navigation({ className }) { 7 | const { 8 | allShopifyProduct: { productTypes }, 9 | } = useStaticQuery(graphql` 10 | { 11 | allShopifyProduct { 12 | productTypes: distinct(field: { productType: SELECT }) 13 | } 14 | } 15 | `) 16 | 17 | return ( 18 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/footer.module.css: -------------------------------------------------------------------------------- 1 | .footerStyle { 2 | padding: var(--space-2xl) var(--space-xl); 3 | gap: var(--space-xl); 4 | display: grid; 5 | grid-template-columns: 1fr; 6 | text-align: center; 7 | color: var(--text-color-secondary); 8 | margin-top: var(--space-3xl); 9 | } 10 | 11 | .logos { 12 | display: flex; 13 | gap: var(--space-md); 14 | justify-content: center; 15 | padding-bottom: var(--space-lg); 16 | } 17 | 18 | .blurb { 19 | font-size: var(--text-sm); 20 | } 21 | 22 | .copyright { 23 | color: var(--text-color-secondary); 24 | font-size: var(--text-xs); 25 | } 26 | 27 | .footerNavList { 28 | display: flex; 29 | flex-direction: column; 30 | font-size: var(--text-md); 31 | } 32 | 33 | .footerNavListItem { 34 | align-items: center; 35 | display: flex; 36 | flex-direction: column; 37 | flex: 0 0 100%; 38 | white-space: nowrap; 39 | } 40 | 41 | .links ul { 42 | flex-direction: column; 43 | font-size: var(--text-md); 44 | } 45 | 46 | .links li { 47 | flex-direction: column; 48 | align-items: center; 49 | } 50 | 51 | .links a { 52 | padding: var(--space-md); 53 | } 54 | -------------------------------------------------------------------------------- /src/icons/filter.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function FilterIcon(props) { 4 | return ( 5 | 13 | {"Filter"} 14 | 18 | 19 | ) 20 | } 21 | 22 | export default FilterIcon 23 | -------------------------------------------------------------------------------- /src/components/filters.module.css: -------------------------------------------------------------------------------- 1 | .priceFilter { 2 | display: grid; 3 | } 4 | 5 | .priceFilterStyle { 6 | display: flex; 7 | } 8 | 9 | .priceFilterStyle label { 10 | cursor: pointer; 11 | margin-top: 2px; 12 | } 13 | 14 | .priceFilterStyle .summary { 15 | cursor: pointer; 16 | font-weight: var(--bold); 17 | text-transform: uppercase; 18 | display: flex; 19 | align-items: center; 20 | justify-content: space-between; 21 | padding-bottom: var(--space-md); 22 | font-size: var(--text-xs); 23 | letter-spacing: var(--tracked); 24 | } 25 | 26 | .priceFilterStyle summary::marker { 27 | color: transparent; 28 | display: none; 29 | } 30 | 31 | .priceFilterStyle summary::-webkit-details-marker { 32 | display: none; /* hide the summary arrow on safari */ 33 | } 34 | 35 | .priceFilterStyle summary { 36 | list-style: none; 37 | } 38 | 39 | /* "Price" reset button */ 40 | .clearButton { 41 | color: var(--text-color-secondary); 42 | font-size: var(--text-sm); 43 | line-height: var(--solid); 44 | } 45 | 46 | .priceFields { 47 | display: grid; 48 | align-items: center; 49 | justify-content: stretch; 50 | gap: var(--space-sm); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/toast.module.css: -------------------------------------------------------------------------------- 1 | .toastWrapper { 2 | background-color: var(--primary); 3 | color: var(--text-color-inverted); 4 | width: max-content; 5 | position: absolute; 6 | right: var(--space-xl); 7 | bottom: 0; 8 | padding: var(--space-md) var(--space-lg); 9 | font-size: var(--text-xs); 10 | font-weight: var(--semibold); 11 | border-radius: var(--radius-rounded); 12 | display: flex; 13 | flex-direction: row; 14 | align-items: center; 15 | gap: var(--space-sm); 16 | min-width: 100px; 17 | justify-content: center; 18 | z-index: 10; 19 | white-space: nowrap; 20 | } 21 | 22 | @keyframes showing { 23 | 0% { 24 | transform: translateX(200px); 25 | opacity: 0; 26 | } 27 | 50% { 28 | transform: translateX(0); 29 | opacity: 1; 30 | } 31 | 100% { 32 | transform: translateX(0); 33 | opacity: 1; 34 | } 35 | } 36 | 37 | @keyframes hiding { 38 | 50% { 39 | transform: translateX(200px); 40 | opacity: 0; 41 | } 42 | 100% { 43 | transform: translateX(200px); 44 | opacity: 0; 45 | } 46 | } 47 | 48 | .hiding { 49 | animation: hiding 1s ease; 50 | } 51 | 52 | .showing { 53 | animation: showing 1s ease-out; 54 | } 55 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config() 2 | 3 | module.exports = { 4 | siteMetadata: { 5 | siteTitle: "gatsby-starter-shopify", 6 | siteTitleDefault: "gatsby-starter-shopify by @GatsbyJS", 7 | siteUrl: "https://shopify-demo.gatsbyjs.com", 8 | siteDescription: 9 | "A Gatsby starter using the latest Shopify plugin showcasing a store with product overview, individual product pages, and a cart.", 10 | siteImage: "/default-og-image.jpg", 11 | twitter: "@gatsbyjs", 12 | }, 13 | flags: { 14 | FAST_DEV: true, 15 | }, 16 | plugins: [ 17 | { 18 | resolve: "gatsby-source-shopify", 19 | options: { 20 | password: process.env.SHOPIFY_SHOP_PASSWORD, 21 | storeUrl: process.env.GATSBY_SHOPIFY_STORE_URL, 22 | shopifyConnections: ["collections"], 23 | }, 24 | }, 25 | "gatsby-plugin-image", 26 | "gatsby-plugin-sharp", 27 | "gatsby-transformer-sharp", 28 | "gatsby-plugin-sitemap", 29 | "gatsby-plugin-gatsby-cloud", 30 | // Add your Google Analytics ID to the .env file to enable 31 | // Otherwise, this plugin can be removed 32 | process.env.GOOGLE_ANALYTICS_ID && { 33 | resolve: "gatsby-plugin-google-analytics", 34 | options: { 35 | trackingId: process.env.GOOGLE_ANALYTICS_ID, 36 | }, 37 | }, 38 | ].filter(Boolean), 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/products/vendor/{ShopifyProduct.vendor}.jsx.example: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql } from "gatsby" 3 | import { Layout } from "../../../components/layout" 4 | import { ProductListing } from "../../../components/product-listing" 5 | import { Seo } from "../../../components/seo" 6 | import slugify from "@sindresorhus/slugify" 7 | import { MoreButton } from "../../../components/more-button" 8 | import { title } from "../index.module.css" 9 | 10 | export default function Products({ 11 | data: { products }, 12 | pageContext: { vendor }, 13 | }) { 14 | return ( 15 | 16 |

{vendor}

17 | 18 | {products.pageInfo.hasNextPage && ( 19 | 20 | More Products 21 | 22 | )} 23 |
24 | ) 25 | } 26 | 27 | export const Head = ({ pageContext: { vendor } }) => { 28 | return ( 29 | 30 | ) 31 | } 32 | 33 | export const query = graphql` 34 | query($vendor: String!) { 35 | products: allShopifyProduct( 36 | filter: { vendor: { eq: $vendor } } 37 | sort: { fields: publishedAt, order: DESC } 38 | limit: 24 39 | ) { 40 | nodes { 41 | ...ProductCard 42 | } 43 | pageInfo { 44 | hasNextPage 45 | } 46 | } 47 | } 48 | ` 49 | -------------------------------------------------------------------------------- /src/pages/products/{ShopifyProduct.productType}/index.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql } from "gatsby" 3 | import { Layout } from "../../../components/layout" 4 | import { ProductListing } from "../../../components/product-listing" 5 | import { Seo } from "../../../components/seo" 6 | import slugify from "@sindresorhus/slugify" 7 | import { MoreButton } from "../../../components/more-button" 8 | import { title } from "../index.module.css" 9 | 10 | export default function ProductTypeIndex({ 11 | data: { products }, 12 | pageContext: { productType }, 13 | }) { 14 | return ( 15 | 16 |

{productType}

17 | 18 | {products.pageInfo.hasNextPage && ( 19 | 20 | More Products 21 | 22 | )} 23 |
24 | ) 25 | } 26 | 27 | export const Head = ({ pageContext: { productType } }) => ( 28 | 29 | ) 30 | 31 | export const query = graphql` 32 | query ($productType: String!) { 33 | products: allShopifyProduct( 34 | filter: { productType: { eq: $productType } } 35 | sort: { publishedAt: ASC } 36 | limit: 24 37 | ) { 38 | nodes { 39 | ...ProductCard 40 | } 41 | pageInfo { 42 | hasNextPage 43 | } 44 | } 45 | } 46 | ` 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-starter-shopify", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "A Gatsby starter using the latest Shopify plugin showcasing a store with product overview, individual product pages, and a cart.", 6 | "author": "LekoArts ", 7 | "license": "0BSD", 8 | "keywords": [ 9 | "gatsby", 10 | "shopify", 11 | "starter", 12 | "ecommerce", 13 | "e-commerce" 14 | ], 15 | "scripts": { 16 | "develop": "gatsby develop", 17 | "start": "gatsby develop", 18 | "build": "gatsby build", 19 | "serve": "gatsby serve", 20 | "clean": "gatsby clean" 21 | }, 22 | "dependencies": { 23 | "@sindresorhus/slugify": "^1.1.0", 24 | "debounce": "^1.2.1", 25 | "dotenv": "^10.0.0", 26 | "gatsby": "^5.0.0", 27 | "gatsby-plugin-gatsby-cloud": "^5.0.0", 28 | "gatsby-plugin-google-analytics": "^5.0.0", 29 | "gatsby-plugin-image": "^3.0.0", 30 | "gatsby-plugin-sharp": "^5.0.0", 31 | "gatsby-plugin-sitemap": "^6.0.0", 32 | "gatsby-source-filesystem": "^5.0.0", 33 | "gatsby-source-shopify": "^6.0.0", 34 | "gatsby-transformer-sharp": "^5.0.0", 35 | "isomorphic-fetch": "^3.0.0", 36 | "lodash.debounce": "^4.0.8", 37 | "lodash.isequal": "^4.5.0", 38 | "query-string": "^7.0.0", 39 | "react": "^18.0.0", 40 | "react-dom": "^18.0.0", 41 | "react-icons": "^4.1.0", 42 | "shopify-buy": "^2.11.0", 43 | "urql": "^2.0.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/footer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Logo from "../icons/logo" 3 | import { 4 | footerStyle, 5 | copyright, 6 | links, 7 | blurb, 8 | logos, 9 | footerNavList, 10 | footerNavListItem, 11 | } from "./footer.module.css" 12 | 13 | export function Footer() { 14 | return ( 15 |
16 |
17 |
18 | 19 |
20 | gatsby-starter-shopify change this by editing{" "} 21 | src/components/footer.jsx 22 |
23 | 47 |
48 | Copyright © {new Date().getFullYear()} · All rights reserved 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/header.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | position: sticky; 5 | z-index: 1; 6 | top: 0; 7 | } 8 | 9 | .header { 10 | display: grid; 11 | width: 100%; 12 | padding: var(--size-gap) var(--size-gutter); 13 | grid-template-columns: var(--size-input) 1fr min-content min-content; 14 | grid-template-areas: "logo nada searchButton cartButton" "navHeader navHeader navHeader navHeader"; 15 | align-items: center; 16 | background-color: var(--background); 17 | } 18 | 19 | .header::after { 20 | grid-area: navHeader; 21 | content: ""; 22 | display: block; 23 | width: var(--space-2xl); 24 | z-index: 1; 25 | align-self: stretch; 26 | background-image: linear-gradient( 27 | 90deg, 28 | rgba(255, 255, 255, 0), 29 | rgba(255, 255, 255, 1) 30 | ); 31 | justify-self: flex-end; 32 | } 33 | 34 | @media (min-width: 640px) { 35 | .header { 36 | grid-template-areas: "logo navHeader searchButton cartButton"; 37 | } 38 | } 39 | 40 | .logo { 41 | display: flex; 42 | grid-area: logo; 43 | height: 100%; 44 | width: 100%; 45 | align-items: center; 46 | justify-content: center; 47 | color: var(--text-color-secondary); 48 | } 49 | 50 | .logo:hover { 51 | color: var(--text-color); 52 | text-decoration: none; 53 | } 54 | 55 | .logo[aria-current="page"] { 56 | color: var(--primary); 57 | } 58 | 59 | .nav { 60 | grid-area: navHeader; 61 | align-self: stretch; 62 | } 63 | 64 | .searchButton { 65 | color: var(--text-color-secondary); 66 | grid-area: searchButton; 67 | width: var(--size-input); 68 | height: var(--size-input); 69 | display: grid; 70 | place-items: center; 71 | } 72 | 73 | .searchButton:hover { 74 | color: var(--text-color); 75 | } 76 | 77 | .searchButton[aria-current="page"] { 78 | color: var(--primary); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/check-filter.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | filter, 4 | summary, 5 | filterOptions, 6 | clearButton, 7 | selectedLabel, 8 | checkbox, 9 | } from "./check-filter.module.css" 10 | 11 | export function CheckFilter({ 12 | items, 13 | name, 14 | selectedItems = [], 15 | setSelectedItems, 16 | open = true, 17 | }) { 18 | const toggleItem = ({ currentTarget: input }) => { 19 | if (input.checked) { 20 | setSelectedItems([...selectedItems, input.value]) 21 | } else { 22 | const idx = selectedItems.indexOf(input.value) 23 | if (idx === -1) { 24 | return 25 | } 26 | const newItems = [ 27 | ...selectedItems.slice(0, idx), 28 | ...selectedItems.slice(idx + 1), 29 | ] 30 | setSelectedItems(newItems) 31 | } 32 | } 33 | 34 | const clearItems = () => { 35 | setSelectedItems([]) 36 | } 37 | 38 | return ( 39 |
40 | {name && ( 41 | 42 |
43 | {name}{" "} 44 | {selectedItems.length ? ( 45 | 48 | ) : undefined} 49 |
50 |
51 | )} 52 |
53 | {items.map((item) => ( 54 | 67 | ))} 68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql } from "gatsby" 3 | import { Layout } from "../components/layout" 4 | import { ProductListing } from "../components/product-listing" 5 | import { Seo } from "../components/seo" 6 | import { 7 | container, 8 | intro, 9 | callOut, 10 | callToAction, 11 | deployButton, 12 | } from "./index.module.css" 13 | 14 | export const query = graphql` 15 | query { 16 | shopifyCollection(handle: { eq: "frontpage" }) { 17 | products { 18 | ...ProductCard 19 | } 20 | } 21 | } 22 | ` 23 | function Hero (props) { 24 | return ( 25 |
26 |

Welcome to the GatsbyJS + Shopify Demo Store.

27 | {!!process.env.GATSBY_DEMO_STORE && ( 28 | <> 29 |

30 | It's a proof-of-concept in a box, with 10k products and 30k variants 31 | to help you get to proof-of-concept as soon as right now. 32 |

33 |

34 | Hook it up to your own Shopify store data and start customizing in 35 | minutes by deploying it to Gatsby Cloud for free. Grab your Shopify 36 | store credentials and 37 | 38 | Deploy to Gatsby Cloud 43 | 44 |

45 | 46 | )} 47 |
48 | ) 49 | } 50 | 51 | export default function IndexPage({ data }) { 52 | return ( 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | 60 | export const Head = () => 61 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Shopify sample data 2 | 3 | This folder includes sample products that you can use with your development store. 4 | 5 | ## Using this data 6 | 7 | 1. **Create your development store** 8 | 9 | Follow [the instructions on the Shopify site](https://help.shopify.com/en/partners/dashboard/managing-stores/development-stores) to create a new development store. 10 | 11 | 2. **Import the products** 12 | 13 | In your development store, navigate to "Products", and then click "Import" in the top right of the page. Choose the sample `products.csv` file, then click "Upload and continue". For a files with small number of products, such as `products.csv` in this folder, the import process should just take a few moments. However if you are using a larger dataset, such as the 30000 SKU file, then it may take several hours to import. You can safely close the window or navigate away, and you will be notified via email when the import is complete. 14 | 15 | ## More data 16 | 17 | If you need to test your site at scale, there is [another dataset available with 30,000 SKUs](https://github.com/gatsby-inc/shopify-csv-generator/tree/main/examples). Bear in mind that it takes several hours to import into a development site, and a cold build of the Gatbsy site will take over 10 minutes. Generally we would recommend doing your development with a site that has around 100-500 SKUs. 18 | 19 | ## Creating your own sample data 20 | 21 | The script to generate the sample data [is available on GitHub](https://github.com/gatsby-inc/shopify-csv-generator), and you can use it to generate any number of products. 22 | 23 | ## Demo sites 24 | 25 | You can see example sites that use this data: 26 | 27 | - [100 SKUs (this data)](https://shopify100.gatsbyjs.io/) 28 | - [30 000 SKUs](https://shopify30k.gatsbyjs.io/) 29 | 30 | ## Credit 31 | 32 | The images used in these datasets are randomly selected via [Unsplash](https://source.unsplash.com/) and are copyright the individual photographers. 33 | 34 | The rest of the data is randomly-generated and free to use for any purpose. 35 | -------------------------------------------------------------------------------- /src/components/header.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Link } from "gatsby" 3 | import { StoreContext } from "../context/store-context" 4 | import Logo from "../icons/logo" 5 | import { Navigation } from "./navigation" 6 | import { CartButton } from "./cart-button" 7 | import SearchIcon from "../icons/search" 8 | import { Toast } from "./toast" 9 | import { 10 | header, 11 | container, 12 | logo as logoCss, 13 | searchButton, 14 | nav, 15 | } from "./header.module.css" 16 | 17 | export function Header() { 18 | const { checkout, loading, didJustAddToCart } = React.useContext(StoreContext) 19 | 20 | const items = checkout ? checkout.lineItems : [] 21 | 22 | const quantity = items.reduce((total, item) => { 23 | return total + item.quantity 24 | }, 0) 25 | 26 | return ( 27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 | {!didJustAddToCart ? ( 40 | "Updating…" 41 | ) : ( 42 | <> 43 | Added to cart{" "} 44 | 50 | 54 | 58 | 62 | 63 | 64 | )} 65 | 66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/check-filter.module.css: -------------------------------------------------------------------------------- 1 | .filter { 2 | display: flex; 3 | } 4 | 5 | .filter label { 6 | cursor: pointer; 7 | margin-top: 6px; 8 | color: var(--text-color-secondary); 9 | display: flex; 10 | align-items: baseline; 11 | line-height: var(--loose); 12 | font-size: var(--text-lg); 13 | } 14 | 15 | .filter label:hover { 16 | color: var(--input-ui-active); 17 | } 18 | 19 | .filter label.selectedLabel { 20 | font-weight: var(--semibold); 21 | color: var(--text-color); 22 | } 23 | 24 | .filter summary { 25 | font-weight: var(--bold); 26 | text-transform: uppercase; 27 | cursor: pointer; 28 | font-size: var(--text-xs); 29 | letter-spacing: var(--tracked); 30 | padding-bottom: var(--space-md); 31 | list-style: none; 32 | } 33 | 34 | .filter summary::marker { 35 | display: none; 36 | color: transparent; 37 | } 38 | 39 | .filter summary::-webkit-details-marker { 40 | display: none; /* hide the summary arrow on safari */ 41 | } 42 | 43 | .summary { 44 | display: flex; 45 | align-items: center; 46 | justify-content: space-between; 47 | } 48 | 49 | .filter-options { 50 | max-height: 200px; 51 | overflow-y: auto; 52 | } 53 | 54 | .clearButton { 55 | color: var(--text-color-secondary); 56 | font-size: var(--text-sm); 57 | line-height: var(--solid); 58 | } 59 | 60 | .checkbox { 61 | display: inline-flex; 62 | align-items: center; 63 | justify-content: center; 64 | appearance: none; 65 | border: 1px var(--text-color-secondary) solid; 66 | width: var(--space-xl); 67 | height: var(--space-xl); 68 | border-radius: var(--radius-sm); 69 | margin-right: var(--space-md); 70 | } 71 | 72 | @media (min-width: 640px) { 73 | .filter label { 74 | line-height: var(--dense); 75 | max-width: 240px; 76 | } 77 | 78 | .checkbox { 79 | width: var(--space-lg); 80 | height: var(--space-lg); 81 | } 82 | } 83 | 84 | @media (min-width: 1024px) { 85 | .filter label { 86 | font-size: var(--text-md); 87 | } 88 | } 89 | 90 | .checkbox:checked { 91 | border-color: var(--primary); 92 | background-color: var(--primary); 93 | } 94 | 95 | .checkbox::before { 96 | line-height: 0; 97 | font-size: var(--text-sm); 98 | font-weight: var(--bold); 99 | color: var(--text-color-inverted); 100 | content: "\2713"; 101 | } 102 | -------------------------------------------------------------------------------- /src/components/numeric-input.module.css: -------------------------------------------------------------------------------- 1 | .wrap { 2 | display: inline-grid; 3 | grid-template-columns: 1fr min-content; 4 | grid-template-areas: "input increment" "input decrement"; 5 | background-color: var(--input-background); 6 | border-radius: var(--radius-md); 7 | height: var(--size-input); 8 | overflow: hidden; 9 | } 10 | 11 | .wrap button span { 12 | display: none; 13 | } 14 | 15 | .input { 16 | grid-area: input; 17 | background: none; 18 | border: none; 19 | padding: var(--space-sm) var(--space-lg); 20 | align-self: stretch; 21 | width: 6ch; 22 | border-style: solid; 23 | border-color: var(--input-border); 24 | border-width: 0 1px 0 0; 25 | font-weight: var(--medium); 26 | color: var(--input-text); 27 | } 28 | 29 | .input:disabled { 30 | color: var(--input-text-disabled); 31 | } 32 | 33 | .wrap button { 34 | background: none; 35 | border: none; 36 | padding: 0 var(--space-sm); 37 | display: grid; 38 | place-items: center; 39 | color: var(--input-ui); 40 | } 41 | 42 | .wrap button:hover { 43 | background-color: var(--input-background-hover); 44 | color: var(--input-ui-active); 45 | } 46 | 47 | .wrap button:disabled:hover, 48 | .wrap button:disabled { 49 | background: none; 50 | color: var(--input-text-disabled); 51 | } 52 | 53 | .wrap button.increment { 54 | grid-area: increment; 55 | border-bottom: 1px var(--input-border) solid; 56 | } 57 | 58 | .decrement { 59 | grid-area: decrement; 60 | } 61 | 62 | /* On mobile, make the buttons easier to press */ 63 | @media (pointer: coarse) { 64 | .wrap { 65 | grid-template-columns: var(--size-input) 1fr var(--size-input); 66 | grid-template-areas: "decrement input increment"; 67 | color: var(--input-color); 68 | } 69 | 70 | .input { 71 | text-align: center; 72 | border-width: 0 1px; 73 | } 74 | 75 | .wrap button { 76 | padding: 0 var(--space-md); 77 | font-size: var(--text-lg); 78 | font-weight: var(--bold); 79 | } 80 | 81 | .wrap button span { 82 | display: inline; 83 | } 84 | 85 | .wrap button svg { 86 | display: none; 87 | } 88 | 89 | .wrap button.increment { 90 | border: none; 91 | } 92 | 93 | .wrap button:active { 94 | background-color: var(--input-background-hover); 95 | color: var(--input-ui-active); 96 | } 97 | 98 | .wrap button:hover { 99 | background-color: inherit; 100 | color: inherit; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/components/seo.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useStaticQuery, graphql } from "gatsby" 3 | import { useLocation } from "@reach/router" 4 | 5 | export function Seo({ 6 | title = "", 7 | description = "", 8 | pathname = "", 9 | image = "", 10 | children = null, 11 | }) { 12 | const location = useLocation() 13 | const { 14 | site: { siteMetadata }, 15 | } = useStaticQuery(graphql` 16 | query { 17 | site { 18 | siteMetadata { 19 | siteTitle 20 | siteTitleDefault 21 | siteUrl 22 | siteDescription 23 | siteImage 24 | twitter 25 | } 26 | } 27 | } 28 | `) 29 | 30 | const { 31 | siteTitle, 32 | siteTitleDefault, 33 | siteUrl, 34 | siteDescription, 35 | siteImage, 36 | twitter, 37 | } = siteMetadata 38 | 39 | const seo = { 40 | title: title || siteTitleDefault, 41 | description: description || siteDescription, 42 | url: pathname ? `${siteUrl}${pathname}` : location.href, 43 | image: `${siteUrl}${image || siteImage}`, 44 | } 45 | 46 | return ( 47 | <> 48 | {title ? `${title} | ${siteTitle}`: siteTitleDefault} 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 68 | 74 | 79 | {/* The following meta tag is for demonstration only and can be removed */} 80 | {!!process.env.GATSBY_DEMO_STORE && ( 81 | 85 | )} 86 | {children} 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/components/filters.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { CheckFilter } from "./check-filter" 3 | import { CurrencyField } from "./currency-field" 4 | import { 5 | priceFilterStyle, 6 | clearButton, 7 | priceFields, 8 | summary, 9 | } from "./filters.module.css" 10 | 11 | export function Filters({ 12 | currencyCode, 13 | productTypes, 14 | tags, 15 | vendors, 16 | filters, 17 | setFilters, 18 | }) { 19 | const updateFilter = (key, value) => { 20 | setFilters((filters) => ({ ...filters, [key]: value })) 21 | } 22 | 23 | const updateNumeric = (key, value) => { 24 | if ( 25 | !isNaN(Number(value)) || 26 | (value.endsWith(".") && !isNaN(Number(value.substring(0, -1)))) 27 | ) { 28 | updateFilter(key, value) 29 | } 30 | } 31 | 32 | return ( 33 | <> 34 | updateFilter("productTypes", value)} 39 | /> 40 |
41 |
42 | 43 |
44 | Price 45 | {(filters.maxPrice || filters.minPrice) && ( 46 | 58 | )} 59 |
60 |
61 |
62 | 67 | updateNumeric("minPrice", event.currentTarget.value) 68 | } 69 | />{" "} 70 | –{" "} 71 | 76 | updateNumeric("maxPrice", event.currentTarget.value) 77 | } 78 | /> 79 |
80 |
81 |
82 | updateFilter("vendors", value)} 87 | /> 88 |
89 | updateFilter("tags", value)} 95 | /> 96 | 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/components/product-card.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql, Link } from "gatsby" 3 | import { GatsbyImage } from "gatsby-plugin-image" 4 | import { getShopifyImage } from "gatsby-source-shopify" 5 | import { formatPrice } from "../utils/format-price" 6 | import { 7 | productCardStyle, 8 | productHeadingStyle, 9 | productImageStyle, 10 | productDetailsStyle, 11 | productVendorStyle, 12 | productPrice, 13 | } from "./product-card.module.css" 14 | 15 | export function ProductCard({ product, eager }) { 16 | const { 17 | title, 18 | priceRangeV2, 19 | slug, 20 | images: [firstImage], 21 | vendor, 22 | storefrontImages, 23 | } = product 24 | 25 | const price = formatPrice( 26 | priceRangeV2.minVariantPrice.currencyCode, 27 | priceRangeV2.minVariantPrice.amount 28 | ) 29 | 30 | const defaultImageHeight = 200 31 | const defaultImageWidth = 200 32 | let storefrontImageData = {} 33 | if (storefrontImages) { 34 | const storefrontImage = storefrontImages.edges[0].node 35 | try { 36 | storefrontImageData = getShopifyImage({ 37 | image: storefrontImage, 38 | layout: "fixed", 39 | width: defaultImageWidth, 40 | height: defaultImageHeight, 41 | }) 42 | } catch (e) { 43 | console.error(e) 44 | } 45 | } 46 | 47 | const hasImage = firstImage || Object.getOwnPropertyNames(storefrontImageData || {}).length 48 | 49 | return ( 50 | 55 | {hasImage 56 | ? ( 57 |
58 | 63 |
64 | ) : ( 65 |
66 | ) 67 | } 68 |
69 |
{vendor}
70 |

71 | {title} 72 |

73 |
{price}
74 |
75 | 76 | ) 77 | } 78 | 79 | export const query = graphql` 80 | fragment ProductCard on ShopifyProduct { 81 | id 82 | title 83 | slug: gatsbyPath( 84 | filePath: "/products/{ShopifyProduct.productType}/{ShopifyProduct.handle}" 85 | ) 86 | images { 87 | id 88 | altText 89 | gatsbyImageData(aspectRatio: 1, width: 640) 90 | } 91 | priceRangeV2 { 92 | minVariantPrice { 93 | amount 94 | currencyCode 95 | } 96 | } 97 | vendor 98 | } 99 | ` 100 | -------------------------------------------------------------------------------- /src/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* tokens */ 3 | /* font-family */ 4 | --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, 5 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 6 | 7 | /* palette */ 8 | --black-fade-5: rgba(0, 0, 0, 0.05); 9 | --black-fade-40: rgba(0, 0, 0, 0.4); 10 | --grey-90: #232129; 11 | --grey-50: #78757a; 12 | --green-80: #088413; 13 | --green-50-rgb: 55, 182, 53; 14 | --white: #ffffff; 15 | 16 | /* radii */ 17 | --radius-sm: 4px; 18 | --radius-md: 8px; 19 | --radius-rounded: 999px; 20 | 21 | /* spacing */ 22 | --space-sm: 4px; 23 | --space-md: 8px; 24 | --space-lg: 16px; 25 | --space-xl: 20px; 26 | --space-2xl: 24px; 27 | --space-3xl: 48px; 28 | 29 | /* line-height */ 30 | --solid: 1; 31 | --dense: 1.25; 32 | --default: 1.5; 33 | --loose: 2; 34 | 35 | /* letter-spacing */ 36 | --tracked: 0.075em; 37 | --tight: -0.015em; 38 | 39 | /* font-weight */ 40 | --body: 400; 41 | --medium: 500; 42 | --semibold: 600; 43 | --bold: 700; 44 | 45 | /* font-size */ 46 | --text-xs: 12px; 47 | --text-sm: 14px; 48 | --text-md: 16px; 49 | --text-lg: 18px; 50 | --text-xl: 20px; 51 | --text-2xl: 24px; 52 | --text-3xl: 32px; 53 | 54 | /* role-based tokens */ 55 | 56 | /* colors */ 57 | --primary: var(--green-80); 58 | --background: var(--white); 59 | --border: var(--black-fade-5); 60 | 61 | /* transitions */ 62 | --transition: box-shadow 0.125s ease-in; 63 | 64 | /* shadows */ 65 | --shadow: 0 4px 12px rgba(var(--green-50-rgb), 0.5); 66 | 67 | /* text */ 68 | /* color */ 69 | --text-color: var(--grey-90); 70 | --text-color-secondary: var(--grey-50); 71 | --text-color-inverted: var(--white); 72 | /* size */ 73 | --text-display: var(--text-2xl); 74 | --text-prose: var(--text-md); 75 | 76 | /* input */ 77 | --input-background: var(--black-fade-5); 78 | --input-background-hover: var(--black-fade-5); 79 | --input-border: var(--black-fade-5); 80 | --input-text: var(--text-color); 81 | --input-text-disabled: var(--black-fade-40); 82 | --input-ui: var(--text-color-secondary); 83 | --input-ui-active: (--text-color); 84 | 85 | /* size */ 86 | --size-input: var(--space-3xl); 87 | --size-gap: 12px; 88 | --size-gutter-raw: var(--space-2xl); 89 | --size-gutter: calc(var(--size-gutter-raw) - 12px); 90 | 91 | /* product */ 92 | --product-grid: 1fr; 93 | } 94 | 95 | /* role-based token adjustments per breakpoint */ 96 | @media (min-width: 640px) { 97 | :root { 98 | --product-grid: 1fr 1fr; 99 | } 100 | } 101 | 102 | @media (min-width: 1024px) { 103 | :root { 104 | --text-display: var(--text-3xl); 105 | --text-prose: var(--text-lg); 106 | --product-grid: repeat(3, 1fr); 107 | --size-gutter-raw: var(--space-3xl); 108 | --size-gap: var(--space-2xl); 109 | } 110 | } 111 | 112 | @media (min-width: 1280px) { 113 | :root { 114 | --product-grid: repeat(4, 1fr); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/components/line-item.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import debounce from "lodash.debounce" 3 | import { StoreContext } from "../context/store-context" 4 | import { formatPrice } from "../utils/format-price" 5 | import { GatsbyImage } from "gatsby-plugin-image" 6 | import { getShopifyImage } from "gatsby-source-shopify" 7 | import DeleteIcon from "../icons/delete" 8 | import { NumericInput } from "./numeric-input" 9 | import { 10 | title, 11 | remove, 12 | variant, 13 | totals, 14 | priceColumn, 15 | } from "./line-item.module.css" 16 | 17 | export function LineItem({ item }) { 18 | const { 19 | removeLineItem, 20 | checkout, 21 | updateLineItem, 22 | loading, 23 | } = React.useContext(StoreContext) 24 | const [quantity, setQuantity] = React.useState(item.quantity) 25 | 26 | const variantImage = { 27 | ...item.variant.image, 28 | originalSrc: item.variant.image.src, 29 | } 30 | const price = formatPrice( 31 | item.variant.priceV2.currencyCode, 32 | Number(item.variant.priceV2.amount) 33 | ) 34 | 35 | const subtotal = formatPrice( 36 | item.variant.priceV2.currencyCode, 37 | Number(item.variant.priceV2.amount) * quantity 38 | ) 39 | 40 | const handleRemove = () => { 41 | removeLineItem(checkout.id, item.id) 42 | } 43 | 44 | const uli = debounce( 45 | (value) => updateLineItem(checkout.id, item.id, value), 46 | 300 47 | ) 48 | // eslint-disable-next-line 49 | const debouncedUli = React.useCallback((value) => uli(value), []) 50 | 51 | const handleQuantityChange = (value) => { 52 | if (value !== "" && Number(value) < 1) { 53 | return 54 | } 55 | setQuantity(value) 56 | if (Number(value) >= 1) { 57 | debouncedUli(value) 58 | } 59 | } 60 | 61 | function doIncrement() { 62 | handleQuantityChange(Number(quantity || 0) + 1) 63 | } 64 | 65 | function doDecrement() { 66 | handleQuantityChange(Number(quantity || 0) - 1) 67 | } 68 | 69 | const image = React.useMemo( 70 | () => 71 | getShopifyImage({ 72 | image: variantImage, 73 | layout: "constrained", 74 | crop: "contain", 75 | width: 160, 76 | height: 160, 77 | }), 78 | // eslint-disable-next-line react-hooks/exhaustive-deps 79 | [variantImage.src] 80 | ) 81 | 82 | return ( 83 | 84 | 85 | {image && ( 86 | 91 | )} 92 | 93 | 94 |

{item.title}

95 |
96 | {item.variant.title === "Default Title" ? "" : item.variant.title} 97 |
98 |
99 | 102 |
103 | 104 | {price} 105 | 106 | handleQuantityChange(e.currentTarget.value)} 113 | /> 114 | 115 | {subtotal} 116 | 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/pages/cart.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: var(--text-display); 3 | font-weight: var(--bold); 4 | margin: 0; 5 | padding: var(--space-2xl) 0 var(--space-2xl); 6 | } 7 | 8 | .table { 9 | flex: 1; 10 | max-width: 1024px; 11 | margin: auto; 12 | width: 100%; 13 | display: grid; 14 | grid-template-columns: 48px 1fr min-content; 15 | gap: var(--space-md) var(--space-2xl); 16 | } 17 | 18 | .table th { 19 | text-align: left; 20 | text-transform: uppercase; 21 | font-size: var(--text-xs); 22 | padding-bottom: var(--space-2xl); 23 | } 24 | 25 | .table tr, 26 | .table thead, 27 | .table tbody { 28 | display: contents; 29 | } 30 | 31 | .wrap { 32 | display: flex; 33 | flex-direction: column; 34 | align-items: stretch; 35 | padding: 0 var(--size-gutter-raw); 36 | } 37 | 38 | .totals, 39 | .table th.totals { 40 | text-align: right; 41 | } 42 | 43 | .summary { 44 | font-weight: var(--semibold); 45 | font-size: var(--text-lg); 46 | } 47 | 48 | .grandTotal { 49 | font-size: var(--text-2xl); 50 | font-weight: var(--semibold); 51 | } 52 | 53 | .checkoutButton { 54 | align-items: center; 55 | background: var(--primary); 56 | border-radius: var(--radius-md); 57 | color: var(--text-color-inverted); 58 | display: flex; 59 | font-size: var(--text-md); 60 | font-weight: var(--semibold); 61 | height: var(--size-input); 62 | justify-content: center; 63 | line-height: var(--solid); 64 | margin: var(--space-3xl) auto var(--space-md); 65 | padding: var(--space-sm) var(--space-xl); 66 | width: max-content; 67 | transition: var(--transition); 68 | } 69 | 70 | .checkoutButton:hover { 71 | box-shadow: var(--shadow); 72 | } 73 | 74 | /* Apply only to first of class */ 75 | .summary td { 76 | padding-top: var(--space-3xl); 77 | } 78 | 79 | .summary ~ .summary td { 80 | padding-top: 0; 81 | } 82 | 83 | .summary .labelColumn { 84 | grid-column-start: 1; 85 | grid-column-end: 2; 86 | } 87 | 88 | .summary .totals { 89 | grid-column-start: 2; 90 | grid-column-end: 4; 91 | } 92 | 93 | .grandTotal .labelColumn { 94 | grid-column-start: 1; 95 | grid-column-end: 3; 96 | } 97 | 98 | .collapseColumn { 99 | display: none; 100 | } 101 | 102 | .productHeader { 103 | grid-column-start: 1; 104 | grid-column-end: 3; 105 | } 106 | 107 | .imageHeader { 108 | position: fixed; 109 | width: 0; 110 | height: 0; 111 | overflow: hidden; 112 | } 113 | 114 | .emptyStateContainer { 115 | text-align: center; 116 | max-width: 48ch; 117 | margin-left: auto; 118 | margin-right: auto; 119 | min-height: 50vh; 120 | display: flex; 121 | justify-content: center; 122 | flex-direction: column; 123 | } 124 | 125 | .emptyStateHeading { 126 | color: var(--text-color); 127 | font-size: var(--text-display); 128 | font-weight: var(--bold); 129 | margin-bottom: var(--space-lg); 130 | } 131 | 132 | .emptyStateLink { 133 | display: inline-block; 134 | color: var(--primary); 135 | font-weight: var(--bold); 136 | margin-top: var(--space-xl); 137 | font-size: var(--text-xl); 138 | } 139 | 140 | @media (min-width: 640px) { 141 | .summary .labelColumn, 142 | .summary .totals, 143 | .grandTotal .labelColumn { 144 | grid-column-start: auto; 145 | grid-column-end: auto; 146 | } 147 | 148 | .collapseColumn { 149 | display: block; 150 | } 151 | 152 | .table { 153 | grid-template-columns: 80px 1fr repeat(3, min-content); 154 | } 155 | } 156 | 157 | @media (min-width: 1024px) { 158 | .table { 159 | grid-template-columns: max-content 1fr repeat(3, max-content); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/utils/search.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string' 2 | import { urqlClient } from '../context/search-provider' 3 | 4 | export const ProductsQuery = ` 5 | query ($query: String!, $sortKey: ProductSortKeys, $first: Int, $last: Int, $after: String, $before: String) { 6 | products( 7 | query: $query 8 | sortKey: $sortKey 9 | first: $first 10 | last: $last 11 | after: $after 12 | before: $before 13 | ) { 14 | pageInfo { 15 | hasNextPage 16 | hasPreviousPage 17 | } 18 | edges { 19 | cursor 20 | node { 21 | title 22 | vendor 23 | productType 24 | handle 25 | priceRangeV2: priceRange { 26 | minVariantPrice { 27 | currencyCode 28 | amount 29 | } 30 | maxVariantPrice { 31 | currencyCode 32 | amount 33 | } 34 | } 35 | id 36 | images(first: 1) { 37 | edges { 38 | node { 39 | originalSrc 40 | width 41 | height 42 | altText 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | ` 51 | function arrayify(value) { 52 | if (!value) { 53 | return [] 54 | } 55 | if (!Array.isArray(value)) { 56 | return [value] 57 | } 58 | return value 59 | } 60 | 61 | function makeFilter(field, selectedItems) { 62 | if (!selectedItems?.length) return 63 | if (selectedItems && !Array.isArray(selectedItems)) { 64 | selectedItems = [selectedItems] 65 | } 66 | return `(${selectedItems 67 | .map((item) => `${field}:${JSON.stringify(item)}`) 68 | .join(" OR ")})` 69 | } 70 | 71 | export function createQuery (filters) { 72 | const { term, tags, productTypes, minPrice, maxPrice, vendors } = filters 73 | const parts = [ 74 | term, 75 | makeFilter("tag", tags), 76 | makeFilter("product_type", productTypes), 77 | makeFilter("vendor", vendors), 78 | // Exclude empty filter values 79 | ].filter(Boolean) 80 | if (maxPrice) { 81 | parts.push(`variants.price:<="${maxPrice}"`) 82 | } 83 | if (minPrice) { 84 | parts.push(`variants.price:>="${minPrice}"`) 85 | } 86 | 87 | return parts.join(" ") 88 | } 89 | 90 | /** 91 | * Extracts default search values from the query string or object 92 | * @param {string|object} query 93 | */ 94 | export function getValuesFromQuery(query) { 95 | const isClient = typeof query === 'string' 96 | const { 97 | q: term, 98 | s: sortKey, 99 | x: maxPrice, 100 | n: minPrice, 101 | p, 102 | t, 103 | v, 104 | } = isClient 105 | ? queryString.parse(query) 106 | : query 107 | return { 108 | term, 109 | sortKey, 110 | maxPrice, 111 | minPrice, 112 | productTypes: arrayify(p), 113 | tags: arrayify(t), 114 | vendors: arrayify(v), 115 | } 116 | } 117 | 118 | export async function getSearchResults({ 119 | query, 120 | count = 24, 121 | }) { 122 | const filters = getValuesFromQuery(query) 123 | 124 | // Relevance is non-deterministic if there is no query, so we default to "title" instead 125 | const initialSortKey = filters.term ? "RELEVANCE" : "TITLE" 126 | 127 | const urqlQuery = createQuery(filters) 128 | 129 | const results = await urqlClient 130 | .query(ProductsQuery, { 131 | query: urqlQuery, 132 | // this does not support paginated results 133 | first: count, 134 | sortKey: filters.sortKey || initialSortKey, 135 | }) 136 | .toPromise() 137 | 138 | return results.data?.products?.edges 139 | } 140 | -------------------------------------------------------------------------------- /src/context/store-context.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import fetch from "isomorphic-fetch" 3 | import Client from "shopify-buy" 4 | 5 | const client = Client.buildClient( 6 | { 7 | domain: process.env.GATSBY_SHOPIFY_STORE_URL, 8 | storefrontAccessToken: process.env.GATSBY_STOREFRONT_ACCESS_TOKEN, 9 | }, 10 | fetch 11 | ) 12 | 13 | const defaultValues = { 14 | cart: [], 15 | isOpen: false, 16 | loading: false, 17 | onOpen: () => {}, 18 | onClose: () => {}, 19 | addVariantToCart: () => {}, 20 | removeLineItem: () => {}, 21 | updateLineItem: () => {}, 22 | client, 23 | checkout: { 24 | lineItems: [], 25 | }, 26 | } 27 | 28 | export const StoreContext = React.createContext(defaultValues) 29 | 30 | const isBrowser = typeof window !== `undefined` 31 | const localStorageKey = `shopify_checkout_id` 32 | 33 | export const StoreProvider = ({ children }) => { 34 | const [checkout, setCheckout] = React.useState(defaultValues.checkout) 35 | const [loading, setLoading] = React.useState(false) 36 | const [didJustAddToCart, setDidJustAddToCart] = React.useState(false) 37 | 38 | const setCheckoutItem = (checkout) => { 39 | if (isBrowser) { 40 | localStorage.setItem(localStorageKey, checkout.id) 41 | } 42 | 43 | setCheckout(checkout) 44 | } 45 | 46 | React.useEffect(() => { 47 | const initializeCheckout = async () => { 48 | const existingCheckoutID = isBrowser 49 | ? localStorage.getItem(localStorageKey) 50 | : null 51 | 52 | if (existingCheckoutID && existingCheckoutID !== `null`) { 53 | try { 54 | const existingCheckout = await client.checkout.fetch( 55 | existingCheckoutID 56 | ) 57 | if (!existingCheckout.completedAt) { 58 | setCheckoutItem(existingCheckout) 59 | return 60 | } 61 | } catch (e) { 62 | localStorage.setItem(localStorageKey, null) 63 | } 64 | } 65 | 66 | const newCheckout = await client.checkout.create() 67 | setCheckoutItem(newCheckout) 68 | } 69 | 70 | initializeCheckout() 71 | }, []) 72 | 73 | const addVariantToCart = (variantId, quantity) => { 74 | setLoading(true) 75 | 76 | const checkoutID = checkout.id 77 | 78 | const lineItemsToUpdate = [ 79 | { 80 | variantId, 81 | quantity: parseInt(quantity, 10), 82 | }, 83 | ] 84 | 85 | return client.checkout 86 | .addLineItems(checkoutID, lineItemsToUpdate) 87 | .then((res) => { 88 | setCheckout(res) 89 | setLoading(false) 90 | setDidJustAddToCart(true) 91 | setTimeout(() => setDidJustAddToCart(false), 3000) 92 | }) 93 | } 94 | 95 | const removeLineItem = (checkoutID, lineItemID) => { 96 | setLoading(true) 97 | 98 | return client.checkout 99 | .removeLineItems(checkoutID, [lineItemID]) 100 | .then((res) => { 101 | setCheckout(res) 102 | setLoading(false) 103 | }) 104 | } 105 | 106 | const updateLineItem = (checkoutID, lineItemID, quantity) => { 107 | setLoading(true) 108 | 109 | const lineItemsToUpdate = [ 110 | { id: lineItemID, quantity: parseInt(quantity, 10) }, 111 | ] 112 | 113 | return client.checkout 114 | .updateLineItems(checkoutID, lineItemsToUpdate) 115 | .then((res) => { 116 | setCheckout(res) 117 | setLoading(false) 118 | }) 119 | } 120 | 121 | return ( 122 | 133 | {children} 134 | 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | line-height: 1.5; 3 | -webkit-text-size-adjust: 100%; 4 | font-family: system-ui, sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | text-rendering: optimizeLegibility; 7 | -moz-osx-font-smoothing: grayscale; 8 | touch-action: manipulation; 9 | } 10 | body { 11 | position: relative; 12 | min-height: 100%; 13 | font-feature-settings: "kern"; 14 | } 15 | *, 16 | *::before, 17 | *::after { 18 | border-width: 0; 19 | border-style: solid; 20 | box-sizing: border-box; 21 | } 22 | main { 23 | display: block; 24 | } 25 | hr { 26 | border-top-width: 1px; 27 | box-sizing: content-box; 28 | height: 0; 29 | overflow: visible; 30 | } 31 | pre, 32 | code, 33 | kbd, 34 | samp { 35 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; 36 | font-size: 1em; 37 | } 38 | a { 39 | background-color: transparent; 40 | color: inherit; 41 | text-decoration: inherit; 42 | } 43 | abbr[title] { 44 | border-bottom: none; 45 | text-decoration: underline; 46 | -webkit-text-decoration: underline dotted; 47 | text-decoration: underline dotted; 48 | } 49 | b, 50 | strong { 51 | font-weight: bold; 52 | } 53 | small { 54 | font-size: 80%; 55 | } 56 | sub, 57 | sup { 58 | font-size: 75%; 59 | line-height: 0; 60 | position: relative; 61 | vertical-align: baseline; 62 | } 63 | sub { 64 | bottom: -0.25em; 65 | } 66 | sup { 67 | top: -0.5em; 68 | } 69 | img { 70 | border-style: none; 71 | } 72 | button, 73 | input, 74 | optgroup, 75 | select, 76 | textarea { 77 | font-family: inherit; 78 | font-size: 100%; 79 | line-height: 1.15; 80 | margin: 0; 81 | } 82 | button, 83 | input { 84 | overflow: visible; 85 | } 86 | button, 87 | select { 88 | text-transform: none; 89 | } 90 | button::-moz-focus-inner, 91 | [type="button"]::-moz-focus-inner, 92 | [type="reset"]::-moz-focus-inner, 93 | [type="submit"]::-moz-focus-inner { 94 | border-style: none; 95 | padding: 0; 96 | } 97 | fieldset { 98 | padding: 0.35em 0.75em 0.625em; 99 | } 100 | legend { 101 | box-sizing: border-box; 102 | color: inherit; 103 | display: table; 104 | max-width: 100%; 105 | padding: 0; 106 | white-space: normal; 107 | } 108 | progress { 109 | vertical-align: baseline; 110 | } 111 | textarea { 112 | overflow: auto; 113 | } 114 | [type="checkbox"], 115 | [type="radio"] { 116 | box-sizing: border-box; 117 | padding: 0; 118 | } 119 | [type="number"]::-webkit-inner-spin-button, 120 | [type="number"]::-webkit-outer-spin-button { 121 | -webkit-appearance: none !important; 122 | } 123 | input[type="number"] { 124 | -moz-appearance: textfield; 125 | } 126 | [type="search"] { 127 | -webkit-appearance: textfield; 128 | outline-offset: -2px; 129 | } 130 | [type="search"]::-webkit-search-decoration { 131 | -webkit-appearance: none !important; 132 | } 133 | ::-webkit-file-upload-button { 134 | -webkit-appearance: button; 135 | font: inherit; 136 | } 137 | details { 138 | display: block; 139 | } 140 | summary { 141 | display: list-item; 142 | } 143 | template { 144 | display: none; 145 | } 146 | [hidden] { 147 | display: none !important; 148 | } 149 | body, 150 | blockquote, 151 | dl, 152 | dd, 153 | h1, 154 | h2, 155 | h3, 156 | h4, 157 | h5, 158 | h6, 159 | hr, 160 | figure, 161 | p, 162 | pre { 163 | margin: 0; 164 | } 165 | button { 166 | background: transparent; 167 | padding: 0; 168 | } 169 | fieldset { 170 | margin: 0; 171 | padding: 0; 172 | } 173 | ol, 174 | ul { 175 | margin: 0; 176 | padding: 0; 177 | } 178 | textarea { 179 | resize: vertical; 180 | } 181 | button, 182 | [role="button"] { 183 | cursor: pointer; 184 | } 185 | button::-moz-focus-inner { 186 | border: 0 !important; 187 | } 188 | table { 189 | border-collapse: collapse; 190 | } 191 | h1, 192 | h2, 193 | h3, 194 | h4, 195 | h5, 196 | h6 { 197 | font-size: inherit; 198 | font-weight: inherit; 199 | } 200 | button, 201 | input, 202 | optgroup, 203 | select, 204 | textarea { 205 | padding: 0; 206 | line-height: inherit; 207 | color: inherit; 208 | } 209 | img, 210 | svg, 211 | video, 212 | canvas, 213 | audio, 214 | iframe, 215 | embed, 216 | object { 217 | display: block; 218 | } 219 | img, 220 | video { 221 | max-width: 100%; 222 | height: auto; 223 | } 224 | select::-ms-expand { 225 | display: none; 226 | } 227 | -------------------------------------------------------------------------------- /src/utils/hooks.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useMemo } from "react" 2 | import queryString from "query-string" 3 | import { useQuery } from "urql" 4 | import { ProductsQuery, createQuery } from './search' 5 | 6 | function makeQueryStringValue(allItems, selectedItems) { 7 | if (allItems.length === selectedItems.length) { 8 | return [] 9 | } 10 | return selectedItems 11 | } 12 | 13 | export function useProductSearch( 14 | filters, 15 | { allTags, allProductTypes, allVendors }, 16 | sortKey, 17 | pause = false, 18 | count = 20, 19 | initialData = [], 20 | initialFilters, 21 | ) { 22 | const [query, setQuery] = useState(createQuery(filters)) 23 | const [cursors, setCursors] = useState({ 24 | before: null, 25 | after: null, 26 | }) 27 | const [initialRender, setInitialRender] = useState(true) 28 | const { term, tags, productTypes, minPrice, maxPrice, vendors } = filters 29 | 30 | // Relevance is non-deterministic if there is no query, so we default to "title" instead 31 | const initialSortKey = filters.term ? "RELEVANCE" : "TITLE" 32 | 33 | // only fetch after the filters have changed 34 | const shouldPause = useMemo(() => (query === createQuery(initialFilters)) || pause, [query, pause, initialFilters]) 35 | 36 | const [result] = useQuery({ 37 | query: ProductsQuery, 38 | variables: { 39 | query, 40 | sortKey: sortKey || initialSortKey, 41 | first: !cursors.before ? count : null, 42 | last: cursors.before ? count : null, 43 | after: cursors.after, 44 | before: cursors.before, 45 | }, 46 | pause: shouldPause, 47 | }) 48 | 49 | useEffect(() => { 50 | const qs = queryString.stringify({ 51 | // Don't show if falsy 52 | q: term || undefined, 53 | x: maxPrice || undefined, 54 | n: minPrice || undefined, 55 | // Don't show if sort order is default 56 | s: sortKey === initialSortKey ? undefined : sortKey, 57 | // Don't show if all values are selected 58 | p: makeQueryStringValue(allProductTypes, productTypes), 59 | v: makeQueryStringValue(allVendors, vendors), 60 | t: makeQueryStringValue(allTags, tags), 61 | c: cursors.after || undefined, 62 | }) 63 | 64 | const url = new URL(window.location.href) 65 | url.search = qs 66 | url.hash = "" 67 | window.history.replaceState({}, null, url.toString()) 68 | setQuery(createQuery(filters)) 69 | // eslint-disable-next-line react-hooks/exhaustive-deps 70 | }, [filters, cursors, sortKey]) 71 | 72 | const fetchPreviousPage = () => { 73 | // when we go back we want all products before the first one of our array 74 | const previousCursor = result.data.products.edges[0].cursor 75 | setCursors({ 76 | before: previousCursor, 77 | after: null, 78 | }) 79 | } 80 | const fetchNextPage = () => { 81 | // when we go forward we want all products after the first one of our array 82 | const prods = result.data.products 83 | const nextCursor = prods.edges[prods.edges.length - 1].cursor 84 | setCursors({ 85 | before: null, 86 | after: nextCursor, 87 | }) 88 | } 89 | 90 | const filterCount = 91 | (filters.tags.length === allTags.length ? 0 : filters.tags.length) + 92 | (filters.productTypes.length === allProductTypes.length 93 | ? 0 94 | : filters.productTypes.length) + 95 | (filters.vendors.length === allVendors.length 96 | ? 0 97 | : filters.vendors.length) + 98 | (filters.minPrice ? 1 : 0) + 99 | (filters.maxPrice ? 1 : 0) 100 | 101 | let hasPreviousPage 102 | let hasNextPage 103 | 104 | const products = useMemo(() => { 105 | if (query === createQuery(initialFilters)) { 106 | return initialData 107 | } 108 | if (result.data && initialRender) setInitialRender(false) 109 | return result.data?.products?.edges || [] 110 | // eslint-disable-next-line react-hooks/exhaustive-deps 111 | }, [query, result.data, initialData, initialFilters]) 112 | 113 | if (result && result.data) { 114 | hasPreviousPage = result.data.products.pageInfo.hasPreviousPage 115 | hasNextPage = result.data.products.pageInfo.hasNextPage 116 | } 117 | 118 | const isFetching = !initialRender && result.fetching 119 | 120 | return { 121 | data: result.data, 122 | isFetching, 123 | hasPreviousPage, 124 | hasNextPage, 125 | products, 126 | filterCount, 127 | fetchNextPage, 128 | fetchPreviousPage, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/pages/products/{ShopifyProduct.productType}/product-page.module.css: -------------------------------------------------------------------------------- 1 | .productBox { 2 | display: grid; 3 | grid-template-columns: 1fr; 4 | column-gap: var(--space-3xl); 5 | } 6 | 7 | .container { 8 | padding: var(--size-gutter-raw); 9 | } 10 | 11 | .header { 12 | font-size: var(--text-display); 13 | font-weight: var(--bold); 14 | margin-bottom: var(--space-xl); 15 | line-height: var(--dense); 16 | } 17 | 18 | .productDescription { 19 | font-size: var(--text-prose); 20 | } 21 | 22 | .productImageWrapper { 23 | position: relative; 24 | padding-bottom: var(--space-2xl); 25 | } 26 | 27 | .productImageList { 28 | display: flex; 29 | overflow-x: auto; 30 | } 31 | 32 | .productImageListItem { 33 | display: flex; 34 | flex: 0 0 100%; 35 | white-space: nowrap; 36 | } 37 | 38 | .scrollForMore { 39 | text-align: center; 40 | margin-top: 1rem; 41 | display: none; 42 | font-size: var(--text-lg); 43 | transform: translate3d(-50%, 0px, 0px); 44 | left: 50%; 45 | position: absolute; 46 | } 47 | 48 | .noImagePreview { 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | min-height: 300px; 53 | font-size: var(--text-lg); 54 | } 55 | 56 | .priceValue > span { 57 | font-size: var(--text-display); 58 | font-weight: var(--bold); 59 | line-height: var(--dense); 60 | color: var(--primary); 61 | } 62 | 63 | .priceValue { 64 | padding: var(--space-lg) 0; 65 | } 66 | 67 | .visuallyHidden { 68 | position: absolute; 69 | width: 1px; 70 | height: 1px; 71 | padding: 0; 72 | margin: -1px; 73 | overflow: hidden; 74 | clip: rect(0, 0, 0, 0); 75 | white-space: nowrap; 76 | border: 0; 77 | } 78 | 79 | .optionsWrapper { 80 | display: grid; 81 | grid-template-columns: var(--product-grid); 82 | gap: var(--space-lg); 83 | padding-bottom: var(--space-lg); 84 | } 85 | 86 | .addToCartStyle { 87 | display: grid; 88 | grid-template-columns: min-content 1fr; 89 | gap: var(--space-lg); 90 | } 91 | 92 | .selectVariant { 93 | background-color: var(--input-background); 94 | border-radius: var(--radius-md); 95 | cursor: pointer; 96 | margin-top: var(--space-md); 97 | min-width: 24ch; 98 | position: relative; 99 | } 100 | 101 | .selectVariant select { 102 | appearance: none; 103 | background-color: transparent; 104 | border: none; 105 | color: var(--input-text); 106 | cursor: inherit; 107 | font-size: var(--text-md); 108 | font-weight: var(--medium); 109 | height: var(--size-input); 110 | margin: 0; 111 | padding: var(--space-sm) var(--space-lg); 112 | padding-right: var(--space-2xl); 113 | width: 100%; 114 | } 115 | 116 | .selectVariant::after { 117 | background-image: url("data:image/svg+xml,%3Csvg fill='none' height='8' viewBox='0 0 13 8' width='13' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m6.87794 7.56356c-.19939.23023-.55654.23024-.75593 0l-5.400738-6.23623c-.280438-.32383-.050412-.82733.377968-.82733h10.80146c.4284 0 .6584.5035.378.82733z' fill='%2378757a'/%3E%3C/svg%3E"); 118 | background-repeat: no-repeat; 119 | content: ""; 120 | height: 8px; 121 | position: absolute; 122 | right: var(--space-lg); 123 | top: 50%; 124 | transform: translateY(-50%); 125 | width: 13px; 126 | pointer-events: none; 127 | } 128 | 129 | .labelFont { 130 | font-size: var(--space-lg); 131 | line-height: var(--space-xl); 132 | padding-right: var(--space-md); 133 | color: var(--text-color-secondary); 134 | } 135 | 136 | .tagList a { 137 | font-weight: var(--semibold); 138 | color: var(--text-color-secondary); 139 | padding-right: var(--space-md); 140 | } 141 | 142 | .tagList a:hover { 143 | color: var(--text-color); 144 | text-decoration: underline; 145 | } 146 | 147 | .breadcrumb { 148 | color: var(--text-color-secondary); 149 | font-size: var(--text-sm); 150 | display: flex; 151 | align-items: center; 152 | flex-direction: row; 153 | } 154 | 155 | .breadcrumb a:hover { 156 | color: var(--text-color); 157 | text-decoration: underline; 158 | } 159 | 160 | .metaSection { 161 | padding-top: var(--space-3xl); 162 | display: grid; 163 | grid-template-columns: max-content 1fr; 164 | align-items: baseline; 165 | } 166 | 167 | @media (min-width: 640px) { 168 | .productBox { 169 | grid-template-columns: 1fr 2fr; 170 | } 171 | .addToCartStyle { 172 | grid-template-columns: min-content max-content; 173 | } 174 | } 175 | 176 | @media (min-width: 1024px) { 177 | .productBox { 178 | grid-template-columns: repeat(2, 1fr); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/pages/cart.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Link } from "gatsby" 3 | import { Layout } from "../components/layout" 4 | import { StoreContext } from "../context/store-context" 5 | import { LineItem } from "../components/line-item" 6 | import { formatPrice } from "../utils/format-price" 7 | import { 8 | table, 9 | wrap, 10 | totals, 11 | grandTotal, 12 | summary, 13 | checkoutButton, 14 | collapseColumn, 15 | labelColumn, 16 | imageHeader, 17 | productHeader, 18 | emptyStateContainer, 19 | emptyStateHeading, 20 | emptyStateLink, 21 | title, 22 | } from "./cart.module.css" 23 | import { Seo } from "../components/seo" 24 | 25 | export default function CartPage() { 26 | const { checkout, loading } = React.useContext(StoreContext) 27 | const emptyCart = checkout.lineItems.length === 0 28 | 29 | const handleCheckout = () => { 30 | window.open(checkout.webUrl) 31 | } 32 | 33 | return ( 34 | 35 |
36 | {emptyCart ? ( 37 |
38 |

Your cart is empty

39 |

40 | Looks like you haven’t found anything yet. We understand that 41 | sometimes it’s hard to choose — maybe this helps: 42 |

43 | 44 | View trending products 45 | 46 |
47 | ) : ( 48 | <> 49 |

Your cart

50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | {checkout.lineItems.map((item) => ( 62 | 63 | ))} 64 | 65 | 66 | 67 | 68 | 69 | 70 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 107 | 108 | 109 |
ImageProductPriceQty.Total
Subtotal 71 | {formatPrice( 72 | checkout.subtotalPriceV2.currencyCode, 73 | checkout.subtotalPriceV2.amount 74 | )} 75 |
Taxes 83 | {formatPrice( 84 | checkout.totalTaxV2.currencyCode, 85 | checkout.totalTaxV2.amount 86 | )} 87 |
ShippingCalculated at checkout
Total Price 102 | {formatPrice( 103 | checkout.totalPriceV2.currencyCode, 104 | checkout.totalPriceV2.amount 105 | )} 106 |
110 | 117 | 118 | )} 119 |
120 |
121 | ) 122 | } 123 | 124 | export const Head = () => 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Gatsby 4 | 5 |

6 |

7 | Gatsby Starter Shopify 8 |

9 | 10 | Kick off your next [Shopify](https://www.shopify.com/) project with this boilerplate. This starter creates a store with a custom landing page, individual filtered views for each product, detailed product pages, advanced instant search and a shopping cart. All styled with CSS Modules. 11 | 12 | Deploy this starter with one click on [Gatsby Cloud](https://www.gatsbyjs.com/cloud/): 13 | 14 | [Deploy to Gatsby Cloud](https://www.gatsbyjs.com/dashboard/deploynow?url=https://github.com/gatsbyjs/gatsby-starter-shopify) 15 | 16 | Check out the [demo site](https://shopify-demo.gatsbyjs.com) showcasing a proof-of-concept with 10k products and 30k variants. 17 | 18 | ## 🚀 Quick start 19 | 20 | 1. **Create a Gatsby site.** 21 | 22 | Use the Gatsby CLI to create a new site, specifying the Shopify starter. 23 | 24 | ```shell 25 | # create a new Gatsby site using the Shopify starter 26 | npx gatsby new my-shopify-store https://github.com/gatsbyjs/gatsby-starter-shopify 27 | ``` 28 | 29 | 2. **Link to your store** 30 | 31 | Follow these instructions here to [link your Shopify store](https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-source-shopify#readme). Create a `.env` file with your Shopify store URL, password, and Storefront access token, using the `.env.example` file as an example. If you want to try with a development store, see [the sample data and instructions here](example/). 32 | 33 | 3. **Start developing.** 34 | 35 | Navigate into your new site’s directory and start it up. 36 | 37 | ```shell 38 | cd my-shopify-starter/ 39 | npm start 40 | ``` 41 | 42 | 4. **Open the source code and start editing!** 43 | 44 | Your site is now running at `http://localhost:8000`! 45 | 46 | _Note: You'll also see a second link: _`http://localhost:8000/___graphql`_. This is a tool you can use to experiment with querying your data. Learn more about using this tool in the [Gatsby tutorial](https://www.gatsbyjs.com/tutorial/part-five/#introducing-graphiql)._ 47 | 48 | Open the `my-shopify-starter` directory in your code editor of choice and edit `src/pages/index.jsx`. Save your changes and the browser will update in real time! 49 | 50 | ## 🧐 What's inside? 51 | 52 | A quick look at the top-level files and directories you'll see in this project. 53 | 54 | . 55 | ├── example 56 | ├── src 57 | ├── static 58 | ├── .env.example 59 | ├── gatsby-browser.js 60 | ├── gatsby-config.js 61 | └── gatsby-node.js 62 | 63 | 1. **`/example`**: This directory includes a CSV file containing sample data to import into a development store. There are also instructions on generating your own sample data, and a link to a dataset with 30,000 SKUs. 64 | 65 | 2. **`/src`**: This directory will contain all of the code related to what you will see on the front-end of your site (what you see in the browser) such as your site header or a page template. `src` is a convention for “source code”. 66 | 67 | 3. **`/static`**: Every file in this directory will be copied over to the `public` folder during the build. Learn more about [using the `static` folder](https://www.gatsbyjs.com/docs/how-to/images-and-media/static-folder/). In this project it holds the `og:image` and favicons. 68 | 69 | 4. **`/.env.example`**: Duplicate this file, rename it to `.env`, and fill out the keys. You'll need to define those environment variables to get the source plugin, cart and search working. 70 | 71 | 5. **`gatsby-browser.js`**: This file is where Gatsby expects to find any usage of the [Gatsby browser APIs](https://www.gatsbyjs.com/docs/browser-apis/) (if any). These allow customization/extension of default Gatsby settings affecting the browser. In this project it wraps the whole application with the context provider of the store/shopping cart. 72 | 73 | 6. **`gatsby-config.js`**: This is the main configuration file for a Gatsby site. This is where you can specify information about your site (metadata) like the site title and description, which Gatsby plugins you’d like to include, etc. (Check out the [config docs](https://www.gatsbyjs.com/docs/gatsby-config/) for more detail). 74 | 75 | 7. **`gatsby-node.js`**: This file is where Gatsby expects to find any usage of the [Gatsby Node APIs](https://www.gatsbyjs.com/docs/node-apis/) (if any). These allow customization/extension of default Gatsby settings affecting pieces of the site build process. In this project it adds a custom Babel plugin to Gatsby. 76 | 77 | ### Detailed look into `src` 78 | 79 | The whole logic for how the site looks and behaves is inside `src`. 80 | 81 | . 82 | ├── components 83 | ├── context 84 | ├── icons 85 | ├── images 86 | ├── pages 87 | ├── styles 88 | └── utils 89 | 90 | 1. **`/components`**: Contains the React components used for building out the pages. 91 | 92 | 2. **`/context`**: Contains the store context (e.g. adding/deleting/updating items in shopping cart, accessing Shopify), and the urql context used for search using Shopify's Storefront API. 93 | 94 | 3. **`/icons`**: Contains all custom SVG icons and the logo. 95 | 96 | 4. **`/pages`**: Contains the homepage and all automatically generated pages for each product category and individual product pages. The [File System Route API](https://www.gatsbyjs.com/docs/reference/routing/file-system-route-api/) is used to create those pages from your Shopify data. 97 | 98 | 5. **`/styles`**: Contains globals styles. These are `variables.css`, used to define shared CSS custom properties, `reset.css`, which contains a CSS reset based on Chakra, and `global.css`, which includes a tiny set of global styles. 99 | 100 | 6. **`/utils`**: Utility functions, e.g. formatting the price correctly, plus custom hooks used for handling search and pagination. 101 | 102 | ### 🎨 Styling 103 | 104 | The site uses [CSS Modules](https://github.com/css-modules/css-modules) for styling, which allows you to use regular CSS, scoped to the individual component. Theme values such as fonts, colors and spacing are set in `src/styles/variables.css`. 105 | 106 | ### SSR Search Page 107 | 108 | The `/search` page uses server-side rendering to show a list of products based on filters and search terms in the URL query parameters. 109 | 110 | 111 | ## 🎓 Learning Gatsby 112 | 113 | Looking for more guidance? Full documentation for Gatsby lives [on the website](https://www.gatsbyjs.com/). Here are some places to start: 114 | 115 | - **For most developers, we recommend starting with our [in-depth tutorial for creating a site with Gatsby](https://www.gatsbyjs.com/tutorial/).** It starts with zero assumptions about your level of ability and walks through every step of the process. 116 | 117 | - **To dive straight into code samples, head [to our documentation](https://www.gatsbyjs.com/docs/).** In particular, check out the _Guides_, _API Reference_, and _Advanced Tutorials_ sections in the sidebar. 118 | 119 | ## 💫 Deploy 120 | 121 | [Build, Deploy, and Host On The Only Cloud Built For Gatsby](https://www.gatsbyjs.com/cloud/) 122 | 123 | Gatsby Cloud is an end-to-end cloud platform specifically built for the Gatsby framework that combines a modern developer experience with an optimized, global edge network. 124 | -------------------------------------------------------------------------------- /src/pages/search-page.module.css: -------------------------------------------------------------------------------- 1 | .visually-hidden { 2 | border: 0; 3 | clip: rect(0, 0, 0, 0); 4 | height: 1px; 5 | margin: -1px; 6 | overflow: hidden; 7 | padding: 0; 8 | position: absolute; 9 | width: 1px; 10 | } 11 | 12 | .main { 13 | display: grid; 14 | grid-template-columns: 1fr; 15 | grid-template-rows: min-content 1fr; 16 | grid-template-areas: 17 | "search" 18 | "results" 19 | "filters"; 20 | } 21 | 22 | .filterStyle { 23 | grid-area: filters; 24 | /* Visually hidden */ 25 | width: 0; 26 | height: 0; 27 | overflow: hidden; 28 | } 29 | 30 | .filterStyle.modalOpen { 31 | display: grid; 32 | grid-template-rows: min-content 1fr; 33 | position: fixed; 34 | top: 0; 35 | left: 0; 36 | right: 0; 37 | bottom: 0; 38 | height: 100vh; 39 | width: 100vw; 40 | z-index: 10; 41 | background-color: var(--background); 42 | overscroll-behavior: contain; 43 | } 44 | 45 | .filterWrap { 46 | overflow-y: auto; 47 | padding: 0 var(--size-gutter-raw); 48 | } 49 | 50 | @media (min-width: 1024px) { 51 | .filterWrap { 52 | padding: 0; 53 | padding-right: var(--space-xl); 54 | } 55 | } 56 | 57 | .filterStyle details { 58 | margin-bottom: var(--space-2xl); 59 | } 60 | 61 | .filterStyle * + details { 62 | margin-top: var(--space-2xl); 63 | } 64 | 65 | .filterStyle hr { 66 | border-color: var(--border); 67 | } 68 | 69 | .filterTitle { 70 | padding: var(--size-gap) var(--size-gap) var(--size-gap) var(--space-2xl); 71 | display: grid; 72 | align-items: center; 73 | grid-template-columns: auto min-content; 74 | color: var(--text-color-secondary); 75 | } 76 | 77 | .filterTitle button { 78 | display: grid; 79 | place-items: center; 80 | width: var(--size-input); 81 | height: var(--size-input); 82 | position: relative; 83 | } 84 | 85 | .filterTitle h2 { 86 | font-size: var(--text-display); 87 | font-weight: var(--bold); 88 | } 89 | 90 | .results { 91 | grid-area: results; 92 | padding: 0 var(--space-2xl); 93 | } 94 | 95 | .results ul { 96 | flex-direction: column; 97 | } 98 | 99 | .search { 100 | position: relative; 101 | grid-area: search; 102 | height: min-content; 103 | display: grid; 104 | grid-template-columns: auto max-content max-content; 105 | align-items: center; 106 | padding: var(--space-md) var(--space-md) var(--space-md) 107 | var(--size-gutter-raw); 108 | } 109 | 110 | .search input { 111 | font-size: var(--text-md); 112 | font-weight: var(--medium); 113 | grid-area: input; 114 | padding-left: var(--space-md); 115 | height: var(--size-input); 116 | background: none; 117 | } 118 | 119 | .searchForm { 120 | display: grid; 121 | grid-template-columns: 1fr max-content; 122 | grid-template-areas: "input clear"; 123 | align-items: center; 124 | background-color: var(--input-background); 125 | border-radius: var(--radius-md); 126 | height: var(--size-input); 127 | padding: 0 var(--space-md); 128 | } 129 | 130 | .searchIcon { 131 | display: none; 132 | grid-area: icon; 133 | color: var(--text-color-secondary); 134 | } 135 | 136 | .clearSearch { 137 | grid-area: clear; 138 | border-radius: var(--radius-rounded); 139 | color: var(--input-ui); 140 | padding: var(--space-sm); 141 | display: grid; 142 | place-items: center; 143 | } 144 | 145 | .clearSearch:hover { 146 | background-color: var(--input-background-hover); 147 | color: var(--input-ui-active); 148 | } 149 | 150 | .filterButton { 151 | color: var(--text-color-secondary); 152 | display: grid; 153 | place-items: center; 154 | width: var(--size-input); 155 | height: var(--size-input); 156 | } 157 | 158 | .filterButton.activeFilters { 159 | color: var(--primary); 160 | } 161 | 162 | .filterButton:hover { 163 | color: var(--text-color-primary); 164 | } 165 | 166 | .sortSelector { 167 | display: grid; 168 | place-items: center; 169 | font-weight: var(--semibold); 170 | } 171 | 172 | .sortSelector label { 173 | grid-area: 1/1; 174 | cursor: pointer; 175 | } 176 | 177 | .sortSelector:hover .sortIcon { 178 | color: var(--text-color-primary); 179 | } 180 | 181 | .sortSelector select { 182 | padding-left: var(--space-lg); 183 | opacity: 0; 184 | width: var(--size-input); 185 | height: var(--size-input); 186 | cursor: pointer; 187 | font-weight: var(--semibold); 188 | appearance: none; 189 | background: url("data:image/svg+xml,%3Csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg' fill='black'%3E%3Cpolyline points='4 6 8 10 12 6'/%3E%3C/svg%3E") 190 | no-repeat center right; 191 | } 192 | 193 | .sortSelector label span { 194 | position: absolute; 195 | width: 0; 196 | height: 0; 197 | overflow: hidden; 198 | } 199 | 200 | .sortIcon { 201 | grid-area: 1/1; 202 | color: var(--text-color-secondary); 203 | } 204 | 205 | .productList { 206 | display: grid; 207 | grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); 208 | grid-gap: var(--space-lg); 209 | padding-top: var(--space-2xl); 210 | padding-right: var(--size-gutter-raw); 211 | } 212 | 213 | @media (min-width: 1024px) { 214 | .filterTitle { 215 | display: none; 216 | } 217 | 218 | .filterButton { 219 | display: none; 220 | } 221 | 222 | .filterStyle.modalOpen { 223 | position: fixed; 224 | } 225 | 226 | .filterStyle { 227 | padding-left: var(--size-gutter-raw); 228 | padding-right: var(--space-2xl); 229 | padding-top: 0; 230 | padding-bottom: 0; 231 | /* Visually hidden */ 232 | width: auto; 233 | height: auto; 234 | } 235 | 236 | .sortIcon { 237 | display: none; 238 | } 239 | 240 | .searchIcon { 241 | display: inline; 242 | } 243 | 244 | .searchForm { 245 | grid-template-columns: 30px 1fr max-content; 246 | grid-template-areas: "icon input clear"; 247 | } 248 | 249 | .search input { 250 | padding-left: 0; 251 | } 252 | 253 | .main { 254 | grid-template-columns: max-content 1fr; 255 | grid-template-areas: 256 | "search search" 257 | "filters results"; 258 | } 259 | 260 | .sortSelector { 261 | padding-left: var(--space-lg); 262 | padding-right: var(--size-gutter); 263 | justify-content: flex-end; 264 | } 265 | 266 | .sortSelector select { 267 | width: auto; 268 | padding-right: var(--space-xl); 269 | opacity: 1; 270 | } 271 | 272 | .sortSelector label span { 273 | position: initial; 274 | width: auto; 275 | height: auto; 276 | } 277 | } 278 | 279 | @media (min-width: 1280px) { 280 | .main { 281 | grid-template-areas: 282 | "filters search" 283 | "filters results"; 284 | padding-top: var(--space-2xl); 285 | } 286 | 287 | .search { 288 | grid-gap: var(--space-2xl); 289 | padding: 0; 290 | } 291 | 292 | .results { 293 | padding: 0; 294 | } 295 | 296 | .sortSelector { 297 | padding-left: 0; 298 | } 299 | } 300 | 301 | .productListItem { 302 | display: flex; 303 | justify-content: center; 304 | width: 100%; 305 | } 306 | 307 | .priceFilter { 308 | display: grid; 309 | } 310 | 311 | .pagination { 312 | display: flex; 313 | flex-direction: row; 314 | justify-content: center; 315 | margin-top: var(--size-gutter-raw); 316 | gap: var(--space-md); 317 | } 318 | 319 | .paginationButton { 320 | width: var(--size-input); 321 | height: var(--size-input); 322 | font-size: var(--text-md); 323 | border-radius: var(--radius-md); 324 | display: grid; 325 | place-items: center; 326 | } 327 | 328 | .paginationButton:hover { 329 | background-color: var(--black-fade-5); 330 | } 331 | 332 | .paginationButton:disabled { 333 | cursor: default; 334 | opacity: 0.5; 335 | } 336 | 337 | .paginationButton:disabled:hover { 338 | background-color: inherit; 339 | } 340 | 341 | .priceFilterStyle { 342 | display: flex; 343 | } 344 | 345 | .priceFilterStyle label { 346 | cursor: pointer; 347 | margin-top: 2px; 348 | } 349 | 350 | .priceFilterStyle summary { 351 | cursor: pointer; 352 | font-weight: var(--bold); 353 | text-transform: uppercase; 354 | display: flex; 355 | align-items: center; 356 | justify-content: space-between; 357 | padding-bottom: var(--space-md); 358 | font-size: var(--text-xs); 359 | letter-spacing: var(--tracked); 360 | } 361 | 362 | .priceFields { 363 | display: flex; 364 | flex-direction: row; 365 | justify-content: space-between; 366 | align-items: center; 367 | gap: var(--space-sm); 368 | } 369 | 370 | .priceFields input { 371 | max-width: 96px; 372 | } 373 | 374 | .progressStyle { 375 | font-size: var(--text-md); 376 | color: var(--text-color-secondary); 377 | display: flex; 378 | flex-direction: row; 379 | align-items: center; 380 | gap: var(--size-gap); 381 | padding: var(--space-xl) 0; 382 | } 383 | 384 | .resultsStyle { 385 | font-size: var(--text-md); 386 | color: var(--text-color); 387 | padding: var(--space-xl) 0; 388 | } 389 | 390 | .resultsStyle span { 391 | font-weight: var(--bold); 392 | } 393 | 394 | .emptyState { 395 | width: 100%; 396 | padding: var(--space-xl); 397 | align-items: center; 398 | text-align: center; 399 | color: var(--grey-50); 400 | font-weight: var(--bold); 401 | } 402 | -------------------------------------------------------------------------------- /src/pages/products/{ShopifyProduct.productType}/{ShopifyProduct.handle}.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql, Link } from "gatsby" 3 | import { Layout } from "../../../components/layout" 4 | import isEqual from "lodash.isequal" 5 | import { GatsbyImage, getSrc } from "gatsby-plugin-image" 6 | import { StoreContext } from "../../../context/store-context" 7 | import { AddToCart } from "../../../components/add-to-cart" 8 | import { NumericInput } from "../../../components/numeric-input" 9 | import { formatPrice } from "../../../utils/format-price" 10 | import { Seo } from "../../../components/seo" 11 | import { CgChevronRight as ChevronIcon } from "react-icons/cg" 12 | import { 13 | productBox, 14 | container, 15 | header, 16 | productImageWrapper, 17 | productImageList, 18 | productImageListItem, 19 | scrollForMore, 20 | noImagePreview, 21 | optionsWrapper, 22 | priceValue, 23 | selectVariant, 24 | labelFont, 25 | breadcrumb, 26 | tagList, 27 | addToCartStyle, 28 | metaSection, 29 | productDescription, 30 | } from "./product-page.module.css" 31 | 32 | export default function Product({ data: { product, suggestions } }) { 33 | const { 34 | options, 35 | variants, 36 | variants: [initialVariant], 37 | priceRangeV2, 38 | title, 39 | description, 40 | images, 41 | } = product 42 | const { client } = React.useContext(StoreContext) 43 | 44 | const [variant, setVariant] = React.useState({ ...initialVariant }) 45 | const [quantity, setQuantity] = React.useState(1) 46 | 47 | const productVariant = 48 | client.product.helpers.variantForOptions(product, variant) || variant 49 | 50 | const [available, setAvailable] = React.useState( 51 | productVariant.availableForSale 52 | ) 53 | 54 | const checkAvailablity = React.useCallback( 55 | (productId) => { 56 | client.product.fetch(productId).then((fetchedProduct) => { 57 | const result = 58 | fetchedProduct?.variants.filter( 59 | (variant) => variant.id === productVariant.storefrontId 60 | ) ?? [] 61 | 62 | if (result.length > 0) { 63 | setAvailable(result[0].available) 64 | } 65 | }) 66 | }, 67 | [productVariant.storefrontId, client.product] 68 | ) 69 | 70 | const handleOptionChange = (index, event) => { 71 | const value = event.target.value 72 | 73 | if (value === "") { 74 | return 75 | } 76 | 77 | const currentOptions = [...variant.selectedOptions] 78 | 79 | currentOptions[index] = { 80 | ...currentOptions[index], 81 | value, 82 | } 83 | 84 | const selectedVariant = variants.find((variant) => { 85 | return isEqual(currentOptions, variant.selectedOptions) 86 | }) 87 | 88 | setVariant({ ...selectedVariant }) 89 | } 90 | 91 | React.useEffect(() => { 92 | checkAvailablity(product.storefrontId) 93 | }, [productVariant.storefrontId, checkAvailablity, product.storefrontId]) 94 | 95 | const price = formatPrice( 96 | priceRangeV2.minVariantPrice.currencyCode, 97 | variant.price 98 | ) 99 | 100 | const hasVariants = variants.length > 1 101 | const hasImages = images.length > 0 102 | const hasMultipleImages = true || images.length > 1 103 | 104 | return ( 105 | 106 |
107 |
108 | {hasImages && ( 109 |
110 |
115 |
    116 | {images.map((image, index) => ( 117 |
  • 121 | 131 |
  • 132 | ))} 133 |
134 |
135 | {hasMultipleImages && ( 136 |
137 | scroll for more{" "} 138 | 139 |
140 | )} 141 |
142 | )} 143 | {!hasImages && ( 144 | No Preview image 145 | )} 146 |
147 |
148 | {product.productType} 149 | 150 |
151 |

{title}

152 |

{description}

153 |

154 | {price} 155 |

156 |
157 | {hasVariants && 158 | options.map(({ id, name, values }, index) => ( 159 |
160 | 171 |
172 | ))} 173 |
174 |
175 | setQuantity((q) => Math.min(q + 1, 20))} 178 | onDecrement={() => setQuantity((q) => Math.max(1, q - 1))} 179 | onChange={(event) => setQuantity(event.currentTarget.value)} 180 | value={quantity} 181 | min="1" 182 | max="20" 183 | /> 184 | 189 |
190 |
191 | Type 192 | 193 | {product.productType} 194 | 195 | Tags 196 | 197 | {product.tags.map((tag) => ( 198 | {tag} 199 | ))} 200 | 201 |
202 |
203 |
204 |
205 |
206 | ) 207 | } 208 | 209 | export const Head = ({ data: { product } }) => { 210 | const { 211 | title, 212 | description, 213 | images: [firstImage], 214 | } = product 215 | 216 | return ( 217 | <> 218 | {firstImage ? ( 219 | 224 | ) : undefined} 225 | 226 | ) 227 | } 228 | 229 | export const query = graphql` 230 | query($id: String!, $productType: String!) { 231 | product: shopifyProduct(id: { eq: $id }) { 232 | title 233 | description 234 | productType 235 | productTypeSlug: gatsbyPath( 236 | filePath: "/products/{ShopifyProduct.productType}" 237 | ) 238 | tags 239 | priceRangeV2 { 240 | maxVariantPrice { 241 | amount 242 | currencyCode 243 | } 244 | minVariantPrice { 245 | amount 246 | currencyCode 247 | } 248 | } 249 | storefrontId 250 | images { 251 | # altText 252 | id 253 | gatsbyImageData(layout: CONSTRAINED, width: 640, aspectRatio: 1) 254 | } 255 | variants { 256 | availableForSale 257 | storefrontId 258 | title 259 | price 260 | selectedOptions { 261 | name 262 | value 263 | } 264 | } 265 | options { 266 | name 267 | values 268 | id 269 | } 270 | } 271 | suggestions: allShopifyProduct( 272 | limit: 3 273 | filter: { productType: { eq: $productType }, id: { ne: $id } } 274 | ) { 275 | nodes { 276 | ...ProductCard 277 | } 278 | } 279 | } 280 | ` 281 | -------------------------------------------------------------------------------- /src/pages/search.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { graphql } from "gatsby" 3 | import slugify from "@sindresorhus/slugify" 4 | import debounce from "debounce" 5 | import { CgChevronRight, CgChevronLeft } from "react-icons/cg" 6 | import { Layout } from "../components/layout" 7 | import CrossIcon from "../icons/cross" 8 | import SortIcon from "../icons/sort" 9 | import FilterIcon from "../icons/filter" 10 | import SearchIcon from "../icons/search" 11 | import { ProductCard } from "../components/product-card" 12 | import { useProductSearch } from "../utils/hooks" 13 | import { getValuesFromQuery } from "../utils/search" 14 | import { getCurrencySymbol } from "../utils/format-price" 15 | import { Spinner } from "../components/progress" 16 | import { Filters } from "../components/filters" 17 | import { SearchProvider } from "../context/search-provider" 18 | import { 19 | visuallyHidden, 20 | main, 21 | search, 22 | searchIcon, 23 | sortSelector, 24 | results, 25 | productList as productListStyle, 26 | productListItem, 27 | pagination, 28 | paginationButton, 29 | progressStyle, 30 | resultsStyle, 31 | filterStyle, 32 | clearSearch, 33 | searchForm, 34 | sortIcon, 35 | filterButton, 36 | filterTitle, 37 | modalOpen, 38 | activeFilters, 39 | filterWrap, 40 | emptyState, 41 | } from "./search-page.module.css" 42 | import { Seo } from "../components/seo" 43 | 44 | const DEFAULT_PRODUCTS_PER_PAGE = 24 45 | 46 | export async function getServerData({ query, ...rest }) { 47 | const { getSearchResults } = require("../utils/search") 48 | const products = await getSearchResults({ 49 | query, 50 | count: DEFAULT_PRODUCTS_PER_PAGE, 51 | }) 52 | 53 | return { 54 | props: { 55 | query, 56 | products, 57 | }, 58 | } 59 | } 60 | 61 | export const query = graphql` 62 | { 63 | meta: allShopifyProduct { 64 | productTypes: distinct(field: { productType: SELECT }) 65 | tags: distinct(field: { tags: SELECT }) 66 | vendors: distinct(field: { vendor: SELECT }) 67 | } 68 | } 69 | ` 70 | 71 | function SearchPage({ 72 | serverData, 73 | data: { 74 | meta: { productTypes, vendors, tags }, 75 | }, 76 | location, 77 | }) { 78 | // These default values come from the page query string 79 | const queryParams = getValuesFromQuery(location.search || serverData.query) 80 | 81 | const [filters, setFilters] = React.useState(queryParams) 82 | // eslint-disable-next-line react-hooks/exhaustive-deps 83 | const initialFilters = React.useMemo(() => queryParams, []) 84 | const [sortKey, setSortKey] = React.useState(queryParams.sortKey) 85 | // We clear the hash when searching, we want to make sure the next page will be fetched due the #more hash. 86 | const shouldLoadNextPage = React.useRef(false) 87 | 88 | // This modal is only used on mobile 89 | const [showModal, setShowModal] = React.useState(false) 90 | 91 | const { 92 | products, 93 | isFetching, 94 | filterCount, 95 | hasNextPage, 96 | hasPreviousPage, 97 | fetchNextPage, 98 | fetchPreviousPage, 99 | } = useProductSearch( 100 | filters, 101 | { 102 | allProductTypes: productTypes, 103 | allVendors: vendors, 104 | allTags: tags, 105 | }, 106 | sortKey, 107 | false, 108 | DEFAULT_PRODUCTS_PER_PAGE, 109 | serverData.products, 110 | initialFilters 111 | ) 112 | 113 | // Scroll up when navigating 114 | React.useEffect(() => { 115 | if (!showModal) { 116 | window.scrollTo({ 117 | top: 0, 118 | left: 0, 119 | behavior: "smooth", 120 | // eslint-disable-next-line react-hooks/exhaustive-deps 121 | }) 122 | } 123 | }, [products, showModal]) 124 | 125 | // Stop page from scrolling when modal is visible 126 | React.useEffect(() => { 127 | if (showModal) { 128 | document.documentElement.style.overflow = "hidden" 129 | } else { 130 | document.documentElement.style.overflow = "" 131 | } 132 | }, [showModal]) 133 | 134 | // Automatically load the next page if "#more" is in the URL 135 | React.useEffect(() => { 136 | if (location.hash === "#more") { 137 | // save state so we can fetch it when the first page got fetched to retrieve the cursor 138 | shouldLoadNextPage.current = true 139 | } 140 | 141 | if (shouldLoadNextPage.current) { 142 | if (hasNextPage) { 143 | fetchNextPage() 144 | } 145 | 146 | shouldLoadNextPage.current = false 147 | } 148 | }, [location.hash, hasNextPage, fetchNextPage]) 149 | 150 | const currencyCode = getCurrencySymbol( 151 | serverData.products?.[0]?.node?.priceRangeV2?.minVariantPrice?.currencyCode 152 | ) 153 | 154 | return ( 155 | 156 |

Search Results

157 |
158 |
159 | 160 | 172 |
173 | 187 | 188 |
189 |
190 |
191 |
192 |

Filter

193 | 196 |
197 |
198 | 206 |
207 |
208 |
213 | {isFetching ? ( 214 |

215 | Searching 216 | {filters.term ? ` for "${filters.term}"…` : `…`} 217 |

218 | ) : ( 219 |

220 | Search results{" "} 221 | {filters.term && ( 222 | <> 223 | for "{filters.term}" 224 | 225 | )} 226 |

227 | )} 228 | {!isFetching && ( 229 |
    230 | {products.map(({ node }, index) => ( 231 |
  • 232 | 246 |
  • 247 | ))} 248 |
249 | )} 250 | {!isFetching && products.length === 0 && ( 251 |
No results found
252 | )} 253 | {hasPreviousPage || hasNextPage ? ( 254 | 260 | ) : undefined} 261 |
262 |
263 |
264 | ) 265 | } 266 | 267 | function SearchBar({ defaultTerm, setFilters }) { 268 | const [term, setTerm] = React.useState(defaultTerm) 269 | 270 | // eslint-disable-next-line react-hooks/exhaustive-deps 271 | const debouncedSetFilters = React.useCallback( 272 | debounce((value) => { 273 | setFilters((filters) => ({ ...filters, term: value })) 274 | }, 200), 275 | [setFilters] 276 | ) 277 | 278 | return ( 279 |
e.preventDefault()} className={searchForm}> 280 | 281 | { 285 | setTerm(e.target.value) 286 | debouncedSetFilters(e.target.value) 287 | }} 288 | placeholder="Search..." 289 | /> 290 | {term ? ( 291 | 302 | ) : undefined} 303 | 304 | ) 305 | } 306 | /** 307 | * Shopify only supports next & previous navigation 308 | */ 309 | function Pagination({ previousPage, hasPreviousPage, nextPage, hasNextPage }) { 310 | return ( 311 | 329 | ) 330 | } 331 | 332 | export default function SearchPageTemplate(props) { 333 | return ( 334 | 335 | 336 | 337 | ) 338 | } 339 | 340 | export const Head = () => 341 | --------------------------------------------------------------------------------