├── .prettierignore ├── components ├── common │ ├── Head │ │ ├── index.ts │ │ └── Head.tsx │ ├── Avatar │ │ ├── index.ts │ │ └── Avatar.tsx │ ├── Footer │ │ ├── index.ts │ │ ├── Footer.module.css │ │ └── Footer.tsx │ ├── Layout │ │ ├── index.ts │ │ ├── Layout.module.css │ │ └── Layout.tsx │ ├── Navbar │ │ ├── index.ts │ │ ├── Navbar.module.css │ │ └── Navbar.tsx │ ├── Searchbar │ │ ├── index.ts │ │ ├── Searchbar.module.css │ │ └── Searchbar.tsx │ ├── UserNav │ │ ├── index.ts │ │ ├── DropdownMenu.module.css │ │ ├── UserNav.module.css │ │ ├── UserNav.tsx │ │ └── DropdownMenu.tsx │ ├── FeatureBar │ │ ├── index.ts │ │ ├── FeatureBar.module.css │ │ └── FeatureBar.tsx │ ├── I18nWidget │ │ ├── index.ts │ │ ├── I18nWidget.module.css │ │ └── I18nWidget.tsx │ ├── NoSSR │ │ └── NoSSR.tsx │ └── index.ts ├── ui │ ├── Input │ │ ├── index.ts │ │ ├── Input.module.css │ │ └── Input.tsx │ ├── Link │ │ ├── index.ts │ │ └── Link.tsx │ ├── Logo │ │ ├── index.ts │ │ └── Logo.tsx │ ├── Modal │ │ ├── index.ts │ │ ├── Modal.module.css │ │ └── Modal.tsx │ ├── Text │ │ ├── index.ts │ │ ├── Text.module.css │ │ └── Text.tsx │ ├── Sidebar │ │ ├── index.ts │ │ ├── Sidebar.module.css │ │ └── Sidebar.tsx │ ├── Skeleton │ │ ├── index.ts │ │ ├── Skeleton.module.css │ │ └── Skeleton.tsx │ ├── Container │ │ ├── index.ts │ │ └── Container.tsx │ ├── LoadingDots │ │ ├── index.ts │ │ ├── LoadingDots.tsx │ │ └── LoadingDots.module.css │ ├── Button │ │ ├── index.ts │ │ ├── Button.module.css │ │ └── Button.tsx │ ├── Grid │ │ ├── index.ts │ │ ├── Grid.tsx │ │ └── Grid.module.css │ ├── README.md │ ├── index.ts │ └── context.tsx ├── product │ ├── Swatch │ │ ├── index.ts │ │ ├── Swatch.module.css │ │ └── Swatch.tsx │ ├── ProductSlider │ │ ├── index.ts │ │ ├── ProductSlider.module.css │ │ └── ProductSlider.tsx │ ├── ProductCard │ │ ├── index.ts │ │ ├── ProductCard.tsx │ │ └── ProductCard.module.css │ └── index.ts ├── cart │ ├── CartItem │ │ ├── index.ts │ │ ├── CartItem.module.css │ │ └── CartItem.tsx │ ├── CartSidebarView │ │ ├── index.ts │ │ ├── CartSidebarView.module.css │ │ └── CartSidebarView.tsx │ └── index.ts └── icons │ ├── Minus.tsx │ ├── Check.tsx │ ├── ChevronUp.tsx │ ├── Moon.tsx │ ├── Cross.tsx │ ├── DoubleChevron.tsx │ ├── Plus.tsx │ ├── Info.tsx │ ├── ArrowLeft.tsx │ ├── RightArrow.tsx │ ├── Sun.tsx │ ├── index.ts │ ├── Bag.tsx │ ├── Heart.tsx │ ├── Trash.tsx │ ├── Github.tsx │ └── Vercel.tsx ├── lib ├── click-outside │ ├── index.ts │ ├── is-in-dom.js │ ├── has-parent.js │ └── click-outside.tsx ├── shopify │ └── storefront-data-hooks │ │ ├── src │ │ ├── utils │ │ │ ├── types │ │ │ │ ├── index.ts │ │ │ │ └── isCart.ts │ │ │ ├── LocalStorage │ │ │ │ ├── index.ts │ │ │ │ ├── keys.ts │ │ │ │ └── LocalStorage.ts │ │ │ ├── index.ts │ │ │ └── product.ts │ │ ├── hooks │ │ │ ├── useCart.ts │ │ │ ├── useSetCartUnsafe.ts │ │ │ ├── useCartItems.ts │ │ │ ├── useCheckoutUrl.ts │ │ │ ├── useClientUnsafe.ts │ │ │ ├── useCartCount.ts │ │ │ ├── useRemoveItemFromCart.ts │ │ │ ├── useAddItemToCart.ts │ │ │ ├── useGetLineItem.ts │ │ │ ├── index.ts │ │ │ ├── useRemoveItemsFromCart.ts │ │ │ ├── useUpdateItemQuantity.ts │ │ │ └── useAddItemsToCart.ts │ │ ├── types.ts │ │ ├── Context.tsx │ │ ├── api │ │ │ ├── operations.ts │ │ │ └── operations-builder.ts │ │ └── CommerceProvider.tsx │ │ └── index.ts ├── range-map.ts ├── defaults.ts ├── to-pixels.ts ├── logger.ts ├── hooks │ └── useAcceptCookies.ts ├── colors.ts └── resolve-builder-content.ts ├── assets ├── components.css ├── main.css └── base.css ├── .env.template ├── global.d.ts ├── public ├── icon.png ├── jacket.png ├── favicon.ico ├── cursor-left.png ├── cursor-right.png ├── icon-144x144.png ├── icon-192x192.png ├── icon-512x512.png ├── slider-arrows.png ├── flag-es.svg ├── site.webmanifest ├── bg-products.svg ├── flag-es-co.svg ├── flag-en-us.svg ├── vercel.svg └── flag-es-ar.svg ├── next-env.d.ts ├── docs ├── ROADMAP.md └── images │ ├── private-key-flow.png │ ├── shopify-permissions.png │ ├── builder-io-organizations.png │ ├── shopify-api-key-mapping.png │ └── shopify-private-app-flow.png ├── sections ├── Hero │ ├── Hero.module.css │ ├── Hero.builder.ts │ └── Hero.tsx ├── CollectionView │ ├── CollectionView.module.css │ ├── CollectionView.builder.ts │ └── CollectionView.tsx ├── ProductView │ ├── ProductView.builder.ts │ ├── ProductView.module.css │ └── ProductView.tsx └── ProductGrid │ ├── ProductGrid.tsx │ └── ProductGrid.builder.ts ├── .env.production ├── .env.development ├── config ├── builder.ts ├── shopify.ts └── seo.json ├── postcss.config.js ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── pages ├── _app.tsx ├── collection │ └── [handle].tsx ├── product │ └── [handle].tsx ├── [[...path]].tsx ├── search.tsx └── cart.tsx ├── license.md ├── next.config.js ├── tailwind.config.js ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | public -------------------------------------------------------------------------------- /components/common/Head/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Head' 2 | -------------------------------------------------------------------------------- /components/ui/Input/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Input' 2 | -------------------------------------------------------------------------------- /components/ui/Link/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Link' 2 | -------------------------------------------------------------------------------- /components/ui/Logo/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Logo' 2 | -------------------------------------------------------------------------------- /components/ui/Modal/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Modal' 2 | -------------------------------------------------------------------------------- /components/ui/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text' 2 | -------------------------------------------------------------------------------- /components/common/Avatar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Avatar' 2 | -------------------------------------------------------------------------------- /components/common/Footer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Footer' 2 | -------------------------------------------------------------------------------- /components/common/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Layout' 2 | -------------------------------------------------------------------------------- /components/common/Navbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Navbar' 2 | -------------------------------------------------------------------------------- /components/product/Swatch/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Swatch' 2 | -------------------------------------------------------------------------------- /components/ui/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Sidebar' 2 | -------------------------------------------------------------------------------- /components/ui/Skeleton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Skeleton' 2 | -------------------------------------------------------------------------------- /lib/click-outside/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './click-outside' 2 | -------------------------------------------------------------------------------- /assets/components.css: -------------------------------------------------------------------------------- 1 | .fit { 2 | min-height: calc(100vh - 88px); 3 | } 4 | -------------------------------------------------------------------------------- /components/cart/CartItem/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './CartItem' 2 | -------------------------------------------------------------------------------- /components/common/Searchbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Searchbar' 2 | -------------------------------------------------------------------------------- /components/common/UserNav/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './UserNav' 2 | -------------------------------------------------------------------------------- /components/ui/Container/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Container' 2 | -------------------------------------------------------------------------------- /components/ui/LoadingDots/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LoadingDots' 2 | -------------------------------------------------------------------------------- /components/common/FeatureBar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './FeatureBar' 2 | -------------------------------------------------------------------------------- /components/common/I18nWidget/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './I18nWidget' 2 | -------------------------------------------------------------------------------- /components/cart/CartSidebarView/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './CartSidebarView' 2 | -------------------------------------------------------------------------------- /components/product/ProductSlider/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ProductSlider' 2 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | SHOPIFY_STOREFRONT_API_TOKEN= 2 | SHOPIFY_STORE_DOMAIN= 3 | BUILDER_PUBLIC_KEY= -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | // Declarations for modules without types 2 | declare module 'next-themes' 3 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/icon.png -------------------------------------------------------------------------------- /components/ui/Button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button' 2 | export * from './Button' 3 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/types/index.ts: -------------------------------------------------------------------------------- 1 | export { isCart } from './isCart' 2 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/jacket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/jacket.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /components/ui/Grid/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Grid' 2 | export type { GridProps } from './Grid' 3 | -------------------------------------------------------------------------------- /public/cursor-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/cursor-left.png -------------------------------------------------------------------------------- /public/cursor-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/cursor-right.png -------------------------------------------------------------------------------- /public/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/icon-144x144.png -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/icon-512x512.png -------------------------------------------------------------------------------- /public/slider-arrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/public/slider-arrows.png -------------------------------------------------------------------------------- /components/ui/Sidebar/Sidebar.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply fixed inset-0 overflow-hidden h-full z-50; 3 | } 4 | -------------------------------------------------------------------------------- /lib/click-outside/is-in-dom.js: -------------------------------------------------------------------------------- 1 | export default function isInDom(obj) { 2 | return Boolean(obj.closest('body')) 3 | } 4 | -------------------------------------------------------------------------------- /docs/ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | - Move to theme-ui or chakra-ui or any better more accessible framework than tailwind. 4 | -------------------------------------------------------------------------------- /docs/images/private-key-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/docs/images/private-key-flow.png -------------------------------------------------------------------------------- /docs/images/shopify-permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/docs/images/shopify-permissions.png -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { CommerceProvider } from './src/CommerceProvider' 2 | export * from './src/hooks' 3 | -------------------------------------------------------------------------------- /components/product/ProductCard/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ProductCard' 2 | export type { ProductCardProps } from './ProductCard' 3 | -------------------------------------------------------------------------------- /docs/images/builder-io-organizations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/docs/images/builder-io-organizations.png -------------------------------------------------------------------------------- /docs/images/shopify-api-key-mapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/docs/images/shopify-api-key-mapping.png -------------------------------------------------------------------------------- /docs/images/shopify-private-app-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/nextjs-shopify/HEAD/docs/images/shopify-private-app-flow.png -------------------------------------------------------------------------------- /components/cart/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CartSidebarView } from './CartSidebarView' 2 | export { default as CartItem } from './CartItem' 3 | -------------------------------------------------------------------------------- /components/ui/README.md: -------------------------------------------------------------------------------- 1 | # UI 2 | 3 | Building blocks to build a rich graphical interfaces. Components should be atomic and pure. Serve one purpose. 4 | -------------------------------------------------------------------------------- /sections/Hero/Hero.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply mx-auto grid grid-cols-1 py-32 gap-4; 3 | @screen md { 4 | @apply grid-cols-2; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @import './base.css'; 3 | 4 | @tailwind components; 5 | @import './components.css'; 6 | 7 | @tailwind utilities; 8 | -------------------------------------------------------------------------------- /components/common/Layout/Layout.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply h-full bg-primary mx-auto transition-colors duration-150; 3 | max-width: 2460px; 4 | } 5 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | SHOPIFY_STOREFRONT_API_TOKEN=dd0057d1e48d2d61ca8ec27b07d3c5e6 2 | SHOPIFY_STORE_DOMAIN=builder-io-demo.myshopify.com 3 | BUILDER_PUBLIC_KEY=9aa8825c0d33424ca3e9076c2cc4a328 -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | SHOPIFY_STOREFRONT_API_TOKEN=dd0057d1e48d2d61ca8ec27b07d3c5e6 2 | SHOPIFY_STORE_DOMAIN=builder-io-demo.myshopify.com 3 | BUILDER_PUBLIC_KEY=9aa8825c0d33424ca3e9076c2cc4a328 4 | -------------------------------------------------------------------------------- /lib/click-outside/has-parent.js: -------------------------------------------------------------------------------- 1 | import isInDOM from './is-in-dom' 2 | 3 | export default function hasParent(element, root) { 4 | return root && root.contains(element) && isInDOM(element) 5 | } 6 | -------------------------------------------------------------------------------- /components/common/Footer/Footer.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | & > svg { 3 | @apply transform duration-75 ease-linear; 4 | } 5 | 6 | &:hover > svg { 7 | @apply scale-110; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/range-map.ts: -------------------------------------------------------------------------------- 1 | export default function rangeMap(n: number, fn: (i: number) => any) { 2 | const arr = [] 3 | while (n > arr.length) { 4 | arr.push(fn(arr.length)) 5 | } 6 | return arr 7 | } 8 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/LocalStorage/index.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage } from './LocalStorage' 2 | import { LocalStorageKeys } from './keys' 3 | 4 | export { LocalStorage, LocalStorageKeys } 5 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage, LocalStorageKeys } from './LocalStorage' 2 | import { isCart } from './types' 3 | 4 | export { LocalStorage, LocalStorageKeys, isCart } 5 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useCart.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | export function useCart() { 5 | const { cart } = useContext(Context) 6 | return cart 7 | } 8 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/LocalStorage/keys.ts: -------------------------------------------------------------------------------- 1 | const CART = 'shopify_local_store__cart' 2 | const CHECKOUT_ID = 'shopify_local_store__checkout_id' 3 | 4 | export const LocalStorageKeys = { 5 | CART, 6 | CHECKOUT_ID, 7 | } 8 | -------------------------------------------------------------------------------- /components/product/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Swatch } from './Swatch' 2 | export { default as ProductCard } from './ProductCard' 3 | export { default as ProductSlider } from './ProductSlider' 4 | export type { ProductCardProps } from './ProductCard' 5 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useSetCartUnsafe.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | export function useSetCartUnsafe() { 5 | const { setCart } = useContext(Context) 6 | return setCart 7 | } 8 | -------------------------------------------------------------------------------- /components/ui/Input/Input.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply bg-primary py-2 px-6 w-full appearance-none transition duration-150 ease-in-out pr-10 border border-accents-3 text-accents-6; 3 | } 4 | 5 | .root:focus { 6 | @apply outline-none shadow-outline-gray; 7 | } 8 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface AttributeInput { 2 | [key: string]: string 3 | } 4 | 5 | export interface LineItemPatch { 6 | variantId: string | number 7 | quantity: number 8 | customAttributes?: AttributeInput[] 9 | } 10 | -------------------------------------------------------------------------------- /lib/defaults.ts: -------------------------------------------------------------------------------- 1 | // Fallback to CMS Data 2 | 3 | export const defatultPageProps = { 4 | header: { 5 | links: [ 6 | { 7 | link: { 8 | title: 'New Arrivals', 9 | url: '/', 10 | }, 11 | }, 12 | ], 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /components/common/FeatureBar/FeatureBar.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply text-center p-6 bg-primary text-sm flex-row justify-center items-center font-medium fixed bottom-0 w-full z-30 transition-all duration-300 ease-out; 3 | 4 | @screen md { 5 | @apply flex text-left; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /config/builder.ts: -------------------------------------------------------------------------------- 1 | if (!process.env.BUILDER_PUBLIC_KEY) { 2 | throw new Error('Missing env varialbe BUILDER_PUBLIC_KEY') 3 | } 4 | 5 | export default { 6 | apiKey: process.env.BUILDER_PUBLIC_KEY, 7 | productsModel: 'shopify-product', 8 | collectionsModel: 'shopify-collection', 9 | } 10 | -------------------------------------------------------------------------------- /components/ui/LoadingDots/LoadingDots.tsx: -------------------------------------------------------------------------------- 1 | import s from './LoadingDots.module.css' 2 | 3 | const LoadingDots: React.FC = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export default LoadingDots 14 | -------------------------------------------------------------------------------- /components/cart/CartSidebarView/CartSidebarView.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply h-full flex flex-col; 3 | } 4 | 5 | .root.empty { 6 | @apply bg-secondary text-secondary; 7 | } 8 | 9 | .root.success { 10 | @apply bg-green text-white; 11 | } 12 | 13 | .root.error { 14 | @apply bg-red text-white; 15 | } 16 | -------------------------------------------------------------------------------- /components/ui/Modal/Modal.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply fixed bg-primary text-primary flex items-center inset-0 z-50 justify-center; 3 | background-color: rgba(0, 0, 0, 0.35); 4 | } 5 | 6 | .modal { 7 | @apply bg-primary p-12 border border-accents-2; 8 | } 9 | 10 | .modal:focus { 11 | @apply outline-none; 12 | } 13 | -------------------------------------------------------------------------------- /components/ui/Link/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink, { LinkProps as NextLinkProps } from 'next/link' 2 | 3 | const Link: React.FC = ({ href, children, ...props }) => { 4 | return ( 5 | 6 | {children} 7 | 8 | ) 9 | } 10 | 11 | export default Link 12 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useCartItems.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | export function useCartItems() { 5 | const { cart } = useContext(Context) 6 | if (!cart || !Array.isArray(cart.lineItems)) { 7 | return [] 8 | } 9 | 10 | return cart.lineItems 11 | } 12 | -------------------------------------------------------------------------------- /lib/to-pixels.ts: -------------------------------------------------------------------------------- 1 | // Convert numbers or strings to pixel value 2 | // Helpful for styled-jsx when using a prop 3 | // height: ${toPixels(height)}; (supports height={20} and height="20px") 4 | 5 | const toPixels = (value: string | number) => { 6 | if (typeof value === 'number') { 7 | return `${value}px` 8 | } 9 | 10 | return value 11 | } 12 | 13 | export default toPixels 14 | -------------------------------------------------------------------------------- /sections/CollectionView/CollectionView.module.css: -------------------------------------------------------------------------------- 1 | .nameBox { 2 | & .name { 3 | @apply px-6 py-2 bg-primary text-primary font-bold; 4 | font-size: 2rem; 5 | letter-spacing: 0.4px; 6 | } 7 | } 8 | 9 | .prducts { 10 | @apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 w-full h-full; 11 | 12 | @screen lg { 13 | @apply col-span-6 py-24 justify-between; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /components/icons/Minus.tsx: -------------------------------------------------------------------------------- 1 | const Minus = ({ ...props }) => { 2 | return ( 3 | 4 | 11 | 12 | ) 13 | } 14 | 15 | export default Minus 16 | -------------------------------------------------------------------------------- /components/ui/Text/Text.module.css: -------------------------------------------------------------------------------- 1 | .body { 2 | @apply text-lg leading-7 font-medium max-w-6xl mx-auto; 3 | } 4 | 5 | .heading { 6 | @apply text-5xl mb-12; 7 | } 8 | 9 | .pageHeading { 10 | @apply pt-1 pb-4 text-2xl leading-7 font-bold tracking-wide; 11 | } 12 | 13 | .sectionHeading { 14 | @apply pt-1 pb-2 font-semibold leading-7 tracking-wider uppercase border-b border-accents-2 mb-3; 15 | } 16 | -------------------------------------------------------------------------------- /lib/logger.ts: -------------------------------------------------------------------------------- 1 | import bunyan from 'bunyan' 2 | import PrettyStream from 'bunyan-prettystream' 3 | 4 | const prettyStdOut = new PrettyStream() 5 | 6 | const log = bunyan.createLogger({ 7 | name: 'Next.js - Commerce', 8 | level: 'debug', 9 | streams: [ 10 | { 11 | level: 'debug', 12 | type: 'raw', 13 | stream: prettyStdOut, 14 | }, 15 | ], 16 | }) 17 | 18 | export default log 19 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useCheckoutUrl.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | export function useCheckoutUrl(): string | null { 5 | const { cart } = useContext(Context) 6 | if (cart == null) { 7 | return null 8 | } 9 | 10 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 11 | // @ts-ignore 12 | return cart.webUrl 13 | } 14 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'tailwindcss', 4 | 'postcss-nesting', 5 | 'postcss-flexbugs-fixes', 6 | [ 7 | 'postcss-preset-env', 8 | { 9 | autoprefixer: { 10 | flexbox: 'no-2009', 11 | }, 12 | stage: 2, 13 | features: { 14 | 'custom-properties': false, 15 | }, 16 | }, 17 | ], 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useClientUnsafe.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | export function useClientUnsafe() { 5 | const { client } = useContext(Context) 6 | if (process.env.NODE_ENV === 'development') { 7 | console.warn( 8 | 'Using client directly will hit shopify API and counts towards your storefront rate limit' 9 | ) 10 | } 11 | return client 12 | } 13 | -------------------------------------------------------------------------------- /components/common/NoSSR/NoSSR.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | const NoSSR: React.FC<{ skeleton?: React.ReactNode }> = ({ 4 | children, 5 | skeleton, 6 | }) => { 7 | const [render, setRender] = useState(false) 8 | useEffect(() => setRender(true), []) 9 | if (render) { 10 | return <>{children} 11 | } 12 | if (skeleton) { 13 | return <>{skeleton} 14 | } 15 | return null 16 | } 17 | export default NoSSR 18 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useCartCount.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | export function useCartCount() { 5 | const { cart } = useContext(Context) 6 | if (cart == null || cart.lineItems.length < 1) { 7 | return 0 8 | } 9 | 10 | const count = cart.lineItems.reduce((totalCount, lineItem) => { 11 | return totalCount + lineItem.quantity 12 | }, 0) 13 | 14 | return count 15 | } 16 | -------------------------------------------------------------------------------- /components/common/Searchbar/Searchbar.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | @apply bg-transparent px-3 py-2 appearance-none w-full transition duration-150 ease-in-out pr-10; 3 | 4 | @screen sm { 5 | min-width: 300px; 6 | } 7 | } 8 | 9 | .input:focus { 10 | @apply outline-none shadow-outline-2; 11 | } 12 | 13 | .iconContainer { 14 | @apply absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none; 15 | } 16 | 17 | .icon { 18 | @apply h-5 w-5; 19 | } 20 | -------------------------------------------------------------------------------- /config/shopify.ts: -------------------------------------------------------------------------------- 1 | if (!process.env.SHOPIFY_STORE_DOMAIN) { 2 | throw new Error('Missing required environment variable SHOPIFY_STORE_DOMAIN') 3 | } 4 | if (!process.env.SHOPIFY_STOREFRONT_API_TOKEN) { 5 | throw new Error( 6 | 'Missing required environment variable SHOPIFY_STOREFRONT_API_TOKEN' 7 | ) 8 | } 9 | 10 | export default { 11 | domain: process.env.SHOPIFY_STORE_DOMAIN, 12 | storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_API_TOKEN, 13 | } 14 | -------------------------------------------------------------------------------- /components/icons/Check.tsx: -------------------------------------------------------------------------------- 1 | const Check = ({ ...props }) => { 2 | return ( 3 | 11 | 17 | 18 | ) 19 | } 20 | 21 | export default Check 22 | -------------------------------------------------------------------------------- /config/seo.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "ACME Storefront | Builder.io + Shopify + Next.js", 3 | "titleTemplate": "%s - ACME Storefront", 4 | "description": "A starter kit demo store for using headless shopify with Builder.io -> https://github.com/BuilderIO/nextjs-shopify", 5 | "openGraph": { 6 | "type": "website", 7 | "locale": "en_IE", 8 | "url": " https://github.com/BuilderIO/nextjs-shopify", 9 | "site_name": "Builder.io + Shopify + Next.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /components/common/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Avatar } from './Avatar' 2 | export { default as FeatureBar } from './FeatureBar' 3 | export { default as Footer } from './Footer' 4 | export { default as Layout } from './Layout' 5 | export { default as Navbar } from './Navbar' 6 | export { default as Searchbar } from './Searchbar' 7 | export { default as UserNav } from './UserNav' 8 | export { default as Head } from './Head' 9 | export { default as I18nWidget } from './I18nWidget' 10 | -------------------------------------------------------------------------------- /public/flag-es.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/cart/CartItem/CartItem.module.css: -------------------------------------------------------------------------------- 1 | .quantity { 2 | appearance: textfield; 3 | @apply w-8 border-accents-2 border mx-3 rounded text-center text-sm text-black; 4 | } 5 | 6 | .quantity::-webkit-outer-spin-button, 7 | .quantity::-webkit-inner-spin-button { 8 | @apply appearance-none m-0; 9 | } 10 | 11 | .productImage { 12 | position: absolute; 13 | transform: scale(1.9); 14 | width: 100%; 15 | height: 100%; 16 | left: 30% !important; 17 | top: 30% !important; 18 | } 19 | -------------------------------------------------------------------------------- /components/icons/ChevronUp.tsx: -------------------------------------------------------------------------------- 1 | const ChevronUp = ({ ...props }) => { 2 | return ( 3 | 15 | 16 | 17 | ) 18 | } 19 | 20 | export default ChevronUp 21 | -------------------------------------------------------------------------------- /components/icons/Moon.tsx: -------------------------------------------------------------------------------- 1 | const Moon = ({ ...props }) => { 2 | return ( 3 | 15 | 16 | 17 | ) 18 | } 19 | 20 | export default Moon 21 | -------------------------------------------------------------------------------- /components/icons/Cross.tsx: -------------------------------------------------------------------------------- 1 | const Cross = ({ ...props }) => { 2 | return ( 3 | 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | export default Cross 22 | -------------------------------------------------------------------------------- /components/common/Head/Head.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import NextHead from 'next/head' 3 | import { DefaultSeo } from 'next-seo' 4 | import config from '@config/seo.json' 5 | 6 | const Head: FC = () => { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export default Head 19 | -------------------------------------------------------------------------------- /components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Logo } from './Logo' 2 | export { default as Grid } from './Grid' 3 | export { default as Button } from './Button' 4 | export { default as Sidebar } from './Sidebar' 5 | export { default as Container } from './Container' 6 | export { default as LoadingDots } from './LoadingDots' 7 | export { default as Skeleton } from './Skeleton' 8 | export { default as Modal } from './Modal' 9 | export { default as Text } from './Text' 10 | export { default as Input } from './Input' 11 | export type { GridProps } from './Grid' 12 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useRemoveItemFromCart.ts: -------------------------------------------------------------------------------- 1 | import { useRemoveItemsFromCart } from './useRemoveItemsFromCart' 2 | 3 | export function useRemoveItemFromCart() { 4 | const removeItemsFromCart = useRemoveItemsFromCart() 5 | 6 | async function removeItemFromCart(variantId: number | string) { 7 | if (variantId === '' || variantId == null) { 8 | throw new Error('VariantId must not be blank or null') 9 | } 10 | 11 | return removeItemsFromCart([String(variantId)]) 12 | } 13 | 14 | return removeItemFromCart 15 | } 16 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/types/isCart.ts: -------------------------------------------------------------------------------- 1 | import ShopifyBuy from 'shopify-buy' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export function isCart(potentialCart: any): potentialCart is ShopifyBuy.Cart { 5 | return ( 6 | potentialCart != null && 7 | potentialCart.id != null && 8 | potentialCart.webUrl != null && 9 | potentialCart.lineItems != null && 10 | potentialCart.type != null && 11 | potentialCart.type.name === 'Checkout' && 12 | potentialCart.type.kind === 'OBJECT' 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useAddItemToCart.ts: -------------------------------------------------------------------------------- 1 | import { useAddItemsToCart } from './useAddItemsToCart' 2 | import { AttributeInput } from '../types' 3 | 4 | export function useAddItemToCart() { 5 | const addItemsToCart = useAddItemsToCart() 6 | 7 | async function addItemToCart( 8 | variantId: number | string, 9 | quantity: number, 10 | customAttributes?: AttributeInput[] 11 | ) { 12 | const item = [{ variantId, quantity, customAttributes }] 13 | 14 | return addItemsToCart(item) 15 | } 16 | 17 | return addItemToCart 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # dev 37 | framework 38 | -------------------------------------------------------------------------------- /components/icons/DoubleChevron.tsx: -------------------------------------------------------------------------------- 1 | const DoubleChevron = ({ ...props }) => { 2 | return ( 3 | 11 | 18 | 19 | ) 20 | } 21 | 22 | export default DoubleChevron 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.js] 16 | quote_type = single 17 | 18 | [{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}] 19 | curly_bracket_next_line = false 20 | spaces_around_operators = true 21 | spaces_around_brackets = outside 22 | # close enough to 1TB 23 | indent_brace_style = K&R 24 | -------------------------------------------------------------------------------- /components/icons/Plus.tsx: -------------------------------------------------------------------------------- 1 | const Plus = ({ ...props }) => { 2 | return ( 3 | 4 | 11 | 18 | 19 | ) 20 | } 21 | 22 | export default Plus 23 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Next.js Commerce", 3 | "short_name": "Next.js Commerce", 4 | "description": "Next.js Commerce -> https://www.nextjs.org/commerce", 5 | "display": "standalone", 6 | "start_url": "/", 7 | "theme_color": "#fff", 8 | "background_color": "#000000", 9 | "orientation": "portrait", 10 | "icons": [ 11 | { 12 | "src": "/icon-192x192.png", 13 | "type": "image/png", 14 | "sizes": "192x192" 15 | }, 16 | { 17 | "src": "/icon-512x512.png", 18 | "type": "image/png", 19 | "sizes": "512x512" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /components/common/Navbar/Navbar.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply sticky top-0 bg-primary z-40 transition-all duration-150; 3 | } 4 | 5 | .link { 6 | @apply inline-flex items-center text-primary leading-6 font-medium transition ease-in-out duration-75 cursor-pointer text-accents-6; 7 | } 8 | 9 | .link:hover { 10 | @apply text-accents-9; 11 | } 12 | 13 | .link:focus { 14 | @apply outline-none text-accents-8; 15 | } 16 | 17 | .logo { 18 | @apply cursor-pointer rounded-full border transform duration-100 ease-in-out; 19 | 20 | &:hover { 21 | @apply shadow-md; 22 | transform: scale(1.05); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /components/icons/Info.tsx: -------------------------------------------------------------------------------- 1 | const Info = ({ ...props }) => { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default Info 23 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/Context.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ShopifyBuy from 'shopify-buy' 3 | 4 | interface ContextShape { 5 | client: ShopifyBuy.Client | null 6 | cart: ShopifyBuy.Cart | null 7 | setCart: React.Dispatch> 8 | domain: string 9 | storefrontAccessToken: string 10 | } 11 | 12 | export const Context = React.createContext({ 13 | client: null, 14 | cart: null, 15 | domain: '', 16 | storefrontAccessToken: '', 17 | setCart: () => { 18 | throw Error('You forgot to wrap this in a Provider object') 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /components/common/UserNav/DropdownMenu.module.css: -------------------------------------------------------------------------------- 1 | .dropdownMenu { 2 | @apply fixed right-0 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full; 3 | 4 | @screen lg { 5 | @apply absolute top-10 border border-accents-1 shadow-lg w-56 h-auto; 6 | } 7 | } 8 | 9 | .link { 10 | @apply text-primary flex cursor-pointer px-6 py-3 flex transition ease-in-out duration-150 leading-6 font-medium items-center; 11 | text-transform: capitalize; 12 | } 13 | 14 | .link:hover { 15 | @apply bg-accents-1; 16 | } 17 | 18 | .link.active { 19 | @apply font-bold bg-accents-2; 20 | } 21 | 22 | .off { 23 | @apply hidden; 24 | } 25 | -------------------------------------------------------------------------------- /components/icons/ArrowLeft.tsx: -------------------------------------------------------------------------------- 1 | const ArrowLeft = ({ ...props }) => { 2 | return ( 3 | 11 | 17 | 23 | 24 | ) 25 | } 26 | 27 | export default ArrowLeft 28 | -------------------------------------------------------------------------------- /components/ui/Container/Container.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React, { FC } from 'react' 3 | 4 | interface Props { 5 | className?: string 6 | children?: any 7 | el?: HTMLElement 8 | clean?: boolean 9 | } 10 | 11 | const Container: FC = ({ children, className, el = 'div', clean }) => { 12 | const rootClassName = cn(className, { 13 | 'mx-auto max-w-8xl px-6': !clean, 14 | }) 15 | 16 | let Component: React.ComponentType< 17 | React.HTMLAttributes 18 | > = el as any 19 | 20 | return {children} 21 | } 22 | 23 | export default Container 24 | -------------------------------------------------------------------------------- /lib/hooks/useAcceptCookies.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | import { useEffect, useState } from 'react' 3 | 4 | const COOKIE_NAME = 'accept_cookies' 5 | 6 | export const useAcceptCookies = () => { 7 | const [acceptedCookies, setAcceptedCookies] = useState(true) 8 | 9 | useEffect(() => { 10 | if (!Cookies.get(COOKIE_NAME)) { 11 | setAcceptedCookies(false) 12 | } 13 | }, []) 14 | 15 | const acceptCookies = () => { 16 | setAcceptedCookies(true) 17 | Cookies.set(COOKIE_NAME, 'accepted', { expires: 365 }) 18 | } 19 | 20 | return { 21 | acceptedCookies, 22 | onAcceptCookies: acceptCookies, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /components/ui/LoadingDots/LoadingDots.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply inline-flex text-center items-center leading-7; 3 | 4 | & span { 5 | @apply bg-accents-6 rounded-full h-2 w-2; 6 | animation-name: blink; 7 | animation-duration: 1.4s; 8 | animation-iteration-count: infinite; 9 | animation-fill-mode: both; 10 | margin: 0 2px; 11 | 12 | &:nth-of-type(2) { 13 | animation-delay: 0.2s; 14 | } 15 | 16 | &:nth-of-type(3) { 17 | animation-delay: 0.4s; 18 | } 19 | } 20 | } 21 | 22 | @keyframes blink { 23 | 0% { 24 | opacity: 0.2; 25 | } 26 | 20% { 27 | opacity: 1; 28 | } 29 | 100% { 30 | opacity: 0.2; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /components/ui/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | const Logo = ({ className = '', ...props }) => ( 2 | 11 | 12 | 18 | 19 | ) 20 | 21 | export default Logo 22 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useGetLineItem.ts: -------------------------------------------------------------------------------- 1 | import { useCartItems } from './useCartItems' 2 | 3 | export function useGetLineItem() { 4 | const cartItems = useCartItems() 5 | 6 | function getLineItem(variantId: string | number): ShopifyBuy.LineItem | null { 7 | if (cartItems.length < 1) { 8 | return null 9 | } 10 | 11 | const item = cartItems.find((cartItem) => { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 13 | // @ts-ignore 14 | return cartItem.variant.id === variantId 15 | }) 16 | 17 | if (item == null) { 18 | return null 19 | } 20 | 21 | return item 22 | } 23 | 24 | return getLineItem 25 | } 26 | -------------------------------------------------------------------------------- /sections/ProductView/ProductView.builder.ts: -------------------------------------------------------------------------------- 1 | import { Builder, builder } from '@builder.io/react' 2 | import dynamic from 'next/dynamic' 3 | 4 | const LazyProductView = dynamic( 5 | () => import(`sections/ProductView/ProductView`), 6 | { ssr: true } 7 | ) 8 | 9 | Builder.registerComponent(LazyProductView, { 10 | name: 'ProductView', 11 | description: 12 | 'Dynamic product details, included in SSR, should only be used in product pages', 13 | defaults: { 14 | bindings: { 15 | 'component.options.product': 'state.product', 16 | 'component.options.title': 'state.product.title', 17 | 'component.options.description': 'state.product.descriptionHtml', 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /components/icons/RightArrow.tsx: -------------------------------------------------------------------------------- 1 | const RightArrow = ({ ...props }) => { 2 | return ( 3 | 11 | 18 | 25 | 26 | ) 27 | } 28 | 29 | export default RightArrow 30 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useClientUnsafe } from './useClientUnsafe' 2 | export { useSetCartUnsafe } from './useSetCartUnsafe' 3 | export { useCart } from './useCart' 4 | export { useCartCount } from './useCartCount' 5 | export { useAddItemToCart } from './useAddItemToCart' 6 | export { useAddItemsToCart } from './useAddItemsToCart' 7 | export { useRemoveItemFromCart } from './useRemoveItemFromCart' 8 | export { useRemoveItemsFromCart } from './useRemoveItemsFromCart' 9 | export { useCartItems } from './useCartItems' 10 | export { useCheckoutUrl } from './useCheckoutUrl' 11 | export { useGetLineItem } from './useGetLineItem' 12 | export { useUpdateItemQuantity } from './useUpdateItemQuantity' 13 | -------------------------------------------------------------------------------- /components/product/Swatch/Swatch.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply h-12 w-12 bg-primary text-primary rounded-full mr-3 inline-flex 3 | items-center justify-center cursor-pointer transition duration-150 ease-in-out 4 | p-0 shadow-none border-gray-200 border box-border; 5 | 6 | & > span { 7 | @apply absolute; 8 | } 9 | 10 | &:hover { 11 | @apply transform scale-110 bg-hover; 12 | } 13 | } 14 | 15 | .color { 16 | @apply text-black transition duration-150 ease-in-out; 17 | 18 | &:hover { 19 | @apply text-black; 20 | } 21 | 22 | &.dark, 23 | &.dark:hover { 24 | color: white !important; 25 | } 26 | } 27 | 28 | .active { 29 | &.size { 30 | @apply border-accents-9 border-2; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /components/icons/Sun.tsx: -------------------------------------------------------------------------------- 1 | const Sun = ({ ...props }) => { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export default Sun 29 | -------------------------------------------------------------------------------- /components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bag } from './Bag' 2 | export { default as Heart } from './Heart' 3 | export { default as Trash } from './Trash' 4 | export { default as Cross } from './Cross' 5 | export { default as ArrowLeft } from './ArrowLeft' 6 | export { default as Plus } from './Plus' 7 | export { default as Minus } from './Minus' 8 | export { default as Check } from './Check' 9 | export { default as Sun } from './Sun' 10 | export { default as Moon } from './Moon' 11 | export { default as Github } from './Github' 12 | export { default as DoubleChevron } from './DoubleChevron' 13 | export { default as RightArrow } from './RightArrow' 14 | export { default as Info } from './Info' 15 | export { default as ChevronUp } from './ChevronUp' 16 | export { default as Vercel } from './Vercel' 17 | -------------------------------------------------------------------------------- /public/bg-products.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "esnext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "paths": { 18 | "@lib/*": ["lib/*"], 19 | "@assets/*": ["assets/*"], 20 | "@config/*": ["config/*"], 21 | "@components/*": ["components/*"], 22 | "@utils/*": ["utils/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /public/flag-es-co.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /components/common/UserNav/UserNav.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply relative; 3 | } 4 | 5 | .list { 6 | @apply flex flex-row items-center justify-items-end h-full; 7 | } 8 | 9 | .item { 10 | @apply mr-6 cursor-pointer relative transition ease-in-out duration-100 flex items-center outline-none text-primary; 11 | 12 | &:hover { 13 | @apply text-accents-6 transition scale-110 duration-100; 14 | } 15 | 16 | &:last-child { 17 | @apply mr-0; 18 | } 19 | 20 | &:focus, 21 | &:active { 22 | @apply outline-none; 23 | } 24 | } 25 | 26 | .bagCount { 27 | @apply border border-accents-1 bg-secondary text-secondary h-4 w-4 absolute rounded-full right-3 top-3 flex items-center justify-center font-bold text-xs; 28 | } 29 | 30 | .avatarButton { 31 | @apply inline-flex justify-center rounded-full; 32 | } 33 | 34 | .avatarButton:focus { 35 | @apply outline-none; 36 | } 37 | -------------------------------------------------------------------------------- /components/common/I18nWidget/I18nWidget.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply relative; 3 | } 4 | 5 | .button { 6 | @apply h-10 px-2 rounded-md border border-accents-2 flex items-center justify-center; 7 | } 8 | 9 | .button:hover { 10 | @apply border-accents-4 shadow-sm; 11 | } 12 | 13 | .button:focus { 14 | @apply outline-none; 15 | } 16 | 17 | .dropdownMenu { 18 | @apply fixed right-0 top-12 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full; 19 | 20 | @screen lg { 21 | @apply absolute border border-accents-1 shadow-lg w-56 h-auto; 22 | } 23 | } 24 | 25 | .closeButton { 26 | @screen md { 27 | @apply hidden; 28 | } 29 | } 30 | 31 | .item { 32 | @apply flex cursor-pointer px-6 py-3 flex transition ease-in-out duration-150 text-primary leading-6 font-medium items-center; 33 | text-transform: capitalize; 34 | } 35 | 36 | .item:hover { 37 | @apply bg-accents-1; 38 | } 39 | 40 | .icon { 41 | transform: rotate(180deg); 42 | } 43 | -------------------------------------------------------------------------------- /components/ui/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply bg-secondary text-accents-1 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out duration-150 shadow-sm font-semibold text-center justify-center uppercase py-4 border border-transparent items-center; 3 | } 4 | 5 | .root:hover { 6 | @apply bg-accents-0 text-primary border border-secondary; 7 | } 8 | 9 | .root:focus { 10 | @apply shadow-outline outline-none; 11 | } 12 | 13 | .root[data-active] { 14 | @apply bg-gray-600; 15 | } 16 | 17 | .loading { 18 | @apply bg-accents-1 text-accents-3 border-accents-2 cursor-not-allowed; 19 | } 20 | 21 | .slim { 22 | @apply py-2 transform-none normal-case; 23 | } 24 | 25 | .disabled, 26 | .disabled:hover { 27 | @apply text-accents-4 border-accents-2 bg-accents-1 cursor-not-allowed; 28 | filter: grayscale(1); 29 | -webkit-transform: translateZ(0); 30 | -webkit-perspective: 1000; 31 | -webkit-backface-visibility: hidden; 32 | } 33 | -------------------------------------------------------------------------------- /components/common/FeatureBar/FeatureBar.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import s from './FeatureBar.module.css' 3 | 4 | interface FeatureBarProps { 5 | className?: string 6 | title: string 7 | description?: string 8 | hide?: boolean 9 | action?: React.ReactNode 10 | } 11 | 12 | const FeatureBar: React.FC = ({ 13 | title, 14 | description, 15 | className, 16 | action, 17 | hide, 18 | }) => { 19 | const rootClassName = cn( 20 | s.root, 21 | { 22 | transform: true, 23 | 'translate-y-0 opacity-100': !hide, 24 | 'translate-y-full opacity-0': hide, 25 | }, 26 | className 27 | ) 28 | return ( 29 |
30 | {title} 31 | 32 | {description} 33 | 34 | {action && action} 35 |
36 | ) 37 | } 38 | 39 | export default FeatureBar 40 | -------------------------------------------------------------------------------- /sections/Hero/Hero.builder.ts: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | import { Builder } from '@builder.io/react' 3 | 4 | const LazyHero = dynamic(async () => { 5 | return (await import('./Hero')).default 6 | }) 7 | 8 | Builder.registerComponent(LazyHero, { 9 | name: 'Hero', 10 | inputs: [ 11 | { 12 | name: 'headline', 13 | type: 'string', 14 | defaultValue: 'The simplest way to build Ecommerce sites with Next.js', 15 | }, 16 | { 17 | name: 'description', 18 | type: 'string', 19 | defaultValue: 20 | 'Stop waiting on development release cycles. Start dragging and dropping to build and optimize digital experiences for your website, app, or ecommerce store.', 21 | }, 22 | { 23 | name: 'ctaLink', 24 | type: 'string', 25 | defaultValue: '/shop', 26 | }, 27 | { 28 | name: 'ctaText', 29 | type: 'string', 30 | defaultValue: 'read more', 31 | }, 32 | ], 33 | }) 34 | -------------------------------------------------------------------------------- /components/ui/Grid/Grid.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import { FC, ReactNode, Component } from 'react' 3 | import s from './Grid.module.css' 4 | 5 | export interface GridProps { 6 | className?: string 7 | children?: ReactNode[] | Component[] | any[] 8 | layout?: 'A' | 'B' | 'C' | 'D' | 'normal' 9 | variant?: 'default' | 'filled' 10 | } 11 | 12 | const Grid: FC = ({ 13 | className, 14 | layout = 'A', 15 | children, 16 | variant = 'default', 17 | }) => { 18 | const rootClassName = cn( 19 | s.root, 20 | { 21 | [s.layoutA]: layout === 'A', 22 | [s.layoutB]: layout === 'B', 23 | [s.layoutC]: layout === 'C', 24 | [s.layoutD]: layout === 'D', 25 | [s.layoutNormal]: layout === 'normal', 26 | [s.default]: variant === 'default', 27 | [s.filled]: variant === 'filled', 28 | }, 29 | className 30 | ) 31 | return
{children}
32 | } 33 | 34 | export default Grid 35 | -------------------------------------------------------------------------------- /components/ui/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import s from './Input.module.css' 3 | import React, { InputHTMLAttributes } from 'react' 4 | 5 | export interface Props extends InputHTMLAttributes { 6 | className?: string 7 | onChange?: (...args: any[]) => any 8 | } 9 | 10 | const Input: React.FC = (props) => { 11 | const { className, children, onChange, ...rest } = props 12 | 13 | const rootClassName = cn(s.root, {}, className) 14 | 15 | const handleOnChange = (e: any) => { 16 | if (onChange) { 17 | onChange(e.target.value) 18 | } 19 | return null 20 | } 21 | 22 | return ( 23 | 34 | ) 35 | } 36 | 37 | export default Input 38 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/api/operations.ts: -------------------------------------------------------------------------------- 1 | import { buildClient } from 'shopify-buy' 2 | 3 | export function getAllProducts(config: ShopifyBuy.Config, limit?: number) { 4 | const client = buildClient(config) 5 | return client.product.fetchAll(limit) 6 | } 7 | 8 | export async function getAllProductPaths( 9 | config: ShopifyBuy.Config, 10 | limit?: number 11 | ): Promise { 12 | const client = buildClient(config) 13 | // interface need update 14 | const products: any[] = await client.product.fetchAll(limit) 15 | return products.map((val) => val.handle) 16 | } 17 | 18 | export function getProduct( 19 | config: ShopifyBuy.Config, 20 | options: { id?: string; handle?: string } 21 | ) { 22 | const client = buildClient(config) 23 | if (options.handle) { 24 | return client.product.fetchByHandle(options.handle) 25 | } 26 | if (!options.id) { 27 | throw new Error('A product ID or handle is required') 28 | } 29 | return client.product.fetch(options.id) 30 | } 31 | -------------------------------------------------------------------------------- /components/common/Avatar/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import { FC, useState, useMemo, useRef, useEffect } from 'react' 3 | import { getRandomPairOfColors } from '@lib/colors' 4 | 5 | interface Props { 6 | className?: string 7 | children?: any 8 | } 9 | 10 | const Avatar: FC = ({}) => { 11 | const [bg] = useState(useMemo(() => getRandomPairOfColors, [])) 12 | let ref = useRef() as React.MutableRefObject 13 | 14 | useEffect(() => { 15 | if (ref && ref.current) { 16 | ref.current.style.backgroundImage = `linear-gradient(140deg, ${bg[0]}, ${bg[1]} 100%)` 17 | } 18 | }, [bg]) 19 | 20 | return ( 21 |
25 | {/* Add an image - We're generating a gradient as placeholder */} 26 |
27 | ) 28 | } 29 | 30 | export default Avatar 31 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@assets/main.css' 2 | import 'keen-slider/keen-slider.min.css' 3 | 4 | import { FC } from 'react' 5 | import type { AppProps } from 'next/app' 6 | 7 | import { ManagedUIContext } from '@components/ui/context' 8 | import { Head } from '@components/common' 9 | import { builder } from '@builder.io/react' 10 | import builderConfig from '@config/builder' 11 | builder.init(builderConfig.apiKey) 12 | 13 | import '../sections/ProductGrid/ProductGrid.builder' 14 | import '../sections/CollectionView/CollectionView.builder' 15 | import '../sections/Hero/Hero.builder' 16 | 17 | const Noop: FC = ({ children }) => <>{children} 18 | 19 | export default function MyApp({ Component, pageProps }: AppProps) { 20 | const Layout = (Component as any).Layout || Noop 21 | 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/ui/Skeleton/Skeleton.module.css: -------------------------------------------------------------------------------- 1 | .skeleton { 2 | @apply block; 3 | background-image: linear-gradient( 4 | 270deg, 5 | var(--accents-1), 6 | var(--accents-2), 7 | var(--accents-2), 8 | var(--accents-1) 9 | ); 10 | background-size: 400% 100%; 11 | animation: loading 8s ease-in-out infinite; 12 | } 13 | 14 | .wrapper { 15 | @apply block relative; 16 | 17 | &:not(.show)::before { 18 | content: none; 19 | } 20 | 21 | &::before { 22 | content: ''; 23 | position: absolute; 24 | top: 0; 25 | left: 0; 26 | right: 0; 27 | bottom: 0; 28 | z-index: 100; 29 | background-image: linear-gradient( 30 | 270deg, 31 | var(--accents-1), 32 | var(--accents-2), 33 | var(--accents-2), 34 | var(--accents-1) 35 | ); 36 | background-size: 400% 100%; 37 | animation: loading 8s ease-in-out infinite; 38 | } 39 | } 40 | 41 | @keyframes loading { 42 | 0% { 43 | background-position: 200% 0; 44 | } 45 | 100% { 46 | background-position: -200% 0; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /components/icons/Bag.tsx: -------------------------------------------------------------------------------- 1 | const Bag = ({ ...props }) => { 2 | return ( 3 | 11 | 17 | 23 | 29 | 30 | ) 31 | } 32 | 33 | export default Bag 34 | -------------------------------------------------------------------------------- /components/common/UserNav/UserNav.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import cn from 'classnames' 3 | import { useCartCount } from '@lib/shopify/storefront-data-hooks' 4 | import { Bag } from '@components/icons' 5 | import { useUI } from '@components/ui/context' 6 | import s from './UserNav.module.css' 7 | import NoSSR from '../NoSSR/NoSSR' 8 | 9 | interface Props { 10 | className?: string 11 | } 12 | 13 | const UserNav: FC = ({ className, children, ...props }) => { 14 | const { toggleSidebar } = useUI() 15 | const itemsCount = useCartCount() 16 | 17 | return ( 18 | 32 | ) 33 | } 34 | 35 | export default UserNav 36 | -------------------------------------------------------------------------------- /components/icons/Heart.tsx: -------------------------------------------------------------------------------- 1 | const Heart = ({ ...props }) => { 2 | return ( 3 | 11 | 18 | 19 | ) 20 | } 21 | 22 | export default Heart 23 | -------------------------------------------------------------------------------- /lib/click-outside/click-outside.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, MouseEvent } from 'react' 2 | import hasParent from './has-parent' 3 | 4 | interface ClickOutsideProps { 5 | active: boolean 6 | onClick: (e?: MouseEvent) => void 7 | children: any 8 | } 9 | 10 | const ClickOutside = ({ 11 | active = true, 12 | onClick, 13 | children, 14 | }: ClickOutsideProps) => { 15 | const innerRef = useRef() 16 | 17 | const handleClick = (event: any) => { 18 | if (!hasParent(event.target, innerRef?.current)) { 19 | if (typeof onClick === 'function') { 20 | onClick(event) 21 | } 22 | } 23 | } 24 | 25 | useEffect(() => { 26 | if (active) { 27 | document.addEventListener('mousedown', handleClick) 28 | document.addEventListener('touchstart', handleClick) 29 | } 30 | 31 | return () => { 32 | if (active) { 33 | document.removeEventListener('mousedown', handleClick) 34 | document.removeEventListener('touchstart', handleClick) 35 | } 36 | } 37 | }) 38 | 39 | return React.cloneElement(children, { ref: innerRef }) 40 | } 41 | 42 | export default ClickOutside 43 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Vercel, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useRemoveItemsFromCart.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | import { useGetLineItem } from './useGetLineItem' 4 | 5 | export function useRemoveItemsFromCart() { 6 | const { client, cart, setCart } = useContext(Context) 7 | const getLineItem = useGetLineItem() 8 | 9 | async function removeItemsFromCart(variantIds: string[]) { 10 | if (cart == null || client == null) { 11 | throw new Error('Called removeItemsFromCart too soon') 12 | } 13 | 14 | if (variantIds.length < 1) { 15 | throw new Error('Must include at least one item to remove') 16 | } 17 | 18 | const lineItemIds = variantIds.map((variantId) => { 19 | const lineItem = getLineItem(variantId) 20 | if (lineItem === null) { 21 | throw new Error( 22 | `Could not find line item in cart with variant id: ${variantId}` 23 | ) 24 | } 25 | return String(lineItem.id) 26 | }) 27 | 28 | const newCart = await client.checkout.removeLineItems(cart.id, lineItemIds) 29 | setCart(newCart) 30 | } 31 | 32 | return removeItemsFromCart 33 | } 34 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useUpdateItemQuantity.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | 4 | import { useGetLineItem } from './useGetLineItem' 5 | 6 | export function useUpdateItemQuantity() { 7 | const { client, cart, setCart } = useContext(Context) 8 | const getLineItem = useGetLineItem() 9 | 10 | async function updateItemQuantity( 11 | variantId: string | number, 12 | quantity: number 13 | ) { 14 | if (variantId == null) { 15 | throw new Error('Must provide a variant id') 16 | } 17 | 18 | if (quantity == null || Number(quantity) < 0) { 19 | throw new Error('Quantity must be greater than 0') 20 | } 21 | 22 | const lineItem = getLineItem(variantId) 23 | if (lineItem == null) { 24 | throw new Error(`Item with variantId ${variantId} not in cart`) 25 | } 26 | 27 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 28 | // @ts-ignore 29 | const newCart = await client.checkout.updateLineItems(cart.id, [ 30 | { id: lineItem.id, quantity }, 31 | ]) 32 | setCart(newCart) 33 | } 34 | 35 | return updateItemQuantity 36 | } 37 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/LocalStorage/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | import ShopifyBuy from 'shopify-buy' 2 | import { LocalStorageKeys } from './keys' 3 | import { isCart } from '../../utils' 4 | 5 | function set(key: string, value: string) { 6 | const isBrowser = typeof window !== 'undefined' 7 | if (isBrowser) { 8 | window.localStorage.setItem(key, value) 9 | } 10 | } 11 | 12 | function get(key: string) { 13 | const isBrowser = typeof window !== 'undefined' 14 | if (!isBrowser) { 15 | return null 16 | } 17 | 18 | try { 19 | const item = window.localStorage.getItem(key) 20 | return item 21 | } catch { 22 | return null 23 | } 24 | } 25 | 26 | function getInitialCart(): ShopifyBuy.Cart | null { 27 | const existingCartString = get(LocalStorageKeys.CART) 28 | if (existingCartString == null) { 29 | return null 30 | } 31 | 32 | try { 33 | const existingCart = JSON.parse(existingCartString) 34 | if (!isCart(existingCart)) { 35 | return null 36 | } 37 | 38 | return existingCart as ShopifyBuy.Cart 39 | } catch { 40 | return null 41 | } 42 | } 43 | 44 | export const LocalStorage = { 45 | get, 46 | set, 47 | getInitialCart, 48 | } 49 | -------------------------------------------------------------------------------- /components/common/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import cn from 'classnames' 3 | import Link from 'next/link' 4 | import { Github } from '@components/icons' 5 | import { Logo } from '@components/ui' 6 | import { I18nWidget } from '@components/common' 7 | import s from './Footer.module.css' 8 | 9 | interface Props { 10 | className?: string 11 | children?: any 12 | } 13 | 14 | const Footer: FC = ({ className }) => { 15 | const rootClassName = cn(className) 16 | 17 | return ( 18 | 40 | ) 41 | } 42 | 43 | export default Footer 44 | -------------------------------------------------------------------------------- /sections/Hero/Hero.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Container } from '@components/ui' 3 | import { RightArrow } from '@components/icons' 4 | import s from './Hero.module.css' 5 | import Link from 'next/link' 6 | interface Props { 7 | className?: string 8 | headline: string 9 | description: string 10 | ctaLink: string 11 | ctaText: string 12 | } 13 | 14 | const Hero: FC = ({ headline, description, ctaLink, ctaText }) => { 15 | return ( 16 |
17 | 18 |
19 |

20 | {headline} 21 |

22 |
23 |

24 | {description} 25 |

26 | 27 | 28 | {ctaText} 29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 | ) 37 | } 38 | 39 | export default Hero 40 | -------------------------------------------------------------------------------- /components/icons/Trash.tsx: -------------------------------------------------------------------------------- 1 | const Trash = ({ ...props }) => { 2 | return ( 3 | 11 | 18 | 25 | 32 | 39 | 40 | ) 41 | } 42 | 43 | export default Trash 44 | -------------------------------------------------------------------------------- /lib/colors.ts: -------------------------------------------------------------------------------- 1 | import random from 'lodash.random' 2 | 3 | export function getRandomPairOfColors() { 4 | const colors = ['#37B679', '#DA3C3C', '#3291FF', '#7928CA', '#79FFE1'] 5 | const getRandomIdx = () => random(0, colors.length - 1) 6 | let idx = getRandomIdx() 7 | let idx2 = getRandomIdx() 8 | 9 | // Has to be a different color 10 | while (idx2 === idx) { 11 | idx2 = getRandomIdx() 12 | } 13 | 14 | // Returns a pair of colors 15 | return [colors[idx], colors[idx2]] 16 | } 17 | 18 | function hexToRgb(hex: string = '') { 19 | // @ts-ignore 20 | const match = hex.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i) 21 | 22 | if (!match) { 23 | return [0, 0, 0] 24 | } 25 | 26 | let colorString = match[0] 27 | 28 | if (match[0].length === 3) { 29 | colorString = colorString 30 | .split('') 31 | .map((char: string) => { 32 | return char + char 33 | }) 34 | .join('') 35 | } 36 | 37 | const integer = parseInt(colorString, 16) 38 | const r = (integer >> 16) & 0xff 39 | const g = (integer >> 8) & 0xff 40 | const b = integer & 0xff 41 | 42 | return [r, g, b] 43 | } 44 | 45 | export function isDark(color = '') { 46 | // Equation from http://24ways.org/2010/calculating-color-contrast 47 | const rgb = hexToRgb(color.toLowerCase()) 48 | const res = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 49 | return res < 128 50 | } 51 | -------------------------------------------------------------------------------- /components/icons/Github.tsx: -------------------------------------------------------------------------------- 1 | const Github = ({ ...props }) => { 2 | return ( 3 | 10 | 16 | 17 | ) 18 | } 19 | 20 | export default Github 21 | -------------------------------------------------------------------------------- /components/ui/Skeleton/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react' 2 | import cn from 'classnames' 3 | import px from '@lib/to-pixels' 4 | import s from './Skeleton.module.css' 5 | 6 | interface Props { 7 | width?: string | number 8 | height?: string | number 9 | boxHeight?: string | number 10 | style?: CSSProperties 11 | show?: boolean 12 | block?: boolean 13 | className?: string 14 | } 15 | 16 | const Skeleton: React.FC = ({ 17 | style, 18 | width, 19 | height, 20 | children, 21 | className, 22 | show = true, 23 | boxHeight = height, 24 | }) => { 25 | // Automatically calculate the size if there are children 26 | // and no fixed sizes are specified 27 | const shouldAutoSize = !!children && !(width || height) 28 | 29 | // Defaults 30 | width = width || 24 31 | height = height || 24 32 | boxHeight = boxHeight || height 33 | 34 | return ( 35 | 52 | {children} 53 | 54 | ) 55 | } 56 | 57 | export default Skeleton 58 | -------------------------------------------------------------------------------- /public/flag-en-us.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useRef, useEffect } from 'react' 2 | import Portal from '@reach/portal' 3 | import s from './Modal.module.css' 4 | import { Cross } from '@components/icons' 5 | import { 6 | disableBodyScroll, 7 | enableBodyScroll, 8 | clearAllBodyScrollLocks, 9 | } from 'body-scroll-lock' 10 | 11 | interface Props { 12 | className?: string 13 | children?: any 14 | open?: boolean 15 | onClose: () => void 16 | } 17 | 18 | const Modal: FC = ({ children, open, onClose }) => { 19 | const ref = useRef() as React.MutableRefObject 20 | 21 | useEffect(() => { 22 | if (ref.current) { 23 | if (open) { 24 | disableBodyScroll(ref.current) 25 | } else { 26 | enableBodyScroll(ref.current) 27 | } 28 | } 29 | return () => { 30 | clearAllBodyScrollLocks() 31 | } 32 | }, [open]) 33 | 34 | return ( 35 | 36 | {open ? ( 37 |
38 |
39 |
40 | 47 |
48 | {children} 49 |
50 |
51 | ) : null} 52 |
53 | ) 54 | } 55 | 56 | export default Modal 57 | -------------------------------------------------------------------------------- /components/ui/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FunctionComponent, 3 | JSXElementConstructor, 4 | CSSProperties, 5 | } from 'react' 6 | import cn from 'classnames' 7 | import s from './Text.module.css' 8 | 9 | interface Props { 10 | variant?: Variant 11 | className?: string 12 | style?: CSSProperties 13 | children?: React.ReactNode | any 14 | html?: string 15 | } 16 | 17 | type Variant = 'heading' | 'body' | 'pageHeading' | 'sectionHeading' 18 | 19 | const Text: FunctionComponent = ({ 20 | style, 21 | className = '', 22 | variant = 'body', 23 | children, 24 | html, 25 | }) => { 26 | const componentsMap: { 27 | [P in Variant]: React.ComponentType | string 28 | } = { 29 | body: 'div', 30 | heading: 'h1', 31 | pageHeading: 'h1', 32 | sectionHeading: 'h2', 33 | } 34 | 35 | const Component: 36 | | JSXElementConstructor 37 | | React.ReactElement 38 | | React.ComponentType 39 | | string = componentsMap![variant!] 40 | 41 | const htmlContentProps = html 42 | ? { 43 | dangerouslySetInnerHTML: { __html: html }, 44 | } 45 | : {} 46 | 47 | return ( 48 | 62 | {children} 63 | 64 | ) 65 | } 66 | 67 | export default Text 68 | -------------------------------------------------------------------------------- /components/product/Swatch/Swatch.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import { FC } from 'react' 3 | import s from './Swatch.module.css' 4 | import { Check } from '@components/icons' 5 | import Button, { ButtonProps } from '@components/ui/Button' 6 | import { isDark } from '@lib/colors' 7 | import colorNames from 'css-color-names' 8 | interface Props { 9 | active?: boolean 10 | children?: any 11 | className?: string 12 | label?: string 13 | variant?: 'size' | 'color' | string 14 | color?: string 15 | } 16 | 17 | function getHexColor(strColor: string) { 18 | return (colorNames as Record)[strColor.toLowerCase()] 19 | } 20 | 21 | const Swatch: FC> = ({ 22 | className, 23 | color = '', 24 | label, 25 | variant = 'size', 26 | active, 27 | ...props 28 | }) => { 29 | variant = variant?.toLowerCase() 30 | label = label?.toLowerCase() 31 | const hexColor = variant == 'color' && getHexColor(color) 32 | const rootClassName = cn( 33 | s.root, 34 | { 35 | [s.active]: active, 36 | [s.size]: !hexColor, 37 | ...(hexColor && { 38 | [s.color]: color, 39 | [s.dark]: color ? isDark(hexColor) : false, 40 | }), 41 | }, 42 | className 43 | ) 44 | 45 | return ( 46 | 59 | ) 60 | } 61 | 62 | export default Swatch 63 | -------------------------------------------------------------------------------- /components/ui/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import s from './Sidebar.module.css' 2 | import Portal from '@reach/portal' 3 | import { FC, useEffect, useRef } from 'react' 4 | import { 5 | disableBodyScroll, 6 | enableBodyScroll, 7 | clearAllBodyScrollLocks, 8 | } from 'body-scroll-lock' 9 | 10 | interface Props { 11 | children: any 12 | open: boolean 13 | onClose: () => void 14 | } 15 | 16 | const Sidebar: FC = ({ children, open = false, onClose }) => { 17 | const ref = useRef() as React.MutableRefObject 18 | 19 | useEffect(() => { 20 | if (ref.current) { 21 | if (open) { 22 | disableBodyScroll(ref.current) 23 | } else { 24 | enableBodyScroll(ref.current) 25 | } 26 | } 27 | return () => { 28 | clearAllBodyScrollLocks() 29 | } 30 | }, [open]) 31 | 32 | return ( 33 | 34 | {open ? ( 35 |
36 |
37 |
41 |
42 |
43 |
44 | {children} 45 |
46 |
47 |
48 |
49 |
50 | ) : null} 51 | 52 | ) 53 | } 54 | 55 | export default Sidebar 56 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/hooks/useAddItemsToCart.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Context } from '../Context' 3 | import ShopifyBuy from 'shopify-buy' 4 | import { LineItemPatch } from '../types' 5 | 6 | export function useAddItemsToCart() { 7 | const { client, cart, setCart } = useContext(Context) 8 | 9 | async function addItemsToCart(items: LineItemPatch[]) { 10 | if (cart == null || client == null) { 11 | throw new Error('Called addItemsToCart too soon') 12 | } 13 | 14 | if (items.length < 1) { 15 | throw new Error( 16 | 'Must include at least one line item, empty line items found' 17 | ) 18 | } 19 | 20 | items.forEach((item) => { 21 | if (item.variantId == null) { 22 | throw new Error(`Missing variantId in item`) 23 | } 24 | 25 | if (item.quantity == null) { 26 | throw new Error( 27 | `Missing quantity in item with variant id: ${item.variantId}` 28 | ) 29 | } else if (typeof item.quantity != 'number') { 30 | throw new Error( 31 | `Quantity is not a number in item with variant id: ${item.variantId}` 32 | ) 33 | } else if (item.quantity < 1) { 34 | throw new Error( 35 | `Quantity must not be less than one in item with variant id: ${item.variantId}` 36 | ) 37 | } 38 | }) 39 | 40 | const newCart = await client.checkout.addLineItems( 41 | cart.id, 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 43 | // @ts-ignore 44 | items as ShopifyBuy.LineItem[] 45 | ) 46 | setCart(newCart) 47 | } 48 | 49 | return addItemsToCart 50 | } 51 | -------------------------------------------------------------------------------- /components/product/ProductSlider/ProductSlider.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply relative w-full h-full; 3 | overflow-y: hidden; 4 | } 5 | 6 | .leftControl, 7 | .rightControl { 8 | @apply absolute top-1/2 -translate-x-1/2 z-20 w-16 h-16 flex items-center justify-center bg-hover-1 rounded-full; 9 | } 10 | 11 | .leftControl:hover, 12 | .rightControl:hover { 13 | @apply bg-hover-2; 14 | } 15 | 16 | .leftControl:hover, 17 | .rightControl:hover { 18 | @apply outline-none shadow-outline-blue; 19 | } 20 | 21 | .leftControl { 22 | @apply bg-cover left-10; 23 | background-image: url('public/cursor-left.png'); 24 | 25 | @screen md { 26 | @apply left-6; 27 | } 28 | } 29 | 30 | .rightControl { 31 | @apply bg-cover right-10; 32 | background-image: url('public/cursor-right.png'); 33 | 34 | @screen md { 35 | @apply right-6; 36 | } 37 | } 38 | 39 | .control { 40 | @apply opacity-0 transition duration-150; 41 | } 42 | 43 | .root:hover .control { 44 | @apply opacity-100; 45 | } 46 | 47 | .positionIndicatorsContainer { 48 | @apply hidden; 49 | 50 | @screen sm { 51 | @apply block absolute bottom-6 left-1/2; 52 | transform: translateX(-50%); 53 | } 54 | } 55 | 56 | .positionIndicator { 57 | @apply rounded-full p-2; 58 | } 59 | 60 | .dot { 61 | @apply bg-hover-1 transition w-3 h-3 rounded-full; 62 | } 63 | 64 | .positionIndicator:hover .dot { 65 | @apply bg-hover-2; 66 | } 67 | 68 | .positionIndicator:focus { 69 | @apply outline-none; 70 | } 71 | 72 | .positionIndicator:focus .dot { 73 | @apply shadow-outline-blue; 74 | } 75 | 76 | .positionIndicatorActive .dot { 77 | @apply bg-white; 78 | } 79 | 80 | .positionIndicatorActive:hover .dot { 81 | @apply bg-white; 82 | } 83 | -------------------------------------------------------------------------------- /components/common/Searchbar/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import cn from 'classnames' 3 | import s from './Searchbar.module.css' 4 | import { useRouter } from 'next/router' 5 | 6 | interface Props { 7 | className?: string 8 | id?: string 9 | } 10 | 11 | const Searchbar: FC = ({ className, id = 'search' }) => { 12 | const router = useRouter() 13 | 14 | return ( 15 |
21 | 24 | { 30 | e.preventDefault() 31 | 32 | if (e.key === 'Enter') { 33 | const q = e.currentTarget.value 34 | 35 | router.push( 36 | { 37 | pathname: `/search`, 38 | query: q ? { q } : {}, 39 | }, 40 | undefined, 41 | { shallow: true } 42 | ) 43 | } 44 | }} 45 | /> 46 |
47 | 48 | 53 | 54 |
55 |
56 | ) 57 | } 58 | 59 | export default Searchbar 60 | -------------------------------------------------------------------------------- /components/ui/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React, { 3 | forwardRef, 4 | ButtonHTMLAttributes, 5 | JSXElementConstructor, 6 | useRef, 7 | } from 'react' 8 | import mergeRefs from 'react-merge-refs' 9 | import s from './Button.module.css' 10 | import { LoadingDots } from '@components/ui' 11 | 12 | export interface ButtonProps extends ButtonHTMLAttributes { 13 | href?: string 14 | className?: string 15 | variant?: 'flat' | 'slim' 16 | active?: boolean 17 | type?: 'submit' | 'reset' | 'button' 18 | Component?: string | JSXElementConstructor 19 | width?: string | number 20 | loading?: boolean 21 | disabled?: boolean 22 | } 23 | 24 | const Button: React.FC = forwardRef((props, buttonRef) => { 25 | const { 26 | className, 27 | variant = 'flat', 28 | children, 29 | active, 30 | width, 31 | loading = false, 32 | disabled = false, 33 | style = {}, 34 | Component = 'button', 35 | ...rest 36 | } = props 37 | const ref = useRef(null) 38 | 39 | const rootClassName = cn( 40 | s.root, 41 | { 42 | [s.slim]: variant === 'slim', 43 | [s.loading]: loading, 44 | [s.disabled]: disabled, 45 | }, 46 | className 47 | ) 48 | 49 | return ( 50 | 62 | {children} 63 | {loading && ( 64 | 65 | 66 | 67 | )} 68 | 69 | ) 70 | }) 71 | 72 | export default Button 73 | -------------------------------------------------------------------------------- /components/common/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import dynamic from 'next/dynamic' 3 | import s from './Layout.module.css' 4 | import React, { FC } from 'react' 5 | import { useUI } from '@components/ui/context' 6 | import { Navbar, Footer } from '@components/common' 7 | import { useAcceptCookies } from '@lib/hooks/useAcceptCookies' 8 | import { Sidebar, Button, LoadingDots } from '@components/ui' 9 | import { CartSidebarView } from '@components/cart' 10 | import { CommerceProvider } from '@lib/shopify/storefront-data-hooks' 11 | import shopifyConfig from '@config/shopify' 12 | import { builder } from '@builder.io/react' 13 | 14 | const FeatureBar = dynamic(() => import('@components/common/FeatureBar'), { 15 | ssr: false, 16 | }) 17 | 18 | const Layout: FC = ({ children }) => { 19 | const { displaySidebar, closeSidebar } = useUI() 20 | const { acceptedCookies, onAcceptCookies } = useAcceptCookies() 21 | 22 | return ( 23 | 24 |
25 | 26 |
{children}
27 |
28 | 29 | 35 | 36 | 37 | 38 | onAcceptCookies()}> 43 | Accept cookies 44 | 45 | } 46 | /> 47 |
48 |
49 | ) 50 | } 51 | 52 | export default Layout 53 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const bundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: !!process.env.BUNDLE_ANALYZE, 3 | }) 4 | 5 | module.exports = bundleAnalyzer({ 6 | images: { 7 | domains: ['cdn.shopify.com', 'cdn.builder.io'], 8 | }, 9 | async headers() { 10 | return [ 11 | { 12 | source: '/:path*', 13 | headers: [ 14 | { 15 | key: 'Content-Security-Policy', 16 | value: 17 | 'frame-ancestors https://*.builder.io https://builder.io http://localhost:1234', 18 | }, 19 | ], 20 | }, 21 | ] 22 | }, 23 | env: { 24 | // expose env to the browser 25 | SHOPIFY_STOREFRONT_API_TOKEN: process.env.SHOPIFY_STOREFRONT_API_TOKEN, 26 | SHOPIFY_STORE_DOMAIN: process.env.SHOPIFY_STORE_DOMAIN, 27 | BUILDER_PUBLIC_KEY: process.env.BUILDER_PUBLIC_KEY, 28 | }, 29 | i18n: { 30 | // These are all the locales you want to support in 31 | // your application 32 | locales: ['en-US', 'es'], 33 | // This is the default locale you want to be used when visiting 34 | // a non-locale prefixed path e.g. `/hello` 35 | defaultLocale: 'en-US', 36 | }, 37 | rewrites() { 38 | return [ 39 | // Rewrites for /search 40 | { 41 | source: '/:locale/search', 42 | destination: '/search', 43 | }, 44 | { 45 | source: '/:locale/search/:path*', 46 | destination: '/search', 47 | }, 48 | { 49 | source: '/search/designers/:name', 50 | destination: '/search', 51 | }, 52 | { 53 | source: '/search/designers/:name/:category', 54 | destination: '/search', 55 | }, 56 | { 57 | // This rewrite will also handle `/search/designers` 58 | source: '/search/:category', 59 | destination: '/search', 60 | locale: false, 61 | }, 62 | ] 63 | }, 64 | }) 65 | -------------------------------------------------------------------------------- /sections/CollectionView/CollectionView.builder.ts: -------------------------------------------------------------------------------- 1 | import { Builder } from '@builder.io/react' 2 | import { Input } from '@builder.io/sdk' 3 | import dynamic from 'next/dynamic' 4 | import { productGridSchema } from '../ProductGrid/ProductGrid.builder' 5 | const LazyCollectionView = dynamic(() => import(`./CollectionView`)) 6 | 7 | const collectionBoxSchema: Input[] = [ 8 | { 9 | name: 'productGridOptions', 10 | type: 'object', 11 | subFields: productGridSchema, 12 | defaultValue: { 13 | cardProps: { 14 | variant: 'simple', 15 | imgPriority: true, 16 | imgLayout: 'responsive', 17 | imgLoading: 'eager', 18 | imgWidth: 540, 19 | imgHeight: 540, 20 | layout: 'fixed', 21 | }, 22 | gridProps: { 23 | variant: 'default', 24 | layout: 'normal', 25 | }, 26 | }, 27 | }, 28 | { 29 | type: 'boolean', 30 | name: 'renderSeo', 31 | advanced: true, 32 | helperText: 33 | 'toggle to render seo info on page, only use for collection pages', 34 | }, 35 | ] 36 | 37 | Builder.registerComponent(LazyCollectionView, { 38 | name: 'CollectionBox', 39 | description: 'Dynamic collection detaills', 40 | inputs: collectionBoxSchema.concat([ 41 | { 42 | name: 'collection', 43 | // ShopifyCollectionHandle is a custom type defined in @builder.io/plugin-shopify that let's the user pick a collection from a picker and resolves to it's handle 44 | type: 'ShopifyCollectionHandle', 45 | }, 46 | ]), 47 | }) 48 | 49 | Builder.registerComponent(LazyCollectionView, { 50 | name: 'CollectionView', 51 | description: 52 | 'Dynamic collection detaills, autobinds to the collection in context, use only on collection pages', 53 | inputs: collectionBoxSchema, 54 | defaults: { 55 | bindings: { 56 | 'component.options.collection': 'state.collection', 57 | 'component.options.renderSeo': 'true', 58 | }, 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | future: { 3 | purgeLayersByDefault: true, 4 | applyComplexClasses: true, 5 | }, 6 | purge: { 7 | content: [ 8 | './pages/**/*.{js,ts,jsx,tsx}', 9 | './sections/**/*.{js,ts,jsx,tsx}', 10 | './components/**/*.{js,ts,jsx,tsx}', 11 | ], 12 | }, 13 | theme: { 14 | extend: { 15 | maxWidth: { 16 | '8xl': '1920px', 17 | }, 18 | colors: { 19 | primary: 'var(--primary)', 20 | 'primary-2': 'var(--primary-2)', 21 | secondary: 'var(--secondary)', 22 | 'secondary-2': 'var(--secondary-2)', 23 | hover: 'var(--hover)', 24 | 'hover-1': 'var(--hover-1)', 25 | 'hover-2': 'var(--hover-2)', 26 | 'accents-0': 'var(--accents-0)', 27 | 'accents-1': 'var(--accents-1)', 28 | 'accents-2': 'var(--accents-2)', 29 | 'accents-3': 'var(--accents-3)', 30 | 'accents-4': 'var(--accents-4)', 31 | 'accents-5': 'var(--accents-5)', 32 | 'accents-6': 'var(--accents-6)', 33 | 'accents-7': 'var(--accents-7)', 34 | 'accents-8': 'var(--accents-8)', 35 | 'accents-9': 'var(--accents-9)', 36 | violet: 'var(--violet)', 37 | 'violet-light': 'var(--violet-light)', 38 | pink: 'var(--pink)', 39 | cyan: 'var(--cyan)', 40 | blue: 'var(--blue)', 41 | green: 'var(--green)', 42 | red: 'var(--red)', 43 | }, 44 | textColor: { 45 | base: 'var(--text-base)', 46 | primary: 'var(--text-primary)', 47 | secondary: 'var(--text-secondary)', 48 | }, 49 | boxShadow: { 50 | 'outline-2': '0 0 0 2px var(--accents-2)', 51 | magical: 52 | 'rgba(0, 0, 0, 0.02) 0px 30px 30px, rgba(0, 0, 0, 0.03) 0px 0px 8px, rgba(0, 0, 0, 0.05) 0px 1px 0px', 53 | }, 54 | lineHeight: { 55 | 'extra-loose': '2.2', 56 | }, 57 | scale: { 58 | 120: '1.2', 59 | }, 60 | }, 61 | }, 62 | plugins: [require('@tailwindcss/ui')], 63 | } 64 | -------------------------------------------------------------------------------- /sections/ProductView/ProductView.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply relative grid items-start gap-8 grid-cols-1 overflow-x-hidden; 3 | 4 | @screen lg { 5 | @apply grid-cols-12; 6 | } 7 | } 8 | 9 | .productDisplay { 10 | @apply relative flex px-0 pb-0 relative box-border col-span-1 bg-violet; 11 | min-height: 600px; 12 | 13 | @screen md { 14 | min-height: 700px; 15 | } 16 | 17 | @screen lg { 18 | margin-right: -2rem; 19 | margin-left: -2rem; 20 | @apply mx-0 col-span-6; 21 | min-height: 100%; 22 | height: 100%; 23 | } 24 | } 25 | 26 | .squareBg { 27 | @apply absolute inset-0 bg-violet z-0 h-full; 28 | } 29 | 30 | .nameBox { 31 | @apply absolute top-6 left-0 z-20 pr-16; 32 | 33 | @screen lg { 34 | @apply left-6 pr-16; 35 | } 36 | 37 | & .name { 38 | @apply px-6 py-2 bg-primary text-primary font-bold; 39 | font-size: 2rem; 40 | letter-spacing: 0.4px; 41 | } 42 | 43 | & .price { 44 | @apply px-6 py-2 pb-4 bg-primary text-primary font-bold inline-block tracking-wide; 45 | } 46 | 47 | @screen lg { 48 | & .name, 49 | & .price { 50 | @apply bg-violet-light text-white; 51 | } 52 | } 53 | } 54 | 55 | .sidebar { 56 | @apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 w-full h-full; 57 | 58 | @screen lg { 59 | @apply col-span-6 py-24 justify-between; 60 | } 61 | } 62 | 63 | .sliderContainer { 64 | @apply absolute z-10 inset-0 flex items-center justify-center overflow-x-hidden; 65 | } 66 | 67 | .imageContainer { 68 | & > div { 69 | @apply h-full; 70 | & > div { 71 | @apply h-full; 72 | } 73 | } 74 | } 75 | 76 | .img { 77 | @apply w-full h-auto max-h-full object-cover; 78 | } 79 | 80 | .button { 81 | text-align: center; 82 | width: 100%; 83 | max-width: 300px; 84 | 85 | @screen sm { 86 | min-width: 300px; 87 | } 88 | } 89 | 90 | .wishlistButton { 91 | @apply absolute z-30 top-6 right-0 bg-primary text-primary w-10 h-10 flex items-center justify-center font-semibold leading-6 cursor-pointer; 92 | 93 | @screen lg { 94 | @apply right-12 text-white bg-violet; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/resolve-builder-content.ts: -------------------------------------------------------------------------------- 1 | import { builder, Builder } from '@builder.io/react' 2 | import { getAsyncProps } from '@builder.io/utils' 3 | import builderConfig from '@config/builder' 4 | import { 5 | getCollection, 6 | getProduct, 7 | searchProducts, 8 | } from './shopify/storefront-data-hooks/src/api/operations-builder' 9 | builder.init(builderConfig.apiKey) 10 | Builder.isStatic = true 11 | 12 | export async function resolveBuilderContent( 13 | modelName: string, 14 | targetingAttributes: any 15 | ) { 16 | let page = await builder 17 | .get(modelName, { 18 | userAttributes: targetingAttributes, 19 | includeRefs: true, 20 | preview: modelName, 21 | cachebust: true, 22 | noCache: true, 23 | } as any) 24 | .toPromise() 25 | 26 | if (page) { 27 | return await getAsyncProps(page, { 28 | async ProductGrid(props) { 29 | let products: any[] = [] 30 | if (props.productsList) { 31 | const promises = props.productsList 32 | .map((entry: any) => entry.product) 33 | .filter((handle: string | undefined) => typeof handle === 'string') 34 | .map( 35 | async (handle: string) => 36 | await getProduct(builderConfig, { handle }) 37 | ) 38 | products = await Promise.all(promises) 39 | } 40 | return { 41 | // resolve the query as `products` for ssr 42 | // used for example in ProductGrid.tsx as initialProducts 43 | products, 44 | } 45 | }, 46 | async CollectionBox(props) { 47 | let collection = props.collection 48 | if (collection && typeof collection === 'string') { 49 | collection = await getCollection(builderConfig, { 50 | handle: collection, 51 | }) 52 | } 53 | return { 54 | collection, 55 | } 56 | }, 57 | 58 | async ProductCollectionGrid({ collection }) { 59 | if (collection && typeof collection === 'string') { 60 | const { products } = await getCollection(builderConfig, { 61 | handle: collection, 62 | }) 63 | return { 64 | products, 65 | } 66 | } 67 | }, 68 | }) 69 | } 70 | return null 71 | } 72 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/CommerceProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import ShopifyBuy from 'shopify-buy' 3 | import { Context } from './Context' 4 | import { LocalStorage, LocalStorageKeys } from './utils' 5 | 6 | export interface CommerceProviderProps extends ShopifyBuy.Config { 7 | children: React.ReactNode 8 | } 9 | 10 | export function CommerceProvider({ 11 | storefrontAccessToken, 12 | domain, 13 | children, 14 | }: CommerceProviderProps) { 15 | if (domain == null || storefrontAccessToken == null) { 16 | throw new Error( 17 | 'Unable to build shopify-buy client object. Please make sure that your access token and domain are correct.' 18 | ) 19 | } 20 | 21 | const initialCart = LocalStorage.getInitialCart() 22 | const [cart, setCart] = useState(initialCart) 23 | 24 | const isCustomDomain = domain.includes('.') 25 | 26 | const client = ShopifyBuy.buildClient({ 27 | storefrontAccessToken, 28 | domain: isCustomDomain ? domain : `${domain}.myshopify.com`, 29 | }) 30 | 31 | useEffect(() => { 32 | async function getNewCart() { 33 | const newCart = await client.checkout.create() 34 | setCart(newCart) 35 | } 36 | 37 | async function refreshExistingCart(cartId: string) { 38 | try { 39 | const refreshedCart = await client.checkout.fetch(cartId) 40 | 41 | if (refreshedCart == null) { 42 | return getNewCart() 43 | } 44 | 45 | const cartHasBeenPurchased = Boolean(refreshedCart.completedAt) 46 | 47 | if (cartHasBeenPurchased) { 48 | getNewCart() 49 | } else { 50 | setCart(refreshedCart) 51 | } 52 | } catch (error) { 53 | console.error(error) 54 | } 55 | } 56 | 57 | if (cart == null) { 58 | getNewCart() 59 | } else { 60 | refreshExistingCart(String(cart.id)) 61 | } 62 | }, []) 63 | 64 | useEffect(() => { 65 | LocalStorage.set(LocalStorageKeys.CART, JSON.stringify(cart)) 66 | }, [cart]) 67 | 68 | return ( 69 | 78 | {children} 79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /sections/CollectionView/CollectionView.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useMemo, useEffect } from 'react' 2 | import cn from 'classnames' 3 | import { NextSeo } from 'next-seo' 4 | 5 | import s from './CollectionView.module.css' 6 | import { LoadingDots, Text } from '@components/ui' 7 | import builderConfig from '@config/builder' 8 | import { ProductGrid, ProductGridProps } from '../ProductGrid/ProductGrid' 9 | import { getCollection } from '@lib/shopify/storefront-data-hooks/src/api/operations-builder' 10 | 11 | interface Props { 12 | className?: string 13 | children?: any 14 | collection: string | any // ShopifyBuy.Collection once their types are up to date 15 | productGridOptions: ProductGridProps 16 | renderSeo?: boolean 17 | } 18 | 19 | const CollectionPreview: FC = ({ 20 | collection: initalCollection, 21 | productGridOptions, 22 | renderSeo, 23 | }) => { 24 | const [collection, setCollection] = useState(initalCollection) 25 | const [loading, setLoading] = useState(false) 26 | 27 | useEffect(() => { 28 | const fetchCollection = async () => { 29 | setLoading(true) 30 | const result = await getCollection(builderConfig, { 31 | handle: collection, 32 | }) 33 | setCollection(result) 34 | setLoading(false) 35 | } 36 | if (typeof collection === 'string') { 37 | fetchCollection() 38 | } 39 | }, [collection, initalCollection]) 40 | 41 | if (!collection || typeof collection === 'string' || loading) { 42 | return 43 | } 44 | 45 | const { title, description, products } = collection 46 | 47 | return ( 48 | <> 49 | {renderSeo && ( 50 | 59 | )} 60 |
61 |
62 |

{title}

63 |
64 | 65 |
66 |
67 | 68 |
69 | 70 |
71 |
72 | 73 | ) 74 | } 75 | 76 | export default CollectionPreview 77 | -------------------------------------------------------------------------------- /pages/collection/[handle].tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | GetStaticPathsContext, 3 | GetStaticPropsContext, 4 | InferGetStaticPropsType, 5 | } from 'next' 6 | import { useRouter } from 'next/router' 7 | import { Layout } from '@components/common' 8 | import { BuilderComponent, Builder, builder } from '@builder.io/react' 9 | import { resolveBuilderContent } from '@lib/resolve-builder-content' 10 | import builderConfig from '@config/builder' 11 | import { 12 | getCollection, 13 | getAllCollectionPaths, 14 | } from '@lib/shopify/storefront-data-hooks/src/api/operations-builder' 15 | import DefaultErrorPage from 'next/error' 16 | import Head from 'next/head' 17 | 18 | builder.init(builderConfig.apiKey!) 19 | Builder.isStatic = true 20 | const builderModel = 'collection-page' 21 | 22 | export async function getStaticProps({ 23 | params, 24 | locale, 25 | }: GetStaticPropsContext<{ handle: string }>) { 26 | const collection = await getCollection(builderConfig, { 27 | handle: params?.handle, 28 | }) 29 | 30 | const page = await resolveBuilderContent(builderModel, { 31 | collectionHandle: params?.handle, 32 | locale, 33 | }) 34 | 35 | return { 36 | props: { 37 | page, 38 | collection, 39 | }, 40 | // 4 hours in production, 1s in development 41 | // todo: 14400 42 | revalidate: 1, 43 | } 44 | } 45 | 46 | export async function getStaticPaths({ locales }: GetStaticPathsContext) { 47 | const paths = await getAllCollectionPaths(builderConfig) 48 | return { 49 | paths: paths.map((path) => `/collection/${path}`), 50 | fallback: 'blocking', 51 | } 52 | } 53 | 54 | export default function Handle({ 55 | collection, 56 | page, 57 | }: InferGetStaticPropsType) { 58 | const router = useRouter() 59 | const isLive = !Builder.isEditing && !Builder.isPreviewing 60 | 61 | if (!collection && isLive) { 62 | return ( 63 | <> 64 | 65 | 66 | 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | return router.isFallback && isLive ? ( 74 |

Loading...

// TODO (BC) Add Skeleton Views 75 | ) : ( 76 | 83 | ) 84 | } 85 | 86 | Handle.Layout = Layout 87 | -------------------------------------------------------------------------------- /components/product/ProductSlider/ProductSlider.tsx: -------------------------------------------------------------------------------- 1 | import { useKeenSlider } from 'keen-slider/react' 2 | import React, { Children, FC, isValidElement, useState } from 'react' 3 | import cn from 'classnames' 4 | 5 | import s from './ProductSlider.module.css' 6 | 7 | const ProductSlider: FC = ({ children }) => { 8 | const [currentSlide, setCurrentSlide] = useState(0) 9 | const [isMounted, setIsMounted] = useState(false) 10 | 11 | const [ref, slider] = useKeenSlider({ 12 | loop: true, 13 | slidesPerView: 1, 14 | mounted: () => setIsMounted(true), 15 | slideChanged(s) { 16 | setCurrentSlide(s.details().relativeSlide) 17 | }, 18 | }) 19 | 20 | return ( 21 |
22 | 69 | ) 70 | })} 71 |
72 | )} 73 |
74 | ) 75 | } 76 | 77 | export default ProductSlider 78 | -------------------------------------------------------------------------------- /components/icons/Vercel.tsx: -------------------------------------------------------------------------------- 1 | const Vercel = ({ ...props }) => { 2 | return ( 3 | 11 | 12 | 16 | 20 | 24 | 28 | 32 | 36 | 37 | ) 38 | } 39 | 40 | export default Vercel 41 | -------------------------------------------------------------------------------- /pages/product/[handle].tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | GetStaticPathsContext, 3 | GetStaticPropsContext, 4 | InferGetStaticPropsType, 5 | } from 'next' 6 | import { useRouter } from 'next/router' 7 | import { Layout } from '@components/common' 8 | import { BuilderComponent, Builder, builder } from '@builder.io/react' 9 | import { resolveBuilderContent } from '@lib/resolve-builder-content' 10 | import '../../sections/ProductView/ProductView.builder' 11 | import builderConfig from '@config/builder' 12 | import { 13 | getAllProductPaths, 14 | getProduct, 15 | } from '@lib/shopify/storefront-data-hooks/src/api/operations-builder' 16 | import DefaultErrorPage from 'next/error' 17 | import Head from 'next/head' 18 | 19 | builder.init(builderConfig.apiKey!) 20 | Builder.isStatic = true 21 | 22 | const builderModel = 'product-page' 23 | 24 | export async function getStaticProps({ 25 | params, 26 | locale, 27 | }: GetStaticPropsContext<{ handle: string }>) { 28 | const product = await getProduct(builderConfig, { 29 | handle: params?.handle, 30 | }) 31 | 32 | const page = await resolveBuilderContent(builderModel, { 33 | productHandle: params?.handle, 34 | locale, 35 | }) 36 | 37 | return { 38 | props: { 39 | page, 40 | product, 41 | }, 42 | revalidate: 120, 43 | } 44 | } 45 | 46 | export async function getStaticPaths({ locales }: GetStaticPathsContext) { 47 | const paths = await getAllProductPaths(builderConfig) 48 | return { 49 | paths: paths.map((path) => `/product/${path}`), 50 | fallback: 'blocking', 51 | } 52 | } 53 | 54 | export default function Handle({ 55 | product, 56 | page, 57 | }: InferGetStaticPropsType) { 58 | const router = useRouter() 59 | const isLive = !Builder.isEditing && !Builder.isPreviewing 60 | // This includes setting the noindex header because static files always return a status 200 but the rendered not found page page should obviously not be indexed 61 | if (!product && isLive) { 62 | return ( 63 | <> 64 | 65 | 66 | 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | return router.isFallback && isLive ? ( 74 |

Loading...

// TODO (BC) Add Skeleton Views 75 | ) : ( 76 | 83 | ) 84 | } 85 | 86 | Handle.Layout = Layout 87 | -------------------------------------------------------------------------------- /sections/ProductGrid/ProductGrid.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState, useMemo } from 'react' 2 | import { Grid, GridProps, LoadingDots } from '@components/ui' 3 | import { ProductCard, ProductCardProps } from '@components/product' 4 | import { 5 | getCollection, 6 | getProduct, 7 | searchProducts, 8 | } from '@lib/shopify/storefront-data-hooks/src/api/operations-builder' 9 | import builderConfig from '@config/builder' 10 | interface HighlightedCardProps extends ProductCardProps { 11 | index: number 12 | } 13 | 14 | export interface ProductGridProps { 15 | gridProps?: GridProps 16 | products?: ShopifyBuy.Product[] 17 | productsList: Array<{ product: string }> 18 | collection?: string | any // ShopifyBuy.Collection 19 | offset: number 20 | limit: number 21 | cardProps: ProductCardProps 22 | highlightCard?: HighlightedCardProps 23 | } 24 | 25 | export const ProductGrid: FC = ({ 26 | products: initialProducts, 27 | collection, 28 | productsList, 29 | offset = 0, 30 | limit = 10, 31 | cardProps, 32 | highlightCard, 33 | gridProps, 34 | }) => { 35 | const [products, setProducts] = useState(initialProducts || []) 36 | const [loading, setLoading] = useState(false) 37 | 38 | useEffect(() => { 39 | const getProducts = async () => { 40 | setLoading(true) 41 | const promises = productsList 42 | .map((entry) => entry.product) 43 | .filter((handle: string | undefined) => typeof handle === 'string') 44 | .map( 45 | async (handle: string) => await getProduct(builderConfig, { handle }) 46 | ) 47 | setProducts(await Promise.all(promises)) 48 | setLoading(false) 49 | } 50 | if (productsList && !initialProducts) { 51 | getProducts() 52 | } 53 | }, [productsList, initialProducts]) 54 | 55 | useEffect(() => { 56 | const fetchCollection = async () => { 57 | setLoading(true) 58 | const result = await getCollection(builderConfig, { 59 | handle: collection, 60 | }) 61 | setProducts(result.products) 62 | setLoading(false) 63 | } 64 | if (typeof collection === 'string' && !initialProducts) { 65 | fetchCollection() 66 | } 67 | }, [collection]) 68 | 69 | if (loading) { 70 | return 71 | } 72 | 73 | return ( 74 | 75 | {products.slice(offset, limit).map((product, i) => ( 76 | 81 | ))} 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /components/ui/Grid/Grid.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | --row-height: calc(100vh - 88px); 3 | @apply grid grid-cols-1 gap-0; 4 | min-height: var(--row-height); 5 | 6 | @screen lg { 7 | @apply grid-cols-3 grid-rows-2; 8 | } 9 | 10 | & > * { 11 | @apply row-span-1 bg-transparent box-border overflow-hidden; 12 | height: 500px; 13 | max-height: 800px; 14 | 15 | @screen lg { 16 | @apply col-span-1; 17 | height: inherit; 18 | } 19 | } 20 | } 21 | 22 | .default { 23 | & > * { 24 | @apply bg-transparent; 25 | } 26 | } 27 | 28 | .layoutNormal { 29 | @apply gap-3; 30 | 31 | & > * { 32 | min-height: 325px; 33 | } 34 | } 35 | 36 | .layoutA { 37 | & > *:nth-child(6n + 1), 38 | & > *:nth-child(6n + 5) { 39 | @apply row-span-2; 40 | height: var(--row-height); 41 | 42 | @screen lg { 43 | @apply col-span-2; 44 | } 45 | } 46 | 47 | &.filled { 48 | & > *:nth-child(6n + 1), 49 | & > *:nth-child(6n + 5) { 50 | @apply bg-violet; 51 | } 52 | 53 | & > *:nth-child(6n + 5) { 54 | @apply bg-blue; 55 | } 56 | 57 | & > *:nth-child(6n + 3) { 58 | @apply bg-pink; 59 | } 60 | 61 | & > *:nth-child(6n + 6) { 62 | @apply bg-cyan; 63 | } 64 | } 65 | } 66 | 67 | .layoutB { 68 | & > *:nth-child(6n + 2), 69 | & > *:nth-child(6n + 4) { 70 | @apply row-span-2; 71 | height: var(--row-height); 72 | 73 | @screen lg { 74 | @apply col-span-2; 75 | } 76 | } 77 | 78 | &.filled { 79 | & > *:nth-child(6n + 2) { 80 | @apply bg-blue; 81 | } 82 | 83 | & > *:nth-child(6n + 4) { 84 | @apply bg-violet; 85 | } 86 | 87 | & > *:nth-child(6n + 3) { 88 | @apply bg-pink; 89 | } 90 | 91 | & > *:nth-child(6n + 6) { 92 | @apply bg-cyan; 93 | } 94 | } 95 | } 96 | 97 | .layoutC { 98 | & > *:nth-child(12n + 1), 99 | & > *:nth-child(12n + 8) { 100 | @apply row-span-2; 101 | height: var(--row-height); 102 | 103 | @screen lg { 104 | @apply col-span-2; 105 | } 106 | } 107 | 108 | &.filled { 109 | & > *:nth-child(12n + 1) { 110 | @apply bg-violet; 111 | height: var(--row-height); 112 | } 113 | 114 | & > *:nth-child(12n + 8) { 115 | @apply bg-cyan; 116 | height: var(--row-height); 117 | } 118 | 119 | & > *:nth-child(6n + 3) { 120 | @apply bg-pink; 121 | } 122 | } 123 | } 124 | 125 | .layoutD { 126 | & > *:nth-child(12n + 2), 127 | & > *:nth-child(12n + 7) { 128 | @apply row-span-2; 129 | height: var(--row-height); 130 | 131 | @screen lg { 132 | @apply col-span-2; 133 | } 134 | } 135 | 136 | &.filled { 137 | & > *:nth-child(12n + 2) { 138 | @apply bg-violet; 139 | } 140 | 141 | & > *:nth-child(12n + 7) { 142 | @apply bg-cyan; 143 | } 144 | 145 | & > *:nth-child(6n + 3) { 146 | @apply bg-pink; 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /components/product/ProductCard/ProductCard.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import Link from 'next/link' 3 | import Image from 'next/image' 4 | import type { FC } from 'react' 5 | import s from './ProductCard.module.css' 6 | import { getPrice } from '@lib/shopify/storefront-data-hooks/src/utils/product' 7 | 8 | export interface ProductCardProps { 9 | className?: string 10 | product: ShopifyBuy.Product 11 | variant?: 'slim' | 'simple' 12 | imgWidth: number | string 13 | imgHeight: number | string 14 | imgLayout?: 'fixed' | 'intrinsic' | 'responsive' | undefined 15 | imgPriority?: boolean 16 | imgLoading?: 'eager' | 'lazy' 17 | imgSizes?: string 18 | } 19 | 20 | const ProductCard: FC = ({ 21 | className, 22 | product: p, 23 | variant, 24 | imgWidth, 25 | imgHeight, 26 | imgPriority, 27 | imgLoading, 28 | imgSizes, 29 | imgLayout = 'responsive', 30 | }) => { 31 | const src = p.images[0].src 32 | const productVariant: any = p.variants[0] 33 | const price = getPrice( 34 | productVariant.priceV2.amount, 35 | productVariant.priceV2.currencyCode 36 | ) 37 | 38 | return ( 39 | 40 | 43 | {variant === 'slim' ? ( 44 |
45 |
46 | 47 | {p.title} 48 | 49 |
50 | {p.title} 61 |
62 | ) : ( 63 | <> 64 |
65 |
66 |
67 |

68 | {p.title} 69 |

70 | {price} 71 |
72 |
73 |
74 | {p.title} 86 |
87 | 88 | )} 89 |
90 | 91 | ) 92 | } 93 | 94 | export default ProductCard 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-commerce", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "analyze": "BUNDLE_ANALYZE=both yarn build", 9 | "find:unused": "next-unused", 10 | "prettier": "prettier" 11 | }, 12 | "prettier": { 13 | "semi": false, 14 | "singleQuote": true 15 | }, 16 | "next-unused": { 17 | "alias": { 18 | "@lib/*": [ 19 | "lib/*" 20 | ], 21 | "@assets/*": [ 22 | "assets/*" 23 | ], 24 | "@config/*": [ 25 | "config/*" 26 | ], 27 | "@components/*": [ 28 | "components/*" 29 | ], 30 | "@utils/*": [ 31 | "utils/*" 32 | ] 33 | }, 34 | "debug": true, 35 | "include": [ 36 | "components", 37 | "lib", 38 | "pages", 39 | "sections" 40 | ], 41 | "exclude": [], 42 | "entrypoints": [ 43 | "pages" 44 | ] 45 | }, 46 | "dependencies": { 47 | "@builder.io/react": "^1.1.34-10", 48 | "@builder.io/utils": "^1.0.3", 49 | "@reach/portal": "^0.11.2", 50 | "@tailwindcss/ui": "^0.6.2", 51 | "@testing-library/react-hooks": "^3.7.0", 52 | "@types/body-scroll-lock": "^2.6.1", 53 | "@types/lodash.throttle": "^4.1.6", 54 | "@types/qs": "^6.9.5", 55 | "@types/react-sticky": "^6.0.3", 56 | "@types/shopify-buy": "^2.10.3", 57 | "@types/traverse": "^0.6.32", 58 | "@vercel/fetch": "^6.1.0", 59 | "atob": "^2.1.2", 60 | "body-scroll-lock": "^3.1.5", 61 | "bowser": "^2.11.0", 62 | "classnames": "^2.2.6", 63 | "css-color-names": "^1.0.1", 64 | "email-validator": "^2.0.4", 65 | "jest": "^26.6.3", 66 | "js-cookie": "^2.2.1", 67 | "keen-slider": "^5.2.4", 68 | "lodash.random": "^3.2.0", 69 | "lodash.throttle": "^4.1.1", 70 | "next": "^10.0.5", 71 | "next-seo": "^4.11.0", 72 | "next-themes": "^0.0.4", 73 | "postcss-nesting": "^7.0.1", 74 | "qs": "^6.9.6", 75 | "react": "^16.14.0", 76 | "react-dom": "^16.14.0", 77 | "react-intersection-observer": "^8.30.1", 78 | "react-json-tree": "^0.13.0", 79 | "react-merge-refs": "^1.1.0", 80 | "react-sticky": "^6.0.3", 81 | "react-ticker": "^1.2.2", 82 | "shopify-buy": "^2.11.0", 83 | "tailwindcss": "^1.9", 84 | "traverse": "^0.6.6" 85 | }, 86 | "devDependencies": { 87 | "@next/bundle-analyzer": "^10.0.1", 88 | "@types/atob": "^2.1.2", 89 | "@types/bunyan": "^1.8.6", 90 | "@types/bunyan-prettystream": "^0.1.31", 91 | "@types/classnames": "^2.2.10", 92 | "@types/js-cookie": "^2.2.6", 93 | "@types/lodash.random": "^3.2.6", 94 | "@types/node": "^14.11.2", 95 | "@types/react": "^16.9.49", 96 | "bunyan": "^1.8.14", 97 | "bunyan-prettystream": "^0.1.3", 98 | "next-unused": "^0.0.3", 99 | "postcss-flexbugs-fixes": "^4.2.1", 100 | "postcss-preset-env": "^6.7.0", 101 | "prettier": "^2.1.2", 102 | "typescript": "^4.0.3" 103 | }, 104 | "resolutions": { 105 | "webpack": "^5.0.0-beta.30" 106 | }, 107 | "license": "MIT" 108 | } 109 | -------------------------------------------------------------------------------- /assets/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #ffffff; 3 | --primary-2: #f1f3f5; 4 | --secondary: #000000; 5 | --secondary-2: #111; 6 | 7 | --selection: var(--cyan); 8 | 9 | --text-base: #000000; 10 | --text-primary: #000000; 11 | --text-secondary: white; 12 | 13 | --hover: rgba(0, 0, 0, 0.075); 14 | --hover-1: rgba(0, 0, 0, 0.15); 15 | --hover-2: rgba(0, 0, 0, 0.25); 16 | 17 | --cyan: #22b8cf; 18 | --green: #37b679; 19 | --red: #da3c3c; 20 | --pink: #e64980; 21 | --purple: #f81ce5; 22 | 23 | --blue: #0070f3; 24 | 25 | --violet-light: #7048e8; 26 | --violet: #5f3dc4; 27 | 28 | --accents-0: #f8f9fa; 29 | --accents-1: #f1f3f5; 30 | --accents-2: #e9ecef; 31 | --accents-3: #dee2e6; 32 | --accents-4: #ced4da; 33 | --accents-5: #adb5bd; 34 | --accents-6: #868e96; 35 | --accents-7: #495057; 36 | --accents-8: #343a40; 37 | --accents-9: #212529; 38 | --font-sans: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue', 39 | 'Helvetica', sans-serif; 40 | } 41 | 42 | [data-theme='dark'] { 43 | --primary: #000000; 44 | --primary-2: #111; 45 | --secondary: #ffffff; 46 | --secondary-2: #f1f3f5; 47 | --hover: rgba(255, 255, 255, 0.075); 48 | --hover-1: rgba(255, 255, 255, 0.15); 49 | --hover-2: rgba(255, 255, 255, 0.25); 50 | --selection: var(--purple); 51 | 52 | --text-base: white; 53 | --text-primary: white; 54 | --text-secondary: black; 55 | 56 | --accents-0: #212529; 57 | --accents-1: #343a40; 58 | --accents-2: #495057; 59 | --accents-3: #868e96; 60 | --accents-4: #adb5bd; 61 | --accents-5: #ced4da; 62 | --accents-6: #dee2e6; 63 | --accents-7: #e9ecef; 64 | --accents-8: #f1f3f5; 65 | --accents-9: #f8f9fa; 66 | } 67 | 68 | *, 69 | *:before, 70 | *:after { 71 | box-sizing: inherit; 72 | } 73 | 74 | html { 75 | height: 100%; 76 | box-sizing: border-box; 77 | touch-action: manipulation; 78 | font-feature-settings: 'case' 1, 'rlig' 1, 'calt' 0; 79 | text-rendering: optimizeLegibility; 80 | -webkit-font-smoothing: antialiased; 81 | -moz-osx-font-smoothing: grayscale; 82 | } 83 | 84 | html, 85 | body { 86 | font-family: var(--font-sans); 87 | text-rendering: optimizeLegibility; 88 | -webkit-font-smoothing: antialiased; 89 | -moz-osx-font-smoothing: grayscale; 90 | background-color: var(--primary); 91 | color: var(--text-primary); 92 | } 93 | 94 | body { 95 | position: relative; 96 | min-height: 100%; 97 | margin: 0; 98 | } 99 | 100 | a { 101 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 102 | } 103 | 104 | .animated { 105 | -webkit-animation-duration: 1s; 106 | animation-duration: 1s; 107 | -webkit-animation-duration: 1s; 108 | animation-duration: 1s; 109 | -webkit-animation-fill-mode: both; 110 | animation-fill-mode: both; 111 | } 112 | 113 | .fadeIn { 114 | -webkit-animation-name: fadeIn; 115 | animation-name: fadeIn; 116 | } 117 | 118 | @-webkit-keyframes fadeIn { 119 | from { 120 | opacity: 0; 121 | } 122 | 123 | to { 124 | opacity: 1; 125 | } 126 | } 127 | 128 | @keyframes fadeIn { 129 | from { 130 | opacity: 0; 131 | } 132 | 133 | to { 134 | opacity: 1; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /components/common/I18nWidget/I18nWidget.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import Link from 'next/link' 3 | import { FC, useState } from 'react' 4 | import { useRouter } from 'next/router' 5 | import s from './I18nWidget.module.css' 6 | import { Cross, ChevronUp } from '@components/icons' 7 | import ClickOutside from '@lib/click-outside' 8 | interface LOCALE_DATA { 9 | name: string 10 | img: { 11 | filename: string 12 | alt: string 13 | } 14 | } 15 | 16 | const LOCALES_MAP: Record = { 17 | es: { 18 | name: 'Español', 19 | img: { 20 | filename: 'flag-es-co.svg', 21 | alt: 'Bandera Colombiana', 22 | }, 23 | }, 24 | 'en-US': { 25 | name: 'English', 26 | img: { 27 | filename: 'flag-en-us.svg', 28 | alt: 'US Flag', 29 | }, 30 | }, 31 | } 32 | 33 | const I18nWidget: FC = () => { 34 | const [display, setDisplay] = useState(false) 35 | const { 36 | locale, 37 | locales, 38 | defaultLocale = 'en-US', 39 | asPath: currentPath, 40 | } = useRouter() 41 | 42 | const options = locales?.filter((val) => val !== locale) 43 | const currentLocale = locale || defaultLocale 44 | 45 | return ( 46 | setDisplay(false)}> 47 | 95 | 96 | ) 97 | } 98 | 99 | export default I18nWidget 100 | -------------------------------------------------------------------------------- /pages/[[...path]].tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | GetStaticPathsContext, 3 | GetStaticPropsContext, 4 | InferGetStaticPropsType, 5 | } from 'next' 6 | import { NextSeo } from 'next-seo' 7 | import { useRouter } from 'next/router' 8 | import { Layout } from '@components/common' 9 | import { BuilderComponent, Builder, builder } from '@builder.io/react' 10 | import builderConfig from '@config/builder' 11 | import DefaultErrorPage from 'next/error' 12 | import Head from 'next/head' 13 | import { resolveBuilderContent } from '@lib/resolve-builder-content' 14 | 15 | builder.init(builderConfig.apiKey) 16 | import '../sections/ProductGrid/ProductGrid.builder' 17 | import '../sections/CollectionView/CollectionView.builder' 18 | import '../sections/Hero/Hero.builder' 19 | 20 | const isProduction = process.env.NODE_ENV === 'production' 21 | 22 | export async function getStaticProps({ 23 | params, 24 | locale, 25 | }: GetStaticPropsContext<{ path: string[] }>) { 26 | const page = await resolveBuilderContent('page', { 27 | locale, 28 | urlPath: '/' + (params?.path?.join('/') || ''), 29 | }) 30 | return { 31 | props: { 32 | page, 33 | locale, 34 | }, 35 | // Next.js will attempt to re-generate the page: 36 | // - When a request comes in 37 | // - At most once every 4 minutes ( 240 seconds) 38 | revalidate: 240, 39 | } 40 | } 41 | 42 | export async function getStaticPaths({ locales }: GetStaticPathsContext) { 43 | const pages = await builder.getAll('page', { 44 | options: { noTargeting: true }, 45 | apiKey: builderConfig.apiKey, 46 | }) 47 | 48 | return { 49 | paths: pages.map((page) => `${page.data?.url}`), 50 | fallback: true, 51 | } 52 | } 53 | 54 | export default function Path({ 55 | page, 56 | locale, 57 | }: InferGetStaticPropsType) { 58 | const router = useRouter() 59 | if (router.isFallback) { 60 | return

Loading...

61 | } 62 | // This includes setting the noindex header because static files always return a status 200 but the rendered not found page page should obviously not be indexed 63 | if (!page && !Builder.isEditing && !Builder.isPreviewing) { 64 | return ( 65 | <> 66 | 67 | 68 | 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | const { title, description, image } = page?.data! || {} 76 | 77 | return ( 78 |
79 | {title && ( 80 | 100 | )} 101 | 106 |
107 | ) 108 | } 109 | 110 | Path.Layout = Layout 111 | -------------------------------------------------------------------------------- /components/common/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useEffect } from 'react' 2 | import Link from 'next/link' 3 | import s from './Navbar.module.css' 4 | import { Logo, Container } from '@components/ui' 5 | import { Searchbar, UserNav } from '@components/common' 6 | import cn from 'classnames' 7 | import throttle from 'lodash.throttle' 8 | import { getAllCollections } from '@lib/shopify/storefront-data-hooks/src/api/operations-builder' 9 | import builderConfig from '@config/builder' 10 | import { BuilderComponent, builder } from '@builder.io/react' 11 | import { useCart } from '@lib/shopify/storefront-data-hooks' 12 | 13 | const Navbar: FC = () => { 14 | const [hasScrolled, setHasScrolled] = useState(false) 15 | const [collections, setCollections] = useState([] as any[]) 16 | const [announcement, setAnnouncement] = useState() 17 | const cart = useCart() 18 | useEffect(() => { 19 | async function fetchContent() { 20 | const items = cart?.lineItems || [] 21 | const anouncementContent = await builder 22 | .get('announcement-bar', { 23 | userAttributes: { 24 | itemInCart: items.map((item: any) => item.variant.product.handle), 25 | } as any, 26 | }) 27 | .toPromise() 28 | setAnnouncement(anouncementContent) 29 | } 30 | fetchContent() 31 | }, [cart?.lineItems]) 32 | 33 | useEffect(() => { 34 | const handleScroll = throttle(() => { 35 | const offset = 0 36 | const { scrollTop } = document.documentElement 37 | const scrolled = scrollTop > offset 38 | setHasScrolled(scrolled) 39 | }, 200) 40 | 41 | document.addEventListener('scroll', handleScroll) 42 | return () => { 43 | document.removeEventListener('scroll', handleScroll) 44 | } 45 | }, []) 46 | 47 | useEffect(() => { 48 | const fetchCollections = async () => { 49 | const result = await getAllCollections( 50 | builderConfig, 51 | 3, 52 | 0, 53 | 'data.handle,data.title' 54 | ) 55 | setCollections(result) 56 | } 57 | fetchCollections() 58 | }, []) 59 | 60 | return ( 61 |
62 | 63 | 64 |
65 |
66 | 67 | 68 | 69 | 70 | 71 | 78 |
79 | 80 |
81 | 82 |
83 | 84 |
85 | 86 |
87 |
88 | 89 |
90 | 91 |
92 |
93 |
94 | ) 95 | } 96 | 97 | export default Navbar 98 | -------------------------------------------------------------------------------- /pages/search.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import type { 3 | GetServerSidePropsContext, 4 | InferGetServerSidePropsType, 5 | } from 'next' 6 | import { useState, useEffect } from 'react' 7 | import { useRouter } from 'next/router' 8 | import { Layout } from '@components/common' 9 | import { Container, Grid, LoadingDots, Skeleton } from '@components/ui' 10 | import builderConfig from '@config/builder' 11 | import rangeMap from '@lib/range-map' 12 | import { ProductCard } from '@components/product' 13 | import { searchProducts } from '@lib/shopify/storefront-data-hooks/src/api/operations-builder' 14 | import NoSSR from '@components/common/NoSSR/NoSSR' 15 | 16 | const castIfNumber = (num: any) => { 17 | const res = Number(num) 18 | if (!isNaN(res)) { 19 | return res 20 | } 21 | } 22 | 23 | export async function getServerSideProps({ 24 | preview, 25 | locale, 26 | }: GetServerSidePropsContext) { 27 | // Maybe ssr search results? 28 | return { 29 | props: { products: [] as ShopifyBuy.Product[] }, 30 | } 31 | } 32 | 33 | export default function Search({ 34 | products: initialProducts, 35 | }: InferGetServerSidePropsType) { 36 | const router = useRouter() 37 | const { q, limit, offset } = router.query 38 | const [products, setProducts] = useState(initialProducts || []) 39 | const [loading, setLoading] = useState(true) 40 | 41 | useEffect(() => { 42 | const getProducts = async () => { 43 | setLoading(true) 44 | const results = await searchProducts( 45 | builderConfig, 46 | String(q), 47 | castIfNumber(limit), 48 | castIfNumber(offset) 49 | ) 50 | setProducts(results) 51 | setLoading(false) 52 | } 53 | if (q && !initialProducts.length) { 54 | getProducts() 55 | } 56 | }, [q, limit, offset, initialProducts]) 57 | 58 | const skeleton = ( 59 | 60 | {rangeMap(12, (i) => ( 61 | 62 | ))} 63 | 64 | ) 65 | 66 | return ( 67 | 68 |
69 | 70 | {products.length ? ( 71 | 72 | {products.map((product: ShopifyBuy.Product) => ( 73 | 81 | ))} 82 | 83 | ) : loading ? ( 84 | skeleton 85 | ) : ( 86 | 0, 90 | })} 91 | > 92 | {q ? ( 93 | <> 94 | There are no products that match "{q}" 95 | 96 | ) : ( 97 | <>Search for something ... 98 | )} 99 | 100 | )} 101 | 102 |
103 |
104 | ) 105 | } 106 | 107 | Search.Layout = Layout 108 | -------------------------------------------------------------------------------- /components/common/UserNav/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import Link from 'next/link' 3 | import { FC, useRef, useState, useEffect } from 'react' 4 | import { useTheme } from 'next-themes' 5 | import { useRouter } from 'next/router' 6 | import s from './DropdownMenu.module.css' 7 | import { Avatar } from '@components/common' 8 | import { Moon, Sun } from '@components/icons' 9 | import { useUI } from '@components/ui/context' 10 | import ClickOutside from '@lib/click-outside' 11 | 12 | import { 13 | disableBodyScroll, 14 | enableBodyScroll, 15 | clearAllBodyScrollLocks, 16 | } from 'body-scroll-lock' 17 | 18 | interface DropdownMenuProps { 19 | open?: boolean 20 | } 21 | 22 | const LINKS = [ 23 | { 24 | name: 'My Cart', 25 | href: '/cart', 26 | }, 27 | ] 28 | 29 | const DropdownMenu: FC = ({ open = false }) => { 30 | const { pathname } = useRouter() 31 | const { theme, setTheme } = useTheme() 32 | const [display, setDisplay] = useState(false) 33 | const { closeSidebarIfPresent } = useUI() 34 | const ref = useRef() as React.MutableRefObject 35 | 36 | useEffect(() => { 37 | if (ref.current) { 38 | if (display) { 39 | disableBodyScroll(ref.current) 40 | } else { 41 | enableBodyScroll(ref.current) 42 | } 43 | } 44 | return () => { 45 | clearAllBodyScrollLocks() 46 | } 47 | }, [display]) 48 | 49 | return ( 50 | setDisplay(false)}> 51 |
52 | 59 | {display && ( 60 | 101 | )} 102 |
103 |
104 | ) 105 | } 106 | 107 | export default DropdownMenu 108 | -------------------------------------------------------------------------------- /sections/ProductGrid/ProductGrid.builder.ts: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | import { Builder } from '@builder.io/react' 3 | import { Input } from '@builder.io/sdk' 4 | const LazyProductGrid = dynamic(async () => { 5 | return (await import('./ProductGrid')).ProductGrid 6 | }) 7 | 8 | const productCardFields: Input[] = [ 9 | { 10 | name: 'variant', 11 | type: 'enum', 12 | enum: ['slim', 'simple'], 13 | }, 14 | { 15 | name: 'imgWidth', 16 | type: 'number', 17 | defaultValue: 540, 18 | }, 19 | { 20 | name: 'imgHeight', 21 | type: 'number', 22 | defaultValue: 540, 23 | }, 24 | { 25 | name: 'imgPriority', 26 | type: 'boolean', 27 | advanced: true, 28 | defaultValue: true, 29 | }, 30 | { 31 | name: 'imgLoading', 32 | type: 'enum', 33 | advanced: true, 34 | defaultValue: 'lazy', 35 | enum: ['eager', 'lazy'], 36 | }, 37 | { 38 | name: 'imgLayout', 39 | type: 'enum', 40 | enum: ['fixed', 'intrinsic', 'responsive', 'fill'], 41 | advanced: true, 42 | defaultValue: 'fill', 43 | }, 44 | ] 45 | 46 | const highlightedCardFields = productCardFields.concat({ 47 | name: 'index', 48 | type: 'number', 49 | }) 50 | 51 | const gridFields: Input[] = [ 52 | { 53 | name: 'variant', 54 | type: 'enum', 55 | defaultValue: 'default', 56 | enum: ['default', 'filled'], 57 | }, 58 | { 59 | name: 'layout', 60 | type: 'enum', 61 | defaultValue: 'A', 62 | enum: ['A', 'B', 'C', 'D', 'normal'], 63 | }, 64 | ] 65 | export const productGridSchema: Input[] = [ 66 | { 67 | name: 'gridProps', 68 | advanced: true, 69 | defaultValue: { 70 | variant: 'default', 71 | layout: 'A', 72 | }, 73 | type: 'object', 74 | subFields: gridFields, 75 | }, 76 | { 77 | name: 'cardProps', 78 | defaultValue: { 79 | variant: 'simple', 80 | imgPriority: true, 81 | imgLayout: 'responsive', 82 | imgLoading: 'eager', 83 | imgWidth: 540, 84 | imgHeight: 540, 85 | layout: 'fixed', 86 | }, 87 | type: 'object', 88 | subFields: productCardFields, 89 | }, 90 | { 91 | name: 'highlightCard', 92 | advanced: true, 93 | defaultValue: { 94 | imgWidth: 1080, 95 | imgHeight: 1080, 96 | variant: 'simple', 97 | imgPriority: true, 98 | imgLayout: 'responsive', 99 | imgLoading: 'eager', 100 | layout: 'fixed', 101 | index: 1, 102 | }, 103 | type: 'object', 104 | subFields: highlightedCardFields, 105 | }, 106 | { 107 | name: 'offset', 108 | type: 'number', 109 | defaultValue: 0, 110 | }, 111 | { 112 | name: 'limit', 113 | type: 'number', 114 | defaultValue: 3, 115 | }, 116 | ] 117 | 118 | Builder.registerComponent(LazyProductGrid, { 119 | name: 'ProductGrid', 120 | description: 'Pick products free form', 121 | inputs: [ 122 | { 123 | name: 'productsList', 124 | type: 'list', 125 | subFields: [ 126 | { 127 | name: 'product', 128 | type: 'ShopifyProductHandle', 129 | }, 130 | ], 131 | }, 132 | ].concat(productGridSchema as any), 133 | }) 134 | 135 | Builder.registerComponent(LazyProductGrid, { 136 | name: 'ProductCollectionGrid', 137 | inputs: [ 138 | { 139 | name: 'collection', 140 | type: 'ShopifyCollectionHandle', 141 | }, 142 | ].concat(productGridSchema), 143 | }) 144 | -------------------------------------------------------------------------------- /components/product/ProductCard/ProductCard.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply relative max-h-full w-full box-border overflow-hidden 3 | bg-no-repeat bg-center bg-cover transition-transform 4 | ease-linear cursor-pointer; 5 | height: 100% !important; 6 | 7 | &:hover { 8 | & .squareBg:before { 9 | transform: scale(0.98); 10 | } 11 | 12 | & .productImage { 13 | transform: scale(1.2625); 14 | } 15 | 16 | & .productTitle > span, 17 | & .productPrice, 18 | & .wishlistButton { 19 | @apply bg-secondary text-secondary; 20 | } 21 | 22 | &:nth-child(6n + 1) .productTitle > span, 23 | &:nth-child(6n + 1) .productPrice, 24 | &:nth-child(6n + 1) .wishlistButton { 25 | @apply bg-violet text-white; 26 | } 27 | 28 | &:nth-child(6n + 5) .productTitle > span, 29 | &:nth-child(6n + 5) .productPrice, 30 | &:nth-child(6n + 5) .wishlistButton { 31 | @apply bg-blue text-white; 32 | } 33 | 34 | &:nth-child(6n + 3) .productTitle > span, 35 | &:nth-child(6n + 3) .productPrice, 36 | &:nth-child(6n + 3) .wishlistButton { 37 | @apply bg-pink text-white; 38 | } 39 | 40 | &:nth-child(6n + 6) .productTitle > span, 41 | &:nth-child(6n + 6) .productPrice, 42 | &:nth-child(6n + 6) .wishlistButton { 43 | @apply bg-cyan text-white; 44 | } 45 | } 46 | 47 | &:nth-child(6n + 1) .squareBg { 48 | @apply bg-violet; 49 | } 50 | 51 | &:nth-child(6n + 5) .squareBg { 52 | @apply bg-blue; 53 | } 54 | 55 | &:nth-child(6n + 3) .squareBg { 56 | @apply bg-pink; 57 | } 58 | 59 | &:nth-child(6n + 6) .squareBg { 60 | @apply bg-cyan; 61 | } 62 | } 63 | 64 | .squareBg, 65 | .productTitle > span, 66 | .productPrice, 67 | .wishlistButton { 68 | @apply transition-colors ease-in-out duration-500; 69 | } 70 | 71 | .squareBg { 72 | @apply transition-colors absolute inset-0 z-0; 73 | background-color: #212529; 74 | } 75 | 76 | .squareBg:before { 77 | @apply transition ease-in-out duration-500 bg-repeat-space w-full h-full block; 78 | background-image: url('/bg-products.svg'); 79 | content: ''; 80 | } 81 | 82 | .simple { 83 | & .squareBg { 84 | @apply bg-accents-0 !important; 85 | background-image: url('/bg-products.svg'); 86 | } 87 | 88 | & .productTitle { 89 | @apply pt-2; 90 | font-size: 1rem; 91 | 92 | & span { 93 | @apply leading-extra-loose; 94 | } 95 | } 96 | 97 | & .productPrice { 98 | @apply text-sm; 99 | } 100 | } 101 | 102 | .productTitle { 103 | @apply pt-0 max-w-full w-full leading-extra-loose; 104 | font-size: 2rem; 105 | letter-spacing: 0.4px; 106 | 107 | & span { 108 | @apply py-4 px-6 bg-primary text-primary font-bold; 109 | font-size: inherit; 110 | letter-spacing: inherit; 111 | box-decoration-break: clone; 112 | -webkit-box-decoration-break: clone; 113 | } 114 | } 115 | 116 | .productPrice { 117 | @apply py-4 px-6 bg-primary text-primary font-semibold inline-block text-sm leading-6; 118 | letter-spacing: 0.4px; 119 | } 120 | 121 | .wishlistButton { 122 | @apply w-10 h-10 flex ml-auto items-center justify-center bg-primary text-primary font-semibold text-xs leading-6 cursor-pointer; 123 | } 124 | 125 | .imageContainer { 126 | @apply flex items-center justify-center; 127 | overflow: hidden; 128 | 129 | & > div { 130 | min-width: 100%; 131 | } 132 | } 133 | 134 | .productImage { 135 | @apply transform transition-transform duration-500 object-cover scale-120; 136 | } 137 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/utils/product.ts: -------------------------------------------------------------------------------- 1 | /* 2 | prepareVariantsWithOptions() 3 | 4 | This function changes the structure of the variants to 5 | more easily get at their options. The original data 6 | structure looks like this: 7 | 8 | { 9 | "shopifyId": "Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC8zMTc4NDQ4MTAzMDE4OA==", 10 | "selectedOptions": [ 11 | { 12 | "name": "Color", 13 | "value": "Red" 14 | }, 15 | { 16 | "name": "Size", 17 | "value": "Small" 18 | } 19 | ] 20 | }, 21 | 22 | This function accepts that and outputs a data structure that looks like this: 23 | 24 | { 25 | "shopifyId": "Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC8zMTc4NDQ4MTAzMDE4OA==", 26 | "color": "Red", 27 | "size": "Small" 28 | }, 29 | */ 30 | 31 | export function prepareVariantsWithOptions( 32 | variants: any[] 33 | // variants: Readonly 34 | ) { 35 | return variants.map((variant) => { 36 | // TODO: look into types, prob need update in @types/shopify-buy 37 | // convert the options to a dictionary instead of an array 38 | const optionsDictionary = variant.selectedOptions?.reduce( 39 | (options: any, option: any) => { 40 | options[`${option?.name?.toLowerCase()}`] = option?.value 41 | return options 42 | }, 43 | {} 44 | ) 45 | 46 | // return an object with all of the variant properties + the options at the top level 47 | return { 48 | ...optionsDictionary, 49 | ...variant, 50 | } 51 | }) as any[] 52 | } 53 | 54 | export const getPrice = (price: string, currency: string) => 55 | Intl.NumberFormat(undefined, { 56 | currency, 57 | minimumFractionDigits: 2, 58 | style: 'currency', 59 | }).format(parseFloat(price ? price : '0')) 60 | 61 | /* 62 | prepareVariantsImages() 63 | 64 | This function distills the variants images into a non-redundant 65 | group that includes an option 'key' (most likely color). The 66 | datastructure coming into this function looks like this: 67 | 68 | { 69 | "shopifyId": "Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC8zMTc4NDQ4MTAzMDE4OA==", 70 | "image": image1, 71 | "color": "Red", 72 | "size": "Small" 73 | }, 74 | { 75 | "shopifyId": "Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaW1l2C8zMTc4NDQ4MTAzMDE4OA==", 76 | "image": image1, 77 | "color": "Red", 78 | "size": "Medium" 79 | }, 80 | 81 | And condenses them so that there is only one unique 82 | image per key value: 83 | 84 | { 85 | "image": image1, 86 | "color": "Red", 87 | }, 88 | */ 89 | 90 | export function prepareVariantsImages( 91 | variants: any[], 92 | // variants: Readonly, 93 | optionKey: any 94 | ): any[] { 95 | // Go through the variants and reduce them into non-redundant 96 | // images by optionKey. Output looks like this: 97 | // { 98 | // [optionKey]: image 99 | // } 100 | const imageDictionary = variants.reduce>( 101 | (images, variant) => { 102 | images[variant[optionKey]] = variant.image 103 | return images 104 | }, 105 | {} 106 | ) 107 | 108 | // prepare an array of image objects that include both the image 109 | // and the optionkey value. 110 | const images = Object.keys(imageDictionary).map((key) => { 111 | return { 112 | [optionKey]: key, 113 | src: imageDictionary[key], 114 | } 115 | }) 116 | 117 | return images 118 | } 119 | -------------------------------------------------------------------------------- /components/cart/CartItem/CartItem.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useEffect, useState } from 'react' 2 | import cn from 'classnames' 3 | import Image from 'next/image' 4 | import Link from 'next/link' 5 | import { Trash, Plus, Minus } from '@components/icons' 6 | import { getPrice } from '@lib/shopify/storefront-data-hooks/src/utils/product' 7 | import { 8 | useUpdateItemQuantity, 9 | useRemoveItemFromCart, 10 | useCheckoutUrl, 11 | } from '@lib/shopify/storefront-data-hooks' 12 | import s from './CartItem.module.css' 13 | 14 | const CartItem = ({ 15 | item, 16 | currencyCode, 17 | }: { 18 | item: /*ShopifyBuy.LineItem todo: check if updated types*/ any 19 | currencyCode: string 20 | }) => { 21 | // TODO: get real maxVariantPrice 22 | const price = getPrice( 23 | item.variant.priceV2.amount, 24 | item.variant.priceV2.currencyCode 25 | ) 26 | const updateItem = useUpdateItemQuantity() 27 | const removeItem = useRemoveItemFromCart() 28 | const [quantity, setQuantity] = useState(item.quantity) 29 | const [removing, setRemoving] = useState(false) 30 | const updateQuantity = async (quantity: number) => { 31 | await updateItem(item.variant.id, quantity) 32 | } 33 | const handleQuantity = (e: ChangeEvent) => { 34 | const val = Number(e.target.value) 35 | 36 | if (Number.isInteger(val) && val >= 0) { 37 | setQuantity(val) 38 | } 39 | } 40 | const handleBlur = () => { 41 | const val = Number(quantity) 42 | 43 | if (val !== item.quantity) { 44 | updateQuantity(val) 45 | } 46 | } 47 | const increaseQuantity = (n = 1) => { 48 | const val = Number(quantity) + n 49 | 50 | if (Number.isInteger(val) && val >= 0) { 51 | setQuantity(val) 52 | updateQuantity(val) 53 | } 54 | } 55 | const handleRemove = async () => { 56 | setRemoving(true) 57 | 58 | try { 59 | // If this action succeeds then there's no need to do `setRemoving(true)` 60 | // because the component will be removed from the view 61 | await removeItem(item.variant.id) 62 | } catch (error) { 63 | console.error(error) 64 | setRemoving(false) 65 | } 66 | } 67 | 68 | useEffect(() => { 69 | // Reset the quantity state if the item quantity changes 70 | if (item.quantity !== Number(quantity)) { 71 | setQuantity(item.quantity) 72 | } 73 | }, [item.quantity]) 74 | 75 | return ( 76 |
  • 81 |
    82 | Product Image 91 |
    92 |
    93 | {/** TODO: Replace this. No `path` found at Cart */} 94 | 95 | 96 | {item.title} 97 | 98 | 99 | 100 |
    101 | 104 | 115 | 118 |
    119 |
    120 |
    121 | {price} 122 | 125 |
    126 |
  • 127 | ) 128 | } 129 | 130 | export default CartItem 131 | -------------------------------------------------------------------------------- /pages/cart.tsx: -------------------------------------------------------------------------------- 1 | import {} from 'react' 2 | import { useCart, useCheckoutUrl } from '@lib/shopify/storefront-data-hooks' 3 | import { Layout } from '@components/common' 4 | import { Button } from '@components/ui' 5 | import { Bag, Cross, Check } from '@components/icons' 6 | import { CartItem } from '@components/cart' 7 | import { Text } from '@components/ui' 8 | import NoSSR from '@components/common/NoSSR/NoSSR' 9 | import { BuilderComponent } from '@builder.io/react' 10 | 11 | export default function Cart() { 12 | const cart = useCart() 13 | const checkoutUrl = useCheckoutUrl() 14 | const subTotal = cart?.subtotalPrice 15 | const total = ' - ' 16 | const items = cart?.lineItems ?? [] 17 | const isEmpty = items.length === 0 18 | 19 | return ( 20 |
    21 |
    22 | {isEmpty ? ( 23 |
    24 | 25 | 26 | 27 |

    28 | Your cart is empty 29 |

    30 |

    31 | Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake. 32 |

    33 |
    34 | ) : ( 35 |
    36 | My Cart 37 | Review your Order 38 |
      39 | {items.map((item: any) => ( 40 | 46 | ))} 47 |
    48 |
    49 | 50 | Before you leave, take a look at these items. We picked them 51 | just for you 52 | 53 |
    54 | {[1, 2, 3, 4, 5, 6].map((x) => ( 55 |
    56 | ))} 57 |
    58 |
    59 |
    60 | )} 61 |
    62 |
    63 |
    64 |
    65 |
      66 |
    • 67 | Subtotal 68 | {subTotal} 69 |
    • 70 |
    • 71 | Taxes 72 | Calculated at checkout 73 |
    • 74 |
    • 75 | Estimated Shipping 76 | FREE 77 |
    • 78 |
    79 |
    80 | Total 81 | {total} 82 |
    83 |
    84 |
    85 |
    86 | {isEmpty ? ( 87 | 90 | ) : checkoutUrl ? ( 91 | 94 | ) : ( 95 | <> 96 | )} 97 |
    98 |
    99 |
    100 |
    101 |
    102 | ) 103 | } 104 | 105 | Cart.Layout = Layout 106 | -------------------------------------------------------------------------------- /components/ui/context.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from 'react' 2 | import { ThemeProvider } from 'next-themes' 3 | 4 | export interface State { 5 | displaySidebar: boolean 6 | displayDropdown: boolean 7 | displayModal: boolean 8 | displayToast: boolean 9 | modalView: string 10 | toastText: string 11 | } 12 | 13 | const initialState = { 14 | displaySidebar: false, 15 | displayDropdown: false, 16 | displayModal: false, 17 | modalView: 'LOGIN_VIEW', 18 | displayToast: false, 19 | toastText: '', 20 | } 21 | 22 | type Action = 23 | | { 24 | type: 'OPEN_SIDEBAR' 25 | } 26 | | { 27 | type: 'CLOSE_SIDEBAR' 28 | } 29 | | { 30 | type: 'OPEN_TOAST' 31 | } 32 | | { 33 | type: 'CLOSE_TOAST' 34 | } 35 | | { 36 | type: 'SET_TOAST_TEXT' 37 | text: ToastText 38 | } 39 | | { 40 | type: 'OPEN_DROPDOWN' 41 | } 42 | | { 43 | type: 'CLOSE_DROPDOWN' 44 | } 45 | | { 46 | type: 'OPEN_MODAL' 47 | } 48 | | { 49 | type: 'CLOSE_MODAL' 50 | } 51 | | { 52 | type: 'SET_MODAL_VIEW' 53 | view: MODAL_VIEWS 54 | } 55 | 56 | type MODAL_VIEWS = 'SIGNUP_VIEW' | 'LOGIN_VIEW' | 'FORGOT_VIEW' 57 | type ToastText = string 58 | 59 | export const UIContext = React.createContext(initialState) 60 | 61 | UIContext.displayName = 'UIContext' 62 | 63 | function uiReducer(state: State, action: Action) { 64 | switch (action.type) { 65 | case 'OPEN_SIDEBAR': { 66 | return { 67 | ...state, 68 | displaySidebar: true, 69 | } 70 | } 71 | case 'CLOSE_SIDEBAR': { 72 | return { 73 | ...state, 74 | displaySidebar: false, 75 | } 76 | } 77 | case 'OPEN_DROPDOWN': { 78 | return { 79 | ...state, 80 | displayDropdown: true, 81 | } 82 | } 83 | case 'CLOSE_DROPDOWN': { 84 | return { 85 | ...state, 86 | displayDropdown: false, 87 | } 88 | } 89 | case 'OPEN_MODAL': { 90 | return { 91 | ...state, 92 | displayModal: true, 93 | } 94 | } 95 | case 'CLOSE_MODAL': { 96 | return { 97 | ...state, 98 | displayModal: false, 99 | } 100 | } 101 | case 'OPEN_TOAST': { 102 | return { 103 | ...state, 104 | displayToast: true, 105 | } 106 | } 107 | case 'CLOSE_TOAST': { 108 | return { 109 | ...state, 110 | displayToast: false, 111 | } 112 | } 113 | case 'SET_MODAL_VIEW': { 114 | return { 115 | ...state, 116 | modalView: action.view, 117 | } 118 | } 119 | case 'SET_TOAST_TEXT': { 120 | return { 121 | ...state, 122 | toastText: action.text, 123 | } 124 | } 125 | } 126 | } 127 | 128 | export const UIProvider: FC = (props) => { 129 | const [state, dispatch] = React.useReducer(uiReducer, initialState) 130 | 131 | const openSidebar = () => dispatch({ type: 'OPEN_SIDEBAR' }) 132 | const closeSidebar = () => dispatch({ type: 'CLOSE_SIDEBAR' }) 133 | const toggleSidebar = () => 134 | state.displaySidebar 135 | ? dispatch({ type: 'CLOSE_SIDEBAR' }) 136 | : dispatch({ type: 'OPEN_SIDEBAR' }) 137 | const closeSidebarIfPresent = () => 138 | state.displaySidebar && dispatch({ type: 'CLOSE_SIDEBAR' }) 139 | 140 | const openDropdown = () => dispatch({ type: 'OPEN_DROPDOWN' }) 141 | const closeDropdown = () => dispatch({ type: 'CLOSE_DROPDOWN' }) 142 | 143 | const openModal = () => dispatch({ type: 'OPEN_MODAL' }) 144 | const closeModal = () => dispatch({ type: 'CLOSE_MODAL' }) 145 | 146 | const openToast = () => dispatch({ type: 'OPEN_TOAST' }) 147 | const closeToast = () => dispatch({ type: 'CLOSE_TOAST' }) 148 | 149 | const setModalView = (view: MODAL_VIEWS) => 150 | dispatch({ type: 'SET_MODAL_VIEW', view }) 151 | 152 | const value = useMemo( 153 | () => ({ 154 | ...state, 155 | openSidebar, 156 | closeSidebar, 157 | toggleSidebar, 158 | closeSidebarIfPresent, 159 | openDropdown, 160 | closeDropdown, 161 | openModal, 162 | closeModal, 163 | setModalView, 164 | openToast, 165 | closeToast, 166 | }), 167 | [state] 168 | ) 169 | 170 | return 171 | } 172 | 173 | export const useUI = () => { 174 | const context = React.useContext(UIContext) 175 | if (context === undefined) { 176 | throw new Error(`useUI must be used within a UIProvider`) 177 | } 178 | return context 179 | } 180 | 181 | export const ManagedUIContext: FC = ({ children }) => ( 182 | 183 | {children} 184 | 185 | ) 186 | -------------------------------------------------------------------------------- /components/cart/CartSidebarView/CartSidebarView.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | import cn from 'classnames' 3 | import { UserNav } from '@components/common' 4 | import { Button } from '@components/ui' 5 | import { Bag, Cross } from '@components/icons' 6 | import { useUI } from '@components/ui/context' 7 | import { useCart, useCheckoutUrl } from '@lib/shopify/storefront-data-hooks' 8 | import CartItem from '../CartItem' 9 | import s from './CartSidebarView.module.css' 10 | import { BuilderComponent, builder } from '@builder.io/react' 11 | 12 | const CartSidebarView: FC = () => { 13 | const { closeSidebar } = useUI() 14 | const checkoutUrl = useCheckoutUrl() 15 | const cart = useCart() 16 | const subTotal = cart?.subtotalPrice 17 | const total = ' - ' 18 | const handleClose = () => closeSidebar() 19 | 20 | const items = cart?.lineItems ?? [] 21 | const isEmpty = items.length === 0 22 | const [cartUpsell, setCartUpsell] = useState() 23 | 24 | useEffect(() => { 25 | async function fetchContent() { 26 | const items = cart?.lineItems || [] 27 | const cartUpsellContent = await builder 28 | .get('cart-upsell-sidebar', { 29 | userAttributes: { 30 | itemInCart: items.map((item: any) => item.variant.product.handle), 31 | } as any, 32 | }) 33 | .toPromise() 34 | setCartUpsell(cartUpsellContent) 35 | } 36 | fetchContent() 37 | }, [cart?.lineItems]) 38 | 39 | return ( 40 |
    45 |
    46 |
    47 |
    48 | 55 |
    56 |
    57 | 58 |
    59 |
    60 |
    61 | 62 | {isEmpty ? ( 63 |
    64 | 65 | 66 | 67 |

    68 | Your cart is empty 69 |

    70 |

    71 | Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake. 72 |

    73 |
    74 | ) : ( 75 | <> 76 |
    77 |

    78 | My Cart 79 |

    80 |
      81 | {items.map((item: any) => ( 82 | 88 | ))} 89 |
    90 |
    91 | 92 |
    93 |
    94 |
      95 |
    • 96 | Subtotal 97 | {subTotal} 98 |
    • 99 |
    • 100 | Taxes 101 | Calculated at checkout 102 |
    • 103 |
    • 104 | Estimated Shipping 105 | FREE 106 |
    • 107 |
    108 |
    109 | Total 110 | {total} 111 |
    112 |
    113 | 117 | {checkoutUrl && ( 118 | 121 | )} 122 |
    123 | 124 | )} 125 |
    126 | ) 127 | } 128 | 129 | export default CartSidebarView 130 | -------------------------------------------------------------------------------- /sections/ProductView/ProductView.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useMemo, useEffect } from 'react' 2 | import cn from 'classnames' 3 | import Image from 'next/image' 4 | import { NextSeo } from 'next-seo' 5 | 6 | import s from './ProductView.module.css' 7 | import { useUI } from '@components/ui/context' 8 | import { Swatch, ProductSlider } from '@components/product' 9 | import { Button, Container, Text } from '@components/ui' 10 | 11 | import { useAddItemToCart } from '@lib/shopify/storefront-data-hooks' 12 | import { 13 | prepareVariantsWithOptions, 14 | getPrice, 15 | } from '@lib/shopify/storefront-data-hooks/src/utils/product' 16 | 17 | interface Props { 18 | className?: string 19 | children?: any 20 | product: ShopifyBuy.Product 21 | } 22 | 23 | const ProductView: FC = ({ product }) => { 24 | const addItem = useAddItemToCart() 25 | const colors: string[] | undefined = product.options 26 | ?.find((option) => option?.name?.toLowerCase() === 'color') 27 | ?.values?.map((op) => op.value as string) 28 | 29 | const sizes: string[] | undefined = product.options 30 | ?.find((option) => option?.name?.toLowerCase() === 'size') 31 | ?.values?.map((op) => op.value as string) 32 | 33 | const variants = useMemo( 34 | () => prepareVariantsWithOptions(product!.variants! as any), 35 | [product.variants] 36 | ) 37 | // const images = useMemo(() => prepareVariantsImages(variants, 'color'), [ 38 | // variants, 39 | // ]) 40 | 41 | const { openSidebar } = useUI() 42 | const [loading, setLoading] = useState(false) 43 | const [variant, setVariant] = useState(variants[0]) 44 | const [color, setColor] = useState(variant.color) 45 | const [size, setSize] = useState(variant.size) 46 | 47 | useEffect(() => { 48 | const newVariant = variants.find((variant) => { 49 | return variant.size === size && variant.color === color 50 | }) 51 | 52 | if (variant.id !== newVariant?.id) { 53 | setVariant(newVariant) 54 | } 55 | }, [size, color, variants, variant.id]) 56 | 57 | const addToCart = async () => { 58 | setLoading(true) 59 | try { 60 | await addItem(variant.id, 1) 61 | openSidebar() 62 | setLoading(false) 63 | } catch (err) { 64 | setLoading(false) 65 | } 66 | } 67 | 68 | return ( 69 | 70 | 87 |
    88 |
    89 |
    90 |

    {product.title}

    91 |
    92 | {getPrice(variant.priceV2.amount, variant.priceV2.currencyCode)} 93 |
    94 |
    95 | 96 |
    97 | 98 | {product.images.map((image, i) => ( 99 |
    100 | {product.title} 109 |
    110 | ))} 111 |
    112 |
    113 |
    114 | 115 |
    116 |
    117 | {colors && colors?.length > 0 && ( 118 |
    119 |

    Color:

    120 |
    121 | {colors.map((option) => ( 122 | { 129 | setColor(option) 130 | }} 131 | /> 132 | ))} 133 |
    134 |
    135 | )} 136 | {sizes && sizes.length > 0 && ( 137 |
    138 |

    Size

    139 |
    140 | {sizes.map((option) => ( 141 | { 147 | setSize(option) 148 | }} 149 | /> 150 | ))} 151 |
    152 |
    153 | )} 154 | 155 |
    156 | 157 |
    158 |
    159 |
    160 | 170 |
    171 |
    172 |
    173 |
    174 | ) 175 | } 176 | 177 | export default ProductView 178 | -------------------------------------------------------------------------------- /lib/shopify/storefront-data-hooks/src/api/operations-builder.ts: -------------------------------------------------------------------------------- 1 | import { BuilderContent } from '@builder.io/react' 2 | import * as qs from 'qs' 3 | 4 | export interface BuillderConfig { 5 | apiKey: string 6 | productsModel: string 7 | collectionsModel: string 8 | } 9 | 10 | export interface CollectionProductsQuery { 11 | handle: string 12 | limit?: number 13 | cursor?: string 14 | apiKey: string 15 | } 16 | 17 | export async function getAllProducts( 18 | config: BuillderConfig, 19 | limit = 100, 20 | offset = 0 21 | ) { 22 | const productsContent: BuilderContent[] = ( 23 | await fetch( 24 | `https://cdn.builder.io/api/v2/content/${config.productsModel}?apiKey=${config.apiKey}&limit=${limit}&offset=${offset}` 25 | ).then((res) => res.json()) 26 | ).results 27 | 28 | return productsContent.map((pr) => pr.data) 29 | } 30 | 31 | export async function searchProducts( 32 | config: BuillderConfig, 33 | searchString: string, 34 | limit = 100, 35 | offset = 0 36 | ) { 37 | const query = qs.stringify( 38 | { 39 | fields: ['data'], 40 | limit, 41 | offset, 42 | apiKey: config.apiKey, 43 | }, 44 | { allowDots: true } 45 | ) 46 | 47 | const productsContent: BuilderContent[] = ( 48 | await fetch( 49 | `https://cdn.builder.io/api/v2/content/${ 50 | config.productsModel 51 | }?${query}&query.$or=${JSON.stringify([ 52 | { 53 | 'data.description': { $regex: `${searchString}`, $options: 'i' }, 54 | }, 55 | { 56 | 'data.title': { $regex: `${searchString}`, $options: 'i' }, 57 | }, 58 | ])}` 59 | ).then((res) => res.json()) 60 | ).results 61 | return productsContent?.map((product) => product.data) || [] 62 | } 63 | 64 | export async function getAllProductPaths( 65 | config: BuillderConfig, 66 | limit?: number 67 | ): Promise { 68 | const products: any[] = await getAllProducts(config, limit) 69 | return products?.map((entry) => entry.handle) || [] 70 | } 71 | 72 | export async function getProduct( 73 | config: BuillderConfig, 74 | options: { id?: string; handle?: string; withContent?: boolean } 75 | ) { 76 | if (Boolean(options.id) === Boolean(options.handle)) { 77 | throw new Error('Either a handle or id is required') 78 | } 79 | const query = qs.stringify({ 80 | limit: 1, 81 | apiKey: config.apiKey, 82 | query: { 83 | data: options.id 84 | ? { 85 | id: { $eq: options.id }, 86 | } 87 | : { 88 | handle: { $eq: options.handle }, 89 | }, 90 | }, 91 | }) 92 | 93 | const productsContent: BuilderContent[] = ( 94 | await fetch( 95 | `https://cdn.builder.io/api/v2/content/${config.productsModel}?${query}` 96 | ).then((res) => res.json()) 97 | ).results 98 | 99 | if (options.withContent) { 100 | return productsContent[0] 101 | } 102 | return productsContent[0]?.data 103 | } 104 | 105 | /** 106 | * Collections 107 | */ 108 | 109 | export async function getAllCollections( 110 | config: BuillderConfig, 111 | limit = 20, 112 | offset = 0, 113 | fields?: string 114 | ) { 115 | const query = qs.stringify( 116 | { 117 | fields: fields || 'data', 118 | limit, 119 | offset, 120 | apiKey: config.apiKey, 121 | }, 122 | { allowDots: true } 123 | ) 124 | 125 | const collectionsContent: BuilderContent[] = ( 126 | await fetch( 127 | `https://cdn.builder.io/api/v2/content/${config.collectionsModel}?${query}` 128 | ).then((res) => res.json()) 129 | ).results 130 | 131 | return collectionsContent?.map((entry) => entry.data) || [] 132 | } 133 | 134 | export async function searchCollections( 135 | config: BuillderConfig, 136 | searchString: string, 137 | limit = 100, 138 | offset = 0 139 | ) { 140 | const query = qs.stringify( 141 | { 142 | fields: ['data'], 143 | limit, 144 | offset, 145 | apiKey: config.apiKey, 146 | }, 147 | { allowDots: true } 148 | ) 149 | 150 | const collectionsContent: BuilderContent[] = ( 151 | await fetch( 152 | `https://cdn.builder.io/api/v2/content/${ 153 | config.collectionsModel 154 | }?${query}&query.$or=${JSON.stringify([ 155 | { 156 | 'data.description': { $regex: `${searchString}` }, 157 | }, 158 | { 159 | 'data.title': { $regex: `${searchString}` }, 160 | }, 161 | ])}` 162 | ).then((res) => res.json()) 163 | ).results 164 | return collectionsContent?.map((entry) => entry.data) || [] 165 | } 166 | 167 | export async function getAllCollectionPaths( 168 | config: BuillderConfig, 169 | limit?: number 170 | ): Promise { 171 | const collections: any[] = await getAllCollections(config, limit) 172 | return collections?.map((entry) => entry.handle) || [] 173 | } 174 | 175 | export async function getCollection( 176 | config: BuillderConfig, 177 | options: { 178 | id?: string 179 | handle?: string 180 | productsQuery?: Omit 181 | } 182 | ) { 183 | if (Boolean(options.id) === Boolean(options.handle)) { 184 | throw new Error('Either a handle or id is required') 185 | } 186 | const query = qs.stringify({ 187 | limit: 1, 188 | apiKey: config.apiKey, 189 | query: { 190 | data: options.id 191 | ? { 192 | id: { $eq: options.id }, 193 | } 194 | : { 195 | handle: { $eq: options.handle }, 196 | }, 197 | }, 198 | }) 199 | 200 | const collectionsContent: BuilderContent[] = ( 201 | await fetch( 202 | `https://cdn.builder.io/api/v2/content/${config.collectionsModel}?${query}` 203 | ).then((res) => res.json()) 204 | ).results 205 | 206 | const collection = collectionsContent[0]?.data 207 | const productsQuery = { 208 | limit: 20, 209 | handle: collection.handle, 210 | ...options.productsQuery, 211 | apiKey: config.apiKey, 212 | } 213 | const { products, nextPageCursor, hasNextPage } = await getCollectionProducts( 214 | productsQuery 215 | ) 216 | 217 | return { 218 | ...collection, 219 | products, 220 | nextPageCursor, 221 | hasNextPage, 222 | } 223 | } 224 | 225 | export const getCollectionProducts = ( 226 | productsQuery: CollectionProductsQuery 227 | ): Promise<{ 228 | nextPageCursor: string 229 | products: any[] 230 | hasNextPage: boolean 231 | }> => { 232 | const search = qs.stringify(productsQuery) 233 | return fetch( 234 | `https://cdn.builder.io/api/v1/shopify-sync/collection-products?${search}` 235 | ).then((res) => res.json()) 236 | } 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Next.js + Shopify + Builder.io starter kit 3 | 4 | The ultimate starter for headless Shopify stores. 5 | 6 | Demo live at: [headless.builders](https://headless.builders/) 7 | 8 | ## Goals and Features 9 | 10 | - Ultra high performance 11 | - SEO optimized 12 | - Themable 13 | - Personalizable (interntionalization, a/b testing, etc) 14 | - Builder.io Visual CMS integrated 15 | - Connect to Shopify data through Builder's high speed data layer 16 | 17 | 18 | **Table of Contents** 19 | 20 | 21 | - [Getting Started](#getting-started) 22 | - [1: Create an account for Builder.io](#1-create-an-account-for-builderio) 23 | - [2: Your Builder.io private key](#2-your-builderio-private-key) 24 | - [3: Clone this repository and initialize a Builder.io space](#3-clone-this-repository-and-initialize-a-builderio-space) 25 | - [4. Shopify private app](#4-shopify-private-app) 26 | - [5. Connecting Builder to Shopify](#5-connecting-builder-to-shopify) 27 | - [6. Configure the project to talk to Shopify](#6-configure-the-project-to-talk-to-shopify) 28 | - [7. Up and Running!](#7-up-and-running) 29 | - [Deploy](#deploy) 30 | 31 | 32 | 33 | 34 | ## Getting Started 35 | 36 | **Pre-requisites** 37 | 38 | This guide will assume that you have the following software installed: 39 | 40 | - nodejs 41 | - npm or yarn 42 | - git 43 | 44 | You should already have a [Shopify](https://www.shopify.com/online-store) account and store created before starting as well. 45 | 46 | **Introduction** 47 | 48 | This starter kit is everything you need to get your own self hosted 49 | Next.js project powered by Builder.io for content and Shopify as an 50 | e-commerce back office. 51 | 52 | After following this guide you will have 53 | 54 | - A Next.js app, ready to deploy to a hosting provider of your choice 55 | - Pulling live collection and product information from Shopify 56 | - Powered by the Builder.io visual CMS 57 | 58 | ### 1: Create an account for Builder.io 59 | 60 | Before we start, head over to Builder.io and [create an account](https://builder.io/signup). 61 | 62 | ### 2: Your Builder.io private key 63 | 64 | Head over to your [organization settings page](https://builder.io/account/organization?root=true) and create a 65 | private key, copy the key for the next step. 66 | 67 | - Visit the [organization settings page](https://builder.io/account/organization?root=true), or select 68 | an organization from the list 69 | 70 | ![organizations drop down list](./docs/images/builder-io-organizations.png) 71 | 72 | - Click "Account" from the left hand sidebar 73 | - Click the edit icon for the "Private keys" row 74 | - Copy the value of the auto-generated key, or create a new one with a name that's meaningful to you 75 | 76 | 77 | ![Example of how to get your private key](./docs/images/private-key-flow.png) 78 | 79 | ### 3: Clone this repository and initialize a Builder.io space 80 | 81 | Next, we'll create a copy of the starter project, and create a new 82 | [space](https://www.builder.io/c/docs/spaces) for it's content to live 83 | in. 84 | 85 | In the example below, replace `` with the key you copied 86 | in the previous step, and change `` to something that's 87 | meaningful to you -- don't worry, you can change it later! 88 | 89 | ``` 90 | git clone https://github.com/BuilderIO/nextjs-shopify.git 91 | cd nextjs-shopify 92 | 93 | npm install --global @builder.io/cli 94 | 95 | builder create --key "" --name "" --debug 96 | ``` 97 | 98 | If this was a success you should be greeted with a message that 99 | includes a public API key for your newly minted Builder.io space. 100 | 101 | *Note: This command will also publish some starter builder.io cms 102 | content from the ./builder directory to your new space when it's 103 | created.* 104 | 105 | ``` bash 106 | ____ _ _ _ _ _ _ 107 | | __ ) _ _ (_) | | __| | ___ _ __ (_) ___ ___ | | (_) 108 | | _ \ | | | | | | | | / _` | / _ \ | '__| | | / _ \ / __| | | | | 109 | | |_) | | |_| | | | | | | (_| | | __/ | | _ | | | (_) | | (__ | | | | 110 | |____/ \__,_| |_| |_| \__,_| \___| |_| (_) |_| \___/ \___| |_| |_| 111 | 112 | |████████████████████████████████████████| shopify-product | 0/0 113 | |████████████████████████████████████████| product-page: writing generic-template.json | 1/1 114 | |████████████████████████████████████████| shopify-collection | 0/0 115 | |████████████████████████████████████████| collection-page: writing generic-collection.json | 1/1 116 | |████████████████████████████████████████| page: writing homepage.json | 2/2 117 | 118 | 119 | Your new space "next.js shopify starter" public API Key: 012345abcdef0123456789abcdef0123 120 | ``` 121 | 122 | Copy the public API key ("012345abcdef0123456789abcdef0123" in the example above) for the next step. 123 | 124 | This starter project uses dotenv files to configure environment variables. 125 | Open the files [.env.development](./.env.development) and 126 | [.env.production](./.env.production) in your favorite text editor, and 127 | set the value of `BUILDER_PUBLIC_KEY` to the public key you just copied. 128 | You can ignore the other variables for now, we'll set them later. 129 | 130 | ```diff 131 | + BUILDER_PUBLIC_KEY=012345abcdef0123456789abcdef0123 132 | - BUILDER_PUBLIC_KEY= 133 | SHOPIFY_STOREFRONT_API_TOKEN= 134 | SHOPIFY_STORE_DOMAIN= 135 | ``` 136 | 137 | ### 4. Shopify private app 138 | 139 | Create a [private app](https://help.shopify.com/en/manual/apps/private-apps) for your Shopify store. 140 | 141 | ![Example of how to create find private app section](./docs/images/shopify-private-app-flow.png) 142 | 143 | When creating the private app you'll have to set a number of permissions so that builder can retrieve your Shopify inventory. 144 | 145 | **Storefront API** 146 | 147 | - Grant all permissions 148 | 149 | **Admin API** 150 | 151 | - Enable read access to Products (read_products scope) 152 | 153 | You should see something like the image below: 154 | 155 | ![Example of Shopify private app permissions](./docs/images/shopify-permissions.png) 156 | 157 | ### 5. Connecting Builder to Shopify 158 | 159 | Access your newly created space by selecting it from the [list of spaces](https://builder.io/spaces?root=true) 160 | in your organization. 161 | 162 | You should be greeted by a modal asking for various Shopify API keys, this will allow Builder.io to import your products and register webhooks so that it's updated when your Shopify products and collection change. 163 | 164 | ![Example of where the Shopify API keys map to Builder settings](./docs/images/shopify-api-key-mapping.png) 165 | 166 | Fill in the required keys and press "Connect your store"! 167 | 168 | ### 6. Configure the project to talk to Shopify 169 | 170 | Open up [.env.development](./.env.development) and [.env.production](./.env.production) again, 171 | but this time set the other two Shopify keys. 172 | 173 | ```diff 174 | BUILDER_PUBLIC_KEY=012345abcdef0123456789abcdef0123 175 | + SHOPIFY_STOREFRONT_API_TOKEN=c11b4053408085753bd76a45806f80dd 176 | - SHOPIFY_STOREFRONT_API_TOKEN= 177 | + SHOPIFY_STORE_DOMAIN=dylanbuilder.myshopify.com 178 | - SHOPIFY_STORE_DOMAIN= 179 | ``` 180 | 181 | ### 7. Up and Running! 182 | 183 | The hard part is over, all you have to do is start up the project now. 184 | 185 | ```bash 186 | npm install 187 | npm run dev 188 | ``` 189 | 190 | This will start a server at `http://localhost:3000`. 191 | 192 | Go to your [new space settings](https://builder.io/account/space) and change the site url to your localhost `http://localhost:3000` for site editing. 193 | 194 | 195 | 196 | 197 | 198 | 199 | ### 8. Start building 200 | Now that we have everything setup, start building and publishing pages on builder.io, for a demo on building something similar to the [demo homepage](https://headless.builders), follow the steps in this [short video](https://www.loom.com/share/9b947acbbf714ee3ac6c319c130cdb85) 201 | 202 | ## Deploy 203 | 204 | You can deploy this code anywhere you like - you can find many deployment options for Next.js [here](https://nextjs.org/docs/deployment). For this project, we particularly recommend Vercel - it's as easy as signing up for Vercel and choosing your repo to deploy. See more info [here](https://nextjs.org/docs/deployment), or use the one click deploy option below: 205 | 206 | 207 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fbuilderio%2Fnextjs-shopify) 208 | 209 | 210 | -------------------------------------------------------------------------------- /public/flag-es-ar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | --------------------------------------------------------------------------------