├── index.d.ts ├── .nvmrc ├── .github ├── .kodiak.toml └── PULL_REQUEST_TEMPLATE.md ├── public ├── og.png ├── favicon.ico ├── icon-192.png ├── icon-512.png ├── apple-touch-icon.png ├── tees │ ├── tee-basement.png │ ├── tee-next-pizza.png │ ├── tee-react-miami.png │ └── tee-basement-studio.png ├── logos │ ├── vercel.svg │ ├── tailwind.svg │ ├── next.svg │ ├── ts.svg │ ├── shopify.svg │ ├── basement.svg │ ├── header-logo.svg │ └── next-pizza.svg ├── manifest.webmanifest ├── primitives │ ├── star-pink.svg │ ├── star-teal.svg │ ├── grid-bg.svg │ ├── ellipse.svg │ ├── pizza.svg │ ├── cart-hr.svg │ ├── hr.svg │ └── cap.svg ├── favicon.svg └── favicon-dark.svg ├── src ├── shopify │ ├── storefront-hooks │ │ ├── cart-cookie-key.ts │ │ ├── index.tsx │ │ └── use-product-form-helper.ts │ ├── utils.ts │ └── sdk-gen │ │ ├── sdk.ts │ │ ├── config.js │ │ ├── generated │ │ ├── runtime │ │ │ ├── index.ts │ │ │ ├── error.ts │ │ │ ├── types.ts │ │ │ ├── createClient.ts │ │ │ ├── fetcher.ts │ │ │ ├── typeSelection.ts │ │ │ ├── linkTypeMap.ts │ │ │ ├── generateGraphqlOperation.ts │ │ │ └── batcher.ts │ │ └── index.ts │ │ └── fragments.ts ├── app │ ├── opengraph-image.png │ ├── fonts │ │ ├── MDNichrome-Bold.woff2 │ │ ├── MDNichrome-Black.woff2 │ │ └── MDNichrome-Regular.woff2 │ ├── components │ │ ├── logo.tsx │ │ ├── container.tsx │ │ ├── header.tsx │ │ ├── aspect-box.tsx │ │ ├── cart │ │ │ ├── cart-header.tsx │ │ │ ├── cart.module.scss │ │ │ ├── cart-footer.tsx │ │ │ ├── index.tsx │ │ │ └── cart-product.tsx │ │ ├── size-btn.tsx │ │ ├── primitives │ │ │ └── portal.tsx │ │ ├── grid │ │ │ └── index.tsx │ │ └── product.tsx │ ├── page.tsx │ ├── sections │ │ ├── hero.tsx │ │ ├── shop.tsx │ │ ├── stack.tsx │ │ └── footer.tsx │ ├── providers.tsx │ ├── css │ │ ├── helpers.scss │ │ ├── global.scss │ │ └── reset.css │ └── layout.tsx ├── hooks │ ├── use-isomorphic-layout-effect.ts │ ├── use-has-rendered.ts │ ├── use-image-fade-in.ts │ ├── use-is-hydrated.ts │ ├── use-toggle-state.ts │ ├── use-mousetrap.ts │ ├── use-elements-observer.ts │ ├── use-intersection-observer.ts │ ├── use-media.ts │ └── use-device-detect.ts └── lib │ ├── utils │ ├── router.ts │ ├── image.ts │ └── index.ts │ └── constants.ts ├── postcss.config.js ├── .env.example ├── next-sitemap.js ├── .vscode ├── extensions.json └── settings.json ├── next-env.d.ts ├── .gitignore ├── next.config.js ├── tsconfig.json ├── tailwind.config.js ├── README.md └── package.json /index.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.x 2 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/og.png -------------------------------------------------------------------------------- /src/shopify/storefront-hooks/cart-cookie-key.ts: -------------------------------------------------------------------------------- 1 | export const cartCookieKey = 'nextjs-pizza-cart-id' 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/icon-192.png -------------------------------------------------------------------------------- /public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/icon-512.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/tees/tee-basement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/tees/tee-basement.png -------------------------------------------------------------------------------- /src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/src/app/opengraph-image.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SITE_URL="http://localhost:3000" 2 | NEXT_PUBLIC_STOREFRONT_ACCESS_TOKEN="ec7a93b2117db2883fdda0af0c50cd2b" -------------------------------------------------------------------------------- /public/tees/tee-next-pizza.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/tees/tee-next-pizza.png -------------------------------------------------------------------------------- /public/tees/tee-react-miami.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/tees/tee-react-miami.png -------------------------------------------------------------------------------- /src/shopify/utils.ts: -------------------------------------------------------------------------------- 1 | export const getShopifyGid = (type: string, id: string) => { 2 | return `gid://shopify/${type}/${id}` 3 | } 4 | -------------------------------------------------------------------------------- /public/tees/tee-basement-studio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/public/tees/tee-basement-studio.png -------------------------------------------------------------------------------- /src/app/fonts/MDNichrome-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/src/app/fonts/MDNichrome-Bold.woff2 -------------------------------------------------------------------------------- /src/app/fonts/MDNichrome-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/src/app/fonts/MDNichrome-Black.woff2 -------------------------------------------------------------------------------- /src/app/fonts/MDNichrome-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/workshop-nextjs-pizza/HEAD/src/app/fonts/MDNichrome-Regular.woff2 -------------------------------------------------------------------------------- /next-sitemap.js: -------------------------------------------------------------------------------- 1 | const siteURL = new URL(process.env.NEXT_PUBLIC_SITE_URL) 2 | 3 | module.exports = { 4 | siteUrl: siteURL.href, 5 | generateRobotsTxt: true, 6 | exclude: [] 7 | } 8 | -------------------------------------------------------------------------------- /src/shopify/sdk-gen/sdk.ts: -------------------------------------------------------------------------------- 1 | import config from './config' 2 | import { createSdk } from './generated' 3 | 4 | export const storefront = createSdk({ ...config, next: { revalidate: 1 } }) 5 | -------------------------------------------------------------------------------- /public/logos/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "stylelint.vscode-stylelint", 6 | "phoenisx.cssvar" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" }, 4 | { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" } 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/hooks/use-isomorphic-layout-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react' 2 | 3 | import { isClient } from '~/lib/constants' 4 | 5 | export const useIsomorphicLayoutEffect = isClient ? useLayoutEffect : useEffect 6 | -------------------------------------------------------------------------------- /src/hooks/use-has-rendered.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const useHasRendered = () => { 4 | const [hasRendered, setHasRendered] = React.useState(false) 5 | 6 | React.useEffect(() => { 7 | setHasRendered(true) 8 | }, []) 9 | 10 | return hasRendered 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/use-image-fade-in.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | export const useImageFadeIn = () => { 4 | const [loaded, setLoaded] = useState(false) 5 | return { 6 | style: { opacity: loaded ? undefined : 0 }, 7 | onLoadingComplete: () => { 8 | setLoaded(true) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/shopify/sdk-gen/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("@bsmnt/sdk-gen").Config} 3 | */ 4 | module.exports = { 5 | endpoint: 'https://next-js-pizza-by-bsmnt.myshopify.com/api/2023-01/graphql', 6 | headers: { 7 | 'x-shopify-storefront-access-token': 8 | process.env.NEXT_PUBLIC_STOREFRONT_ACCESS_TOKEN 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/use-is-hydrated.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | let globalIsHydrated = false 4 | 5 | export const useIsHydrated = () => { 6 | const [isHydrated, setIsHydrated] = React.useState(globalIsHydrated) 7 | 8 | React.useEffect(() => { 9 | setIsHydrated(true) 10 | globalIsHydrated = true 11 | }, []) 12 | 13 | return isHydrated 14 | } 15 | -------------------------------------------------------------------------------- /public/primitives/star-pink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/primitives/star-teal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/utils/router.ts: -------------------------------------------------------------------------------- 1 | import { siteURL } from '~/lib/constants' 2 | 3 | export type QueryParams = { [key: string]: string | null } 4 | 5 | /** 6 | * Checks is link is external or not. 7 | */ 8 | export const checkIsExternal = (href: string) => { 9 | if (!href.startsWith('http://') && !href.startsWith('https://')) return false 10 | const url = new URL(href) 11 | return url.hostname !== siteURL.hostname 12 | } 13 | -------------------------------------------------------------------------------- /src/app/components/logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | 4 | export const Logo = () => { 5 | return ( 6 | 7 | logo nextjs & pizza 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/app/components/container.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import * as React from 'react' 3 | 4 | export const Container = React.forwardRef< 5 | HTMLDivElement, 6 | JSX.IntrinsicElements['div'] & { 7 | as?: 'div' | 'section' 8 | } 9 | >(({ className, as = 'div', ...props }, ref) => { 10 | const Element: React.ElementType = as 11 | return 12 | }) 13 | 14 | export type ContainerProps = React.ComponentProps 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | > include a short description for this PR, and/or link any related issues that this PR addresses **← and then delete me.** 2 | 3 | ## Checklist 4 | 5 | - [ ] Related issues linked. 6 | - [ ] My code follows the style guidelines of this project. 7 | - [ ] I have performed a self-review of my own code and it's looking great. 8 | - [ ] I have tested my changes in desktop. 9 | - [ ] I have tested my changes in a mobile device. 10 | - [ ] I have tested my changes in dark and light mode (if applicable). 11 | -------------------------------------------------------------------------------- /src/app/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Cart } from './cart' 2 | import { Logo } from './logo' 3 | 4 | export const Header = async () => { 5 | return ( 6 |
7 |
8 | 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/use-toggle-state.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const useToggleState = (initialState = false) => { 4 | const [isOn, setIsOn] = React.useState(initialState) 5 | 6 | const handleOn = React.useCallback(() => { 7 | setIsOn(true) 8 | }, []) 9 | 10 | const handleOff = React.useCallback(() => { 11 | setIsOn(false) 12 | }, []) 13 | 14 | const handleToggle = React.useCallback(() => { 15 | setIsOn((p) => !p) 16 | }, []) 17 | 18 | return { isOn, handleToggle, handleOn, handleOff } 19 | } 20 | 21 | export type ToggleState = ReturnType 22 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from './components/container' 2 | import { Hero } from './sections/hero' 3 | import { Shop } from './sections/shop' 4 | import { Stack } from './sections/stack' 5 | 6 | // can't use edge runtime yet because of this issue: https://github.com/vercel/next.js/issues/43690 7 | // export const runtime = 'edge' 8 | 9 | const Page = () => { 10 | return ( 11 | <> 12 | 13 | 14 | {/* @ts-expect-error rsc */} 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default Page 23 | -------------------------------------------------------------------------------- /.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 | */robots.txt 20 | */sitemap.xml 21 | */sitemap-0.xml 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env 32 | .env.local 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | tsconfig.tsbuildinfo 40 | -------------------------------------------------------------------------------- /src/shopify/sdk-gen/generated/runtime/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | export { createClient } from './createClient' 5 | export type { ClientOptions } from './createClient' 6 | export type { FieldsSelection } from './typeSelection' 7 | export { generateGraphqlOperation } from './generateGraphqlOperation' 8 | export type { GraphqlOperation } from './generateGraphqlOperation' 9 | export { linkTypeMap } from './linkTypeMap' 10 | // export { Observable } from 'zen-observable-ts' 11 | export { createFetcher } from './fetcher' 12 | export { GenqlError } from './error' 13 | export const everything = { 14 | __scalar: true, 15 | } 16 | -------------------------------------------------------------------------------- /src/app/sections/hero.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | import jbSvg from '~/public/jb.svg' 4 | 5 | export const Hero = () => { 6 | return ( 7 |
8 |

9 | 10 | SPICY SHOP FT. 11 | 12 | 13 | julian benegas 19 |

20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/use-mousetrap.ts: -------------------------------------------------------------------------------- 1 | import mousetrap from 'mousetrap' 2 | import * as React from 'react' 3 | 4 | type MousetrapParameters = Parameters 5 | 6 | export type Traps = { 7 | keys: MousetrapParameters['0'] 8 | callback: MousetrapParameters['1'] 9 | action?: MousetrapParameters['2'] 10 | }[] 11 | 12 | export const useMousetrap = (traps: Traps, bind = true) => { 13 | React.useEffect(() => { 14 | if (bind) { 15 | traps.forEach(({ keys, callback, action }) => 16 | mousetrap.bind(keys, callback, action) 17 | ) 18 | return () => { 19 | traps.forEach(({ keys }) => mousetrap.unbind(keys)) 20 | } 21 | } 22 | }, [traps, bind]) 23 | } 24 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer') 2 | const withTM = require('next-transpile-modules') 3 | 4 | /** 5 | * @type {import('next').NextConfig} 6 | */ 7 | const config = { 8 | reactStrictMode: false, 9 | swcMinify: true, 10 | images: { 11 | formats: ['image/avif', 'image/webp'], 12 | domains: ['cdn.shopify.com'] 13 | }, 14 | experimental: { appDir: true } 15 | } 16 | 17 | module.exports = (_phase, { defaultConfig: _ }) => { 18 | const plugins = [ 19 | withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' }), 20 | withTM([]) // add modules you want to transpile here 21 | ] 22 | return plugins.reduce((acc, plugin) => plugin(acc), { ...config }) 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/use-elements-observer.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const useElementsObserver = (selector: string) => { 4 | React.useEffect(() => { 5 | const observer = new IntersectionObserver((entries) => { 6 | entries.forEach((entry) => { 7 | if (entry.isIntersecting) { 8 | entry.target.classList.remove('invisible') 9 | } else { 10 | entry.target.classList.add('invisible') 11 | } 12 | }) 13 | }) 14 | 15 | const elementsArray = Array.from(document.querySelectorAll(selector)) 16 | elementsArray.forEach((element) => { 17 | observer.observe(element) 18 | }) 19 | 20 | return () => { 21 | observer.disconnect() 22 | } 23 | }, [selector]) 24 | } 25 | -------------------------------------------------------------------------------- /src/app/components/aspect-box.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const AspectBox = React.forwardRef< 4 | HTMLDivElement, 5 | { ratio: number } & JSX.IntrinsicElements['div'] 6 | >(({ ratio, children, style, ...rest }, ref) => { 7 | return ( 8 |
16 |
27 | {children} 28 |
29 |
30 | ) 31 | }) 32 | -------------------------------------------------------------------------------- /src/app/components/cart/cart-header.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | export const CartHeader = ({ 4 | closeTrigger 5 | }: { 6 | closeTrigger?: React.ReactNode 7 | }) => { 8 | return ( 9 |
10 |
11 |

YOUR CART

12 | {closeTrigger} 13 |
14 | 15 | 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true, 6 | "source.fixAll.stylelint": true 7 | }, 8 | "stylelint.validate": [ 9 | "css", 10 | "scss" 11 | ], 12 | "css.validate": false, 13 | "less.validate": false, 14 | "scss.validate": false, 15 | "[css]": { 16 | "editor.formatOnSave": false 17 | }, 18 | "[scss]": { 19 | "editor.formatOnSave": false 20 | }, 21 | "[less]": { 22 | "editor.formatOnSave": false 23 | }, 24 | "typescript.tsdk": "node_modules\\typescript\\lib", 25 | "typescript.enablePromptUseWorkspaceTsdk": true, 26 | "prettier.useEditorConfig": true, 27 | "dotenv.enableAutocloaking": false 28 | } -------------------------------------------------------------------------------- /src/shopify/sdk-gen/generated/runtime/error.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | export class GenqlError extends Error { 5 | errors: Array = [] 6 | /** 7 | * Partial data returned by the server 8 | */ 9 | data?: any 10 | constructor(errors: any[], data: any) { 11 | let message = Array.isArray(errors) 12 | ? errors.map((x) => x?.message || '').join('\n') 13 | : '' 14 | if (!message) { 15 | message = 'GraphQL error' 16 | } 17 | super(message) 18 | this.errors = errors 19 | this.data = data 20 | } 21 | } 22 | 23 | interface GraphqlError { 24 | message: string 25 | locations?: Array<{ 26 | line: number 27 | column: number 28 | }> 29 | path?: string[] 30 | extensions?: Record 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/size-btn.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | export const SizeButton = ({ 4 | size, 5 | selected, 6 | onClick, 7 | disabled 8 | }: { 9 | size: string 10 | selected?: boolean 11 | onClick?: () => void 12 | disabled?: boolean 13 | }) => { 14 | return ( 15 | <> 16 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/use-intersection-observer.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const useIntersectionObserver = ( 4 | options: IntersectionObserverInit & { triggerOnce?: boolean } 5 | ) => { 6 | const ref = React.useRef(null) 7 | const [inView, setInView] = React.useState(false) 8 | 9 | React.useEffect(() => { 10 | const elementToObserve = ref.current 11 | if (!elementToObserve) return 12 | const handleObserve: IntersectionObserverCallback = ([element]) => { 13 | if (element) { 14 | setInView((p) => { 15 | // trigger once? 16 | if (options && options.triggerOnce && p === true) return p 17 | else return element.isIntersecting 18 | }) 19 | } 20 | } 21 | 22 | const observer = new IntersectionObserver(handleObserve, options) 23 | 24 | observer.observe(elementToObserve) 25 | 26 | return () => { 27 | observer.disconnect() 28 | } 29 | }, [options]) 30 | 31 | return [ref, inView] as const 32 | } 33 | -------------------------------------------------------------------------------- /public/logos/tailwind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "alwaysStrict": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "jsx": "preserve", 9 | "lib": [ 10 | "dom", 11 | "es2017" 12 | ], 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "noEmit": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "skipLibCheck": true, 21 | "strict": true, 22 | "target": "esnext", 23 | "noUncheckedIndexedAccess": true, 24 | "baseUrl": "./src", 25 | "paths": { 26 | "~/public/*": [ 27 | "../public/*" 28 | ], 29 | "~/*": [ 30 | "./*" 31 | ] 32 | }, 33 | "incremental": true, 34 | "plugins": [ 35 | { 36 | "name": "next" 37 | } 38 | ] 39 | }, 40 | "exclude": [ 41 | "node_modules" 42 | ], 43 | "include": [ 44 | "**/*.ts", 45 | "**/*.tsx", 46 | ".next/types/**/*.ts" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/app/sections/shop.tsx: -------------------------------------------------------------------------------- 1 | import { productFragment } from '~/shopify/sdk-gen/fragments' 2 | import { storefront } from '~/shopify/sdk-gen/sdk' 3 | import { getShopifyGid } from '~/shopify/utils' 4 | 5 | import Grid from '../components/grid' 6 | import { Product } from '../components/product' 7 | 8 | export const Shop = async () => { 9 | const { collection } = await storefront.query({ 10 | collection: { 11 | __args: { id: getShopifyGid('Collection', '442672120084') }, 12 | products: { 13 | __args: { first: 4, sortKey: 'CREATED' }, 14 | nodes: productFragment 15 | } 16 | } 17 | }) 18 | 19 | return ( 20 |
21 |
22 |
23 | {collection?.products.nodes.map((product) => { 24 | return 25 | })} 26 |
27 | 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect } from 'react' 4 | 5 | import { basementLog, isClient, isProd } from '~/lib/constants' 6 | import { QueryClientProvider } from '~/shopify/storefront-hooks' 7 | 8 | if (isProd && isClient) { 9 | // eslint-disable-next-line no-console 10 | console.log(basementLog) 11 | } 12 | 13 | export const Providers = ({ children }: { children?: React.ReactNode }) => { 14 | // User is tabbing hook 15 | useEffect(() => { 16 | function handleKeyDown(event: KeyboardEvent) { 17 | if (event.code === `Tab`) { 18 | document.body.classList.add('user-is-tabbing') 19 | } 20 | } 21 | 22 | function handleMouseDown() { 23 | document.body.classList.remove('user-is-tabbing') 24 | } 25 | 26 | window.addEventListener('keydown', handleKeyDown) 27 | window.addEventListener('mousedown', handleMouseDown) 28 | return () => { 29 | window.removeEventListener('keydown', handleKeyDown) 30 | window.removeEventListener('mousedown', handleMouseDown) 31 | } 32 | }, []) 33 | 34 | return {children} 35 | } 36 | -------------------------------------------------------------------------------- /src/app/components/cart/cart.module.scss: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | from { 3 | opacity: 0; 4 | } 5 | 6 | to { 7 | opacity: 1; 8 | } 9 | } 10 | 11 | @keyframes fade-out { 12 | from { 13 | opacity: 1; 14 | } 15 | 16 | to { 17 | opacity: 0; 18 | } 19 | } 20 | 21 | @keyframes drawer-in { 22 | 0% { 23 | opacity: 0; 24 | transform: translateX(100%); 25 | } 26 | 27 | 100% { 28 | opacity: 1; 29 | transform: translateX(0); 30 | } 31 | } 32 | 33 | @keyframes drawer-out { 34 | 0% { 35 | opacity: 1; 36 | transform: translateX(0); 37 | } 38 | 39 | 100% { 40 | opacity: 0; 41 | transform: translateX(100%); 42 | } 43 | } 44 | 45 | .overlay { 46 | &[data-state='open'] { 47 | animation: fade-in 0.3s ease-in-out forwards; 48 | } 49 | 50 | &[data-state='closed'] { 51 | animation: fade-out 0.3s ease-in-out forwards; 52 | animation-delay: 0.15s; 53 | } 54 | } 55 | 56 | .content { 57 | &[data-state='open'] { 58 | animation: drawer-in 0.3s ease-in-out forwards; 59 | } 60 | 61 | &[data-state='closed'] { 62 | animation: drawer-out 0.3s ease-in-out forwards; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV === 'development' 2 | export const isProd = process.env.NODE_ENV === 'production' 3 | 4 | export const isClient = typeof document !== 'undefined' 5 | export const isServer = !isClient 6 | 7 | if (typeof process.env.NEXT_PUBLIC_SITE_URL !== 'string') { 8 | throw new Error( 9 | `Please set the NEXT_PUBLIC_SITE_URL environment variable to your site's URL. 10 | 11 | 1. Create .env file at the root of your project. 12 | 2. Add NEXT_PUBLIC_SITE_URL=http://localhost:3000 13 | 3. For other environments (like production), make sure you set the correct URL. 14 | ` 15 | ) 16 | } 17 | 18 | export const siteURL = new URL(process.env.NEXT_PUBLIC_SITE_URL) 19 | export const siteOrigin = siteURL.origin 20 | 21 | // we like putting this in the JavaScript console, 22 | // as our signature. 23 | // you can delete it if not needed. 24 | export const basementLog = ` 25 | 26 | ██╗ 27 | ██║ 28 | ██████╗ 29 | ██╔══██╗ ██╗ 30 | ██████╔╝ ██╝ 31 | ╚═════╝ 32 | 33 | From the basement. https://basement.studio 34 | ` 35 | -------------------------------------------------------------------------------- /src/hooks/use-media.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { isApiSupported } from '~/lib/utils' 4 | 5 | export const useMedia = (mediaQuery: string, initialValue?: boolean) => { 6 | const [isVerified, setIsVerified] = React.useState( 7 | initialValue 8 | ) 9 | 10 | React.useEffect(() => { 11 | if (!isApiSupported('matchMedia')) { 12 | console.warn('matchMedia is not supported by your current browser') 13 | return 14 | } 15 | const mediaQueryList = window.matchMedia(mediaQuery) 16 | const changeHandler = () => setIsVerified(!!mediaQueryList.matches) 17 | 18 | changeHandler() 19 | if (typeof mediaQueryList.addEventListener === 'function') { 20 | mediaQueryList.addEventListener('change', changeHandler) 21 | return () => { 22 | mediaQueryList.removeEventListener('change', changeHandler) 23 | } 24 | } else if (typeof mediaQueryList.addListener === 'function') { 25 | mediaQueryList.addListener(changeHandler) 26 | return () => { 27 | mediaQueryList.removeListener(changeHandler) 28 | } 29 | } 30 | }, [mediaQuery]) 31 | 32 | return isVerified 33 | } 34 | -------------------------------------------------------------------------------- /public/primitives/grid-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/utils/image.ts: -------------------------------------------------------------------------------- 1 | // in sync with next.config.js (https://nextjs.org/docs/api-reference/next/image#device-sizes) 2 | const imageWidths = [ 3 | 16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840 4 | ] as const 5 | 6 | export type NextImageWidth = (typeof imageWidths)[number] 7 | 8 | export const getNextImageSrc = ({ 9 | src, 10 | width, 11 | quality = 75 12 | }: { 13 | src: string 14 | width: NextImageWidth 15 | quality?: number 16 | }) => { 17 | return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}` 18 | } 19 | 20 | export const findClosestNextImageWidth = (width: number): NextImageWidth => { 21 | return ( 22 | (imageWidths.find((w) => w >= width) || 23 | imageWidths[imageWidths.length - 1]) ?? 24 | 3840 25 | ) 26 | } 27 | 28 | export const getImageSizes = ( 29 | desktop: number, 30 | tablet?: number, 31 | mobile?: number 32 | ) => { 33 | let str = '' 34 | 35 | if (mobile) { 36 | str += `(max-width: 767px) ${mobile}, ` 37 | } 38 | if (tablet) { 39 | str += `(max-width: 1024px) ${tablet}, ` 40 | } 41 | if (desktop) { 42 | str += desktop 43 | } 44 | 45 | return str 46 | } 47 | -------------------------------------------------------------------------------- /src/app/components/primitives/portal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { createPortal } from 'react-dom' 3 | 4 | type Props = { 5 | id?: string 6 | onMount?: () => void 7 | children?: React.ReactNode 8 | className?: string 9 | } 10 | 11 | export const Portal = ({ 12 | children, 13 | id = 'basement-portal', 14 | onMount, 15 | className 16 | }: Props) => { 17 | const ref = React.useRef() 18 | const [isMounted, setIsMounted] = React.useState(false) 19 | 20 | React.useEffect(() => { 21 | let portal: HTMLElement | undefined = undefined 22 | const existingPortal = document.getElementById(id) as HTMLElement | null 23 | if (existingPortal) { 24 | portal = existingPortal 25 | } else { 26 | portal = document.createElement('div') 27 | portal.id = id 28 | document.body.appendChild(portal) 29 | } 30 | portal.className = className ?? '' 31 | ref.current = portal 32 | setIsMounted(true) 33 | }, [className, id]) 34 | 35 | React.useEffect(() => { 36 | if (isMounted && onMount) onMount() 37 | }, [isMounted, onMount]) 38 | 39 | return isMounted && ref.current ? createPortal(children, ref.current) : null 40 | } 41 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './app/**/*.{js,ts,jsx,tsx}', 5 | './pages/**/*.{js,ts,jsx,tsx}', 6 | './components/**/*.{js,ts,jsx,tsx}', 7 | 8 | // Or if using `src` directory: 9 | './src/**/*.{js,ts,jsx,tsx}' 10 | ], 11 | theme: { 12 | extend: { 13 | screens: { 14 | xl: '1440px', 15 | 'better-hover': { raw: '(hover: hover) and (pointer: fine)' } 16 | }, 17 | colors: { 18 | black: '#000000', 19 | cream: '#FFF5DC', 20 | pink: '#F765B8', 21 | teal: '#53E5D0' 22 | }, 23 | fontFamily: { 24 | display: ['var(--font-nichrome)'], 25 | neon: ['var(--font-neon)'] 26 | }, 27 | fontSize: { 28 | base: '2rem', 29 | product: '64px', 30 | title: '80px', 31 | hero: '244px' 32 | }, 33 | lineHeight: { 34 | trim: '74%', 35 | tight: '100%' 36 | }, 37 | dropShadow: { 38 | cart: '0px 2px 0px #000000', 39 | section: '0px 8px 0px #6BE5D0' 40 | }, 41 | borderRadius: { 42 | extra: '32px' 43 | } 44 | } 45 | }, 46 | plugins: [] 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { isClient } from '~/lib/constants' 2 | 3 | export const formatError = ( 4 | error: unknown 5 | ): { message: string; name?: string } => { 6 | try { 7 | if (error instanceof Error) { 8 | return { message: error.message, name: error.name } 9 | } 10 | return { message: String(error) } 11 | } catch (error) { 12 | return { message: 'An unknown error ocurred.' } 13 | } 14 | } 15 | 16 | export const isApiSupported = (api: string) => isClient && api in window 17 | 18 | /* Builds responsive sizes string for images */ 19 | export const getSizes = ( 20 | entries: ({ breakpoint: string; width: string } | string | number)[] 21 | ) => { 22 | const sizes = entries.map((entry) => { 23 | if (!entry) { 24 | return '' 25 | } 26 | 27 | if (typeof entry === 'string') { 28 | return entry 29 | } 30 | 31 | if (typeof entry === 'number') { 32 | return `${entry}px` 33 | } 34 | 35 | if (entry.breakpoint.includes('px') || entry.breakpoint.includes('rem')) { 36 | return `(min-width: ${entry.breakpoint}) ${entry.width}` 37 | } 38 | 39 | throw new Error(`Invalid breakpoint: ${entry.breakpoint}`) 40 | }) 41 | 42 | return sizes.join(', ') 43 | } 44 | -------------------------------------------------------------------------------- /src/app/css/helpers.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:string'; 2 | @use 'sass:math'; 3 | 4 | /* stylelint-disable-next-line number-max-precision */ 5 | $golden-ratio: 1.6180339887498948482; 6 | $reciprocal-golden-ratio: 1 / $golden-ratio; 7 | $duration: $reciprocal-golden-ratio * 1.2; 8 | 9 | @function tovw($target, $context: 1920px, $min: 'placeholder') { 10 | @if $context == 'desktop-large' { 11 | $context: 1920px; 12 | } 13 | 14 | @if $context == 'desktop' { 15 | $context: 1440px; 16 | } 17 | 18 | @if $context == 'tablet' { 19 | $context: 620px; 20 | } 21 | 22 | @if $context == 'mobile' { 23 | $context: 375px; 24 | } 25 | 26 | @if $target == 0 { 27 | @return 0; 28 | } 29 | 30 | @if $min != 'placeholder' { 31 | @return string.unquote( 32 | 'max(' + $min + ', ' + (math.div($target, $context) * 100) + 'vw)' 33 | ); 34 | } 35 | 36 | @return string.unquote((math.div($target, $context) * 100) + 'vw'); 37 | } 38 | 39 | @function torem($target, $context: 16px) { 40 | @if $target == 0 { 41 | @return 0; 42 | } 43 | 44 | @return math.div($target, $context) + 0rem; 45 | } 46 | 47 | @function toem($target, $context) { 48 | @if $target == 0 { 49 | @return 0; 50 | } 51 | 52 | @return math.div($target, $context) + 0em; 53 | } 54 | -------------------------------------------------------------------------------- /src/app/components/cart/cart-footer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Image from 'next/image' 3 | 4 | export const CartFooter = ({ 5 | total, 6 | checkoutUrl, 7 | emptyState 8 | }: { 9 | total: number 10 | checkoutUrl: string 11 | emptyState: boolean 12 | }) => { 13 | return ( 14 |
15 | 23 |
24 |
25 | TOTAL ${total} 26 |
27 | 36 | CHECKOUT 37 | 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /public/logos/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /public/primitives/ellipse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/app/components/grid/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | const Grid = () => { 4 | return ( 5 | <> 6 | hr 15 | hr 23 |
24 |
25 | hr 33 | hr 41 |
42 | 43 | ) 44 | } 45 | 46 | export default Grid 47 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './css/global.scss' 2 | 3 | import localFont from '@next/font/local' 4 | import type { Metadata } from 'next' 5 | 6 | import { siteURL } from '~/lib/constants' 7 | 8 | import { Header } from './components/header' 9 | import { Providers } from './providers' 10 | import Footer from './sections/footer' 11 | 12 | const nichrome = localFont({ 13 | src: [ 14 | { 15 | path: './fonts/MDNichrome-Black.woff2', 16 | weight: '900', 17 | style: 'normal' 18 | }, 19 | { 20 | path: './fonts/MDNichrome-Bold.woff2', 21 | weight: '700', 22 | style: 'bold' 23 | }, 24 | { 25 | path: './fonts/MDNichrome-Regular.woff2', 26 | weight: '400', 27 | style: 'regular' 28 | } 29 | ], 30 | preload: true, 31 | variable: '--font-nichrome' 32 | }) 33 | 34 | export const metadata: Metadata = { 35 | title: 'Next.js & Pizza — a workshop by basement.studio', 36 | description: 37 | 'The Spicy Shop is a simple t-shirt e-commerce project strongly spiced with basement.studio stack for the Next.js & Pizza workshop', 38 | metadataBase: siteURL 39 | } 40 | 41 | const RootLayout = ({ children }: { children: React.ReactNode }) => { 42 | return ( 43 | 44 | 45 | 46 | {/* @ts-expect-error rsc */} 47 |
48 |
{children}
49 |