├── .nvmrc ├── public ├── .gitkeep ├── favicon.ico ├── favicon-dark.ico ├── font │ ├── jet-brains-mono.woff2 │ ├── lexend-zetta-black.woff2 │ ├── inter-italic-latin-var.woff2 │ └── inter-roman-latin-var.woff2 ├── favicon-dark.svg └── favicon.svg ├── .npmrc ├── .prettierignore ├── app ├── assets │ ├── images │ │ ├── splash.avif │ │ ├── load-runner.gif │ │ ├── social-main.jpg │ │ ├── load-runner-1.webp │ │ ├── matrix-text │ │ │ ├── empty.png │ │ │ ├── error-404.png │ │ │ └── error-500.png │ │ └── social-collections.jpg │ └── icons │ │ ├── x.svg │ │ ├── chevron-left.svg │ │ ├── chevron-right.svg │ │ ├── chevron-down.svg │ │ ├── chevron-up.svg │ │ ├── circle-minus.svg │ │ ├── check.svg │ │ ├── fast-forward.svg │ │ ├── circle-plus.svg │ │ ├── info.svg │ │ ├── mail.svg │ │ ├── circle-check.svg │ │ ├── x-logo.svg │ │ ├── cart.svg │ │ ├── github.svg │ │ ├── youtube.svg │ │ ├── bag.svg │ │ ├── discord.svg │ │ ├── remix-logo.svg │ │ └── remix-glyphs.svg ├── test │ └── setup.ts ├── lib │ ├── cn.ts │ ├── redirect.ts │ ├── i18n.ts │ ├── use-relative-url.tsx │ ├── image-utils.ts │ ├── context.ts │ ├── session.ts │ ├── meta.ts │ ├── filters │ │ ├── query-variables.server.ts │ │ └── index.ts │ ├── data │ │ ├── header.server.ts │ │ ├── hero.server.ts │ │ ├── lookbook.server.ts │ │ ├── collection.server.ts │ │ ├── policy.server.ts │ │ ├── product.server.ts │ │ └── subscribe.server.ts │ ├── fragments.ts │ ├── hooks.ts │ └── __tests__ │ │ └── filters.test.ts ├── routes │ ├── pages │ │ ├── ($locale).collections._index.tsx │ │ ├── ($locale).$.tsx │ │ ├── ($locale).[sitemap.xml].tsx │ │ ├── ($locale).tsx │ │ ├── ($locale).sitemap.$type.$page[.xml].tsx │ │ ├── ($locale).policies.$handle.tsx │ │ ├── ($locale).discount.$code.tsx │ │ ├── ($locale).cart.$lines.tsx │ │ ├── ($locale).collections.$handle.tsx │ │ ├── [robots.txt].tsx │ │ ├── components.tsx │ │ ├── components.animated-link.tsx │ │ └── ($locale).subscribe.tsx │ └── resources │ │ └── load-more-products.tsx ├── routes.ts ├── components │ ├── icon │ │ ├── types.generated.ts │ │ └── index.tsx │ ├── carousel │ │ ├── dot-button.tsx │ │ └── arrow-buttons.tsx │ ├── ui │ │ ├── popover.tsx │ │ ├── dropdown-menu.tsx │ │ ├── blur-image.tsx │ │ └── animated-link.tsx │ ├── store-wide-sale.tsx │ ├── page-title.tsx │ ├── snow-field.tsx │ ├── remix-logo.tsx │ ├── product-grid.tsx │ ├── product-images.tsx │ ├── footer.tsx │ └── cart.tsx ├── entry.client.tsx ├── entry.server.tsx └── tailwind.css ├── .gitignore ├── .graphqlrc.yml ├── react-router.config.ts ├── components.json ├── vitest.config.ts ├── .github └── workflows │ ├── test.yml │ ├── lint.yml │ ├── oxygen-deployment.yml │ └── format.yml ├── .env.example ├── tsconfig.json ├── vite.config.ts ├── LICENSE.md ├── env.d.ts ├── server.ts ├── package.json ├── README.md └── eslint.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | v24 -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .shopify 2 | node_modules 3 | package-lock.json 4 | dist 5 | *.generated.* -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-dark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/public/favicon-dark.ico -------------------------------------------------------------------------------- /app/assets/images/splash.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/splash.avif -------------------------------------------------------------------------------- /app/assets/images/load-runner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/load-runner.gif -------------------------------------------------------------------------------- /app/assets/images/social-main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/social-main.jpg -------------------------------------------------------------------------------- /public/font/jet-brains-mono.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/public/font/jet-brains-mono.woff2 -------------------------------------------------------------------------------- /app/assets/images/load-runner-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/load-runner-1.webp -------------------------------------------------------------------------------- /public/font/lexend-zetta-black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/public/font/lexend-zetta-black.woff2 -------------------------------------------------------------------------------- /app/assets/images/matrix-text/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/matrix-text/empty.png -------------------------------------------------------------------------------- /app/assets/images/social-collections.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/social-collections.jpg -------------------------------------------------------------------------------- /public/font/inter-italic-latin-var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/public/font/inter-italic-latin-var.woff2 -------------------------------------------------------------------------------- /public/font/inter-roman-latin-var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/public/font/inter-roman-latin-var.woff2 -------------------------------------------------------------------------------- /app/assets/images/matrix-text/error-404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/matrix-text/error-404.png -------------------------------------------------------------------------------- /app/assets/images/matrix-text/error-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-store/HEAD/app/assets/images/matrix-text/error-500.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /.cache 3 | /build 4 | /dist 5 | /public/build 6 | /.mf 7 | .env 8 | .shopify 9 | .DS_STORE 10 | /coverage 11 | .react-router 12 | 13 | .cursor/ -------------------------------------------------------------------------------- /app/test/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | import { cleanup } from "@testing-library/react"; 3 | import { afterEach } from "vitest"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | -------------------------------------------------------------------------------- /app/assets/icons/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/chevron-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/lib/cn.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | // Should only be used if arbitrary classnames can be passed into a component 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | -------------------------------------------------------------------------------- /app/assets/icons/circle-minus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).collections._index.tsx: -------------------------------------------------------------------------------- 1 | import { href, redirect } from "react-router"; 2 | 3 | export async function loader() { 4 | // TODO: Add a collections index page, for now just redirect to all products 5 | throw redirect(href("/:locale?/collections/:handle", { handle: "all" })); 6 | } 7 | -------------------------------------------------------------------------------- /app/assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | projects: 2 | default: 3 | schema: "node_modules/@shopify/hydrogen/storefront.schema.json" 4 | documents: 5 | - "!*.d.ts" 6 | - "*.{ts,tsx,js,jsx}" 7 | - "app/**/*.{ts,tsx,js,jsx}" 8 | - "!app/graphql/**/*.{ts,tsx,js,jsx}" 9 | - "!app/lib/data/subscribe.server.ts" 10 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).$.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/($locale).$"; 2 | 3 | export async function loader({ request }: Route.LoaderArgs) { 4 | throw new Response(`${new URL(request.url).pathname} not found`, { 5 | status: 404, 6 | }); 7 | } 8 | 9 | export default function CatchAllPage() { 10 | return null; 11 | } 12 | -------------------------------------------------------------------------------- /app/assets/icons/fast-forward.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/circle-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/assets/icons/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | import { hydrogenPreset } from "@shopify/hydrogen/react-router-preset"; 3 | 4 | export default { 5 | presets: [hydrogenPreset()], 6 | future: { 7 | unstable_optimizeDeps: true, 8 | }, 9 | appDirectory: "app", 10 | buildDirectory: "dist", 11 | ssr: true, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /app/assets/icons/circle-check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/tailwind.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/assets/icons/x-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/cart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).[sitemap.xml].tsx: -------------------------------------------------------------------------------- 1 | import { getSitemapIndex } from "@shopify/hydrogen"; 2 | import type { Route } from "./+types/($locale).[sitemap.xml]"; 3 | 4 | export async function loader({ 5 | request, 6 | context: { storefront }, 7 | }: Route.LoaderArgs) { 8 | const response = await getSitemapIndex({ 9 | storefront, 10 | request, 11 | }); 12 | 13 | response.headers.set("Cache-Control", `max-age=${60 * 60 * 24}`); 14 | 15 | return response; 16 | } 17 | -------------------------------------------------------------------------------- /app/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import tsconfigPaths from "vite-tsconfig-paths"; 3 | import { defineConfig } from "vitest/config"; 4 | 5 | export default defineConfig({ 6 | plugins: [tsconfigPaths()], 7 | test: { 8 | globals: true, 9 | environment: "happy-dom", 10 | setupFiles: "./app/test/setup.ts", 11 | css: true, 12 | coverage: { 13 | provider: "v8", 14 | reporter: ["text", "json", "html"], 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /app/assets/icons/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { flatRoutes } from "@react-router/fs-routes"; 2 | import { hydrogenRoutes } from "@shopify/hydrogen"; 3 | import { prefix, route, type RouteConfig } from "@react-router/dev/routes"; 4 | 5 | const routes = [ 6 | ...(await hydrogenRoutes( 7 | await flatRoutes({ rootDirectory: "routes/pages" }), 8 | )), 9 | ...prefix("_resources", [ 10 | route("load-more-products", "routes/resources/load-more-products.tsx"), 11 | ]), 12 | ] satisfies RouteConfig; 13 | 14 | export default routes; 15 | -------------------------------------------------------------------------------- /app/components/icon/types.generated.ts: -------------------------------------------------------------------------------- 1 | // This file is generated by icon spritesheet generator 2 | 3 | export const iconNames = [ 4 | "youtube", 5 | "x", 6 | "x-logo", 7 | "remix-logo", 8 | "remix-glyphs", 9 | "mail", 10 | "info", 11 | "github", 12 | "fast-forward", 13 | "discord", 14 | "circle-plus", 15 | "circle-minus", 16 | "circle-check", 17 | "chevron-up", 18 | "chevron-right", 19 | "chevron-left", 20 | "chevron-down", 21 | "check", 22 | "cart", 23 | "bag", 24 | ] as const 25 | 26 | export type IconName = typeof iconNames[number] 27 | -------------------------------------------------------------------------------- /app/assets/icons/bag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/components/icon/index.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/cn"; 2 | import type { IconName } from "./types.generated"; 3 | 4 | export type IconProps = Omit, "ref"> & { 5 | name: IconName; 6 | }; 7 | 8 | export function Icon({ name, className, ...props }: IconProps) { 9 | return ( 10 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/($locale)"; 2 | 3 | export async function loader({ params, context }: Route.LoaderArgs) { 4 | const { language, country } = context.storefront.i18n; 5 | 6 | if ( 7 | params.locale && 8 | params.locale.toLowerCase() !== `${language}-${country}`.toLowerCase() 9 | ) { 10 | // If the locale URL param is defined, yet we still are still at the default locale 11 | // then the the locale param must be invalid, send to the 404 page 12 | throw new Response("Page not found", { status: 404 }); 13 | } 14 | 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test 2 | on: pull_request 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | test: 10 | name: 🧪 Test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: ⬇️ Checkout repo 14 | uses: actions/checkout@v4 15 | 16 | - name: ⎔ Setup node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 24 20 | cache: npm 21 | 22 | - name: 📥 Install deps 23 | run: npm ci 24 | 25 | - name: 🧪 Run tests 26 | run: npm run test -- run 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: ⬣ Lint 2 | on: pull_request 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | lint: 10 | name: ⬣ Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: ⬇️ Checkout repo 14 | uses: actions/checkout@v4 15 | 16 | - name: ⎔ Setup node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 24 20 | cache: npm 21 | 22 | - name: 📥 Install deps 23 | run: npm ci 24 | 25 | - name: 🔬 Lint 26 | run: npm run lint && npm run typecheck 27 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { HydratedRouter } from "react-router/dom"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | import { NonceProvider } from "@shopify/hydrogen"; 5 | 6 | if (!window.location.origin.includes("webcache.googleusercontent.com")) { 7 | startTransition(() => { 8 | const existingNonce = 9 | document.querySelector("script[nonce]")?.nonce; 10 | 11 | hydrateRoot( 12 | document, 13 | 14 | 15 | 16 | 17 | , 18 | ); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /app/lib/redirect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates that a redirect URL is safe to use. 3 | * Only allows relative paths to prevent open redirect vulnerabilities. 4 | * 5 | * @param url - The URL to validate 6 | * @returns true if the URL is safe to redirect to, false otherwise 7 | */ 8 | export function isValidRedirect(url: string | null | undefined): boolean { 9 | if (!url || typeof url !== "string") { 10 | return false; 11 | } 12 | 13 | // Reject external URLs (protocol-relative or absolute URLs) 14 | if (url.includes("//")) { 15 | return false; 16 | } 17 | 18 | // Only allow relative paths starting with / 19 | return url.startsWith("/") && !url.startsWith("//"); 20 | } 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Note: all of the following are real (public) tokens/values used on 2 | # https://shop.remix.run. They are required to run the store locally. 3 | # 4 | # THIS IS A PUBLIC STORE -- all purchases made will place real orders 5 | # that will really take your money and will tell the Remix team to really 6 | # send you some great merch 7 | 8 | PUBLIC_STOREFRONT_ID=1000020043 9 | PUBLIC_STOREFRONT_API_TOKEN=4e46f3697737cea62f88321f040c7128 10 | PUBLIC_STORE_DOMAIN=2d1167-3d.myshopify.com 11 | PUBLIC_CHECKOUT_DOMAIN=checkout.remix.run 12 | 13 | 14 | # This is not the real session secret, but a value is needed to run' 15 | # the store locally 16 | 17 | SESSION_SECRET=nottherealsessionsecret -------------------------------------------------------------------------------- /app/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import type { I18nBase } from "@shopify/hydrogen"; 2 | 3 | export interface I18nLocale extends I18nBase { 4 | pathPrefix: string; 5 | } 6 | 7 | export function getLocaleFromRequest(request: Request): I18nLocale { 8 | const url = new URL(request.url); 9 | const firstPathPart = url.pathname.split("/")[1]?.toUpperCase() ?? ""; 10 | 11 | type I18nFromUrl = [I18nLocale["language"], I18nLocale["country"]]; 12 | 13 | let pathPrefix = ""; 14 | let [language, country]: I18nFromUrl = ["EN", "US"]; 15 | 16 | if (/^[A-Z]{2}-[A-Z]{2}$/i.test(firstPathPart)) { 17 | pathPrefix = "/" + firstPathPart; 18 | [language, country] = firstPathPart.split("-") as I18nFromUrl; 19 | } 20 | 21 | return { language, country, pathPrefix }; 22 | } 23 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).sitemap.$type.$page[.xml].tsx: -------------------------------------------------------------------------------- 1 | import { getSitemap } from "@shopify/hydrogen"; 2 | import type { Route } from "./+types/($locale).sitemap.$type.$page[.xml]"; 3 | 4 | export async function loader({ 5 | request, 6 | params, 7 | context: { storefront }, 8 | }: Route.LoaderArgs) { 9 | const response = await getSitemap({ 10 | storefront, 11 | request, 12 | params, 13 | locales: ["EN-US", "EN-CA", "FR-CA"], 14 | getLink: ({ type, baseUrl, handle, locale }) => { 15 | if (!locale) return `${baseUrl}/${type}/${handle}`; 16 | return `${baseUrl}/${locale}/${type}/${handle}`; 17 | }, 18 | }); 19 | 20 | response.headers.set("Cache-Control", `max-age=${60 * 60 * 24}`); 21 | 22 | return response; 23 | } 24 | -------------------------------------------------------------------------------- /public/favicon-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/lib/use-relative-url.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteLoaderData } from "react-router"; 2 | import type { loader as rootLoader } from "~/root"; 3 | 4 | /** 5 | * Strips the domain for internal URLs 6 | */ 7 | export function useRelativeUrl(ogUrl: string) { 8 | const rootData = useRouteLoaderData("root"); 9 | 10 | if (!rootData) { 11 | throw new Error("Failed to find data for root loader"); 12 | } 13 | 14 | const { header, publicStoreDomain } = rootData; 15 | const primaryDomainUrl = header.shop.primaryDomain.url; 16 | 17 | const url = 18 | ogUrl.includes("myshopify.com") || 19 | ogUrl.includes(publicStoreDomain) || 20 | ogUrl.includes(primaryDomainUrl) 21 | ? new URL(ogUrl).pathname 22 | : ogUrl; 23 | const isExternal = !url.startsWith("/"); 24 | 25 | return { url, isExternal }; 26 | } 27 | -------------------------------------------------------------------------------- /app/lib/image-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extracts focal point coordinates from image presentation data 3 | */ 4 | export function getFocalPoint( 5 | presentation: unknown, 6 | ): { x: number; y: number } | undefined { 7 | if (typeof presentation !== "object" || presentation === null) { 8 | return undefined; 9 | } 10 | 11 | if (!("focalPoint" in presentation)) { 12 | return undefined; 13 | } 14 | 15 | const focalPoint = presentation.focalPoint; 16 | if (typeof focalPoint !== "object" || focalPoint === null) { 17 | return undefined; 18 | } 19 | 20 | if (!("x" in focalPoint) || !("y" in focalPoint)) { 21 | return undefined; 22 | } 23 | 24 | const x = Number(focalPoint.x); 25 | const y = Number(focalPoint.y); 26 | 27 | if (isNaN(x) || isNaN(y)) { 28 | return undefined; 29 | } 30 | 31 | return { x, y }; 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/oxygen-deployment.yml: -------------------------------------------------------------------------------- 1 | # Don't change the line below! 2 | #! oxygen_storefront_id: 1000020043 3 | 4 | name: Storefront 1000020043 5 | on: [push] 6 | 7 | permissions: 8 | contents: read 9 | deployments: write 10 | 11 | jobs: 12 | deploy: 13 | name: Deploy to Oxygen 14 | timeout-minutes: 30 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 24 23 | check-latest: true 24 | cache: npm 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build and Publish to Oxygen 30 | run: npx shopify hydrogen deploy 31 | env: 32 | SHOPIFY_HYDROGEN_DEPLOYMENT_TOKEN: ${{ secrets.OXYGEN_DEPLOYMENT_TOKEN_1000020043 }} 33 | -------------------------------------------------------------------------------- /app/lib/context.ts: -------------------------------------------------------------------------------- 1 | import { createHydrogenContext } from "@shopify/hydrogen"; 2 | import { AppSession } from "~/lib/session"; 3 | import { CART_QUERY_FRAGMENT } from "~/lib/fragments"; 4 | import { getLocaleFromRequest } from "~/lib/i18n"; 5 | 6 | export async function createHydrogenRouterContext( 7 | request: Request, 8 | env: Env, 9 | executionContext: ExecutionContext, 10 | ) { 11 | if (!env?.SESSION_SECRET) { 12 | throw new Error("SESSION_SECRET environment variable is not set"); 13 | } 14 | 15 | const waitUntil = executionContext.waitUntil.bind(executionContext); 16 | const [cache, session] = await Promise.all([ 17 | caches.open("hydrogen"), 18 | AppSession.init(request, [env.SESSION_SECRET]), 19 | ]); 20 | 21 | return createHydrogenContext({ 22 | env, 23 | request, 24 | cache, 25 | waitUntil, 26 | session, 27 | i18n: getLocaleFromRequest(request), 28 | cart: { 29 | queryFragment: CART_QUERY_FRAGMENT, 30 | }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: 💅 Format 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | format: 13 | name: 💅 Format 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: ⬇️ Checkout repo 18 | uses: actions/checkout@v4 19 | 20 | - name: ⎔ Setup node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 24 24 | cache: npm 25 | 26 | - name: 📥 Install deps 27 | run: npm ci 28 | 29 | - name: 💅 Format 30 | run: npm run format --if-present 31 | 32 | - name: 🗝️ Commit changes 33 | run: | 34 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 35 | git config --local user.name "github-actions[bot]" 36 | git add . 37 | git diff --quiet && git diff --staged --quiet || git commit -m "chore: format [skip ci]" 38 | git push 39 | -------------------------------------------------------------------------------- /app/assets/icons/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "env.d.ts", 4 | "app/**/*.ts", 5 | "app/**/*.tsx", 6 | "app/**/*.d.ts", 7 | "*.ts", 8 | "*.tsx", 9 | "*.d.ts", 10 | ".graphqlrc.ts", 11 | ".react-router/types/**/*" 12 | ], 13 | "exclude": ["node_modules", "dist", "build", "packages/**/dist/**/*"], 14 | "compilerOptions": { 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 16 | "isolatedModules": true, 17 | "esModuleInterop": true, 18 | "jsx": "react-jsx", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "module": "ES2022", 22 | "target": "ES2022", 23 | "strict": true, 24 | "allowJs": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "skipLibCheck": true, 27 | "baseUrl": ".", 28 | "types": [ 29 | "@shopify/oxygen-workers-types", 30 | "react-router", 31 | "@shopify/hydrogen/react-router-types", 32 | "vite/client" 33 | ], 34 | "paths": { 35 | "~/*": ["app/*"] 36 | }, 37 | "noEmit": true, 38 | "rootDirs": [".", "./.react-router/types"] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { hydrogen } from "@shopify/hydrogen/vite"; 3 | import { oxygen } from "@shopify/mini-oxygen/vite"; 4 | import tailwindcss from "@tailwindcss/vite"; 5 | import { iconsSpritesheet } from "vite-plugin-icons-spritesheet"; 6 | import { reactRouter } from "@react-router/dev/vite"; 7 | import tsconfigPaths from "vite-tsconfig-paths"; 8 | 9 | export default defineConfig({ 10 | optimizeDeps: { 11 | include: ["embla-carousel-react"], 12 | }, 13 | plugins: [ 14 | hydrogen(), 15 | oxygen(), 16 | tailwindcss(), 17 | iconsSpritesheet({ 18 | inputDir: "app/assets/icons", 19 | outputDir: "public", 20 | typesOutputFile: "app/components/icon/types.generated.ts", 21 | fileName: "sprites.svg", 22 | withTypes: true, 23 | iconNameTransformer: (name) => 24 | name.charAt(0).toLowerCase() + name.slice(1), 25 | }), 26 | reactRouter(), 27 | tsconfigPaths(), 28 | ], 29 | build: { 30 | // Allow a strict Content-Security-Policy 31 | // without inlining assets as base64: 32 | assetsInlineLimit: 0, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Shopify Inc. 2025-present 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 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // Enhance TypeScript's built-in typings. 6 | import "@total-typescript/ts-reset"; 7 | 8 | import type { 9 | HydrogenContext, 10 | HydrogenSessionData, 11 | HydrogenEnv, 12 | } from "@shopify/hydrogen"; 13 | import type { createAppLoadContext } from "~/lib/context"; 14 | 15 | declare global { 16 | /** 17 | * A global `process` object is only available during build to access NODE_ENV. 18 | */ 19 | const process: { env: { NODE_ENV: "production" | "development" } }; 20 | 21 | interface Env extends HydrogenEnv { 22 | // declare additional Env parameter use in the fetch handler and Remix loader context here 23 | ADMIN_ACCESS_TOKEN?: string; 24 | } 25 | } 26 | 27 | declare module "react-router" { 28 | interface AppLoadContext extends Awaited< 29 | ReturnType 30 | > { 31 | // to change context type, change the return of createAppLoadContext() instead 32 | } 33 | 34 | interface SessionData extends HydrogenSessionData { 35 | // declare local additions to the Remix session data here 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).policies.$handle.tsx: -------------------------------------------------------------------------------- 1 | import { PageTitle } from "~/components/page-title"; 2 | import { generateMeta } from "~/lib/meta"; 3 | import { getPolicyData } from "~/lib/data/policy.server"; 4 | import type { Route } from "./+types/($locale).policies.$handle"; 5 | 6 | export function meta({ data, matches }: Route.MetaArgs) { 7 | const { siteUrl } = matches[0].data; 8 | 9 | return generateMeta({ 10 | title: data?.policy.title, 11 | url: siteUrl, 12 | }); 13 | } 14 | 15 | export async function loader({ params, context }: Route.LoaderArgs) { 16 | if (!params.handle) { 17 | throw new Response("No handle was passed in", { status: 404 }); 18 | } 19 | 20 | const policy = await getPolicyData(context.storefront, { 21 | handle: params.handle, 22 | }); 23 | 24 | return { policy }; 25 | } 26 | 27 | export default function Policies({ loaderData }: Route.ComponentProps) { 28 | const { policy } = loaderData; 29 | 30 | return ( 31 |
32 | {policy.title} 33 |
34 |
35 |
36 | 37 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/assets/icons/remix-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/lib/session.ts: -------------------------------------------------------------------------------- 1 | import type { HydrogenSession } from "@shopify/hydrogen"; 2 | import { 3 | createCookieSessionStorage, 4 | type SessionStorage, 5 | type Session, 6 | } from "react-router"; 7 | 8 | export class AppSession implements HydrogenSession { 9 | #sessionStorage; 10 | #session; 11 | public isPending = false; 12 | 13 | constructor(sessionStorage: SessionStorage, session: Session) { 14 | this.#sessionStorage = sessionStorage; 15 | this.#session = session; 16 | } 17 | 18 | static async init(request: Request, secrets: string[]) { 19 | const storage = createCookieSessionStorage({ 20 | cookie: { 21 | name: "session", 22 | httpOnly: true, 23 | path: "/", 24 | sameSite: "lax", 25 | secure: process.env.NODE_ENV === "production", 26 | secrets, 27 | }, 28 | }); 29 | 30 | const session = await storage 31 | .getSession(request.headers.get("Cookie")) 32 | .catch(() => storage.getSession()); 33 | 34 | return new this(storage, session); 35 | } 36 | 37 | get has() { 38 | return this.#session.has; 39 | } 40 | 41 | get get() { 42 | return this.#session.get; 43 | } 44 | 45 | get flash() { 46 | return this.#session.flash; 47 | } 48 | 49 | get unset() { 50 | this.isPending = true; 51 | return this.#session.unset; 52 | } 53 | 54 | get set() { 55 | this.isPending = true; 56 | return this.#session.set; 57 | } 58 | 59 | destroy() { 60 | return this.#sessionStorage.destroySession(this.#session); 61 | } 62 | 63 | commit() { 64 | this.isPending = false; 65 | return this.#sessionStorage.commitSession(this.#session); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).discount.$code.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import type { Route } from "./+types/($locale).discount.$code"; 3 | 4 | /** 5 | * Automatically applies a discount found on the url 6 | * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied 7 | * 8 | * @example 9 | * Example path applying a discount and optional redirecting (defaults to the home page) 10 | * ```js 11 | * /discount/FREESHIPPING?redirect=/products 12 | * 13 | * ``` 14 | */ 15 | export async function loader({ request, context, params }: Route.LoaderArgs) { 16 | const { cart } = context; 17 | const { code } = params; 18 | 19 | const url = new URL(request.url); 20 | const searchParams = new URLSearchParams(url.search); 21 | let redirectParam = 22 | searchParams.get("redirect") || searchParams.get("return_to") || "/"; 23 | 24 | if (redirectParam.includes("//")) { 25 | // Avoid redirecting to external URLs to prevent phishing attacks 26 | redirectParam = "/"; 27 | } 28 | 29 | searchParams.delete("redirect"); 30 | searchParams.delete("return_to"); 31 | 32 | const redirectUrl = `${redirectParam}?${searchParams}`; 33 | 34 | if (!code) { 35 | return redirect(redirectUrl); 36 | } 37 | 38 | const result = await cart.updateDiscountCodes([code]); 39 | const headers = cart.setCartId(result.cart.id); 40 | 41 | // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000) 42 | // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie 43 | // on localhost:3000 44 | return redirect(redirectUrl, { 45 | status: 303, 46 | headers, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /app/components/carousel/dot-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | type ComponentPropsWithRef, 3 | useCallback, 4 | useEffect, 5 | useState, 6 | } from "react"; 7 | import type { EmblaCarouselType } from "embla-carousel"; 8 | 9 | type UseDotButtonType = { 10 | selectedIndex: number; 11 | scrollSnaps: number[]; 12 | onDotButtonClick: (index: number) => void; 13 | }; 14 | 15 | export const useDotButton = ( 16 | emblaApi: EmblaCarouselType | undefined, 17 | ): UseDotButtonType => { 18 | const [selectedIndex, setSelectedIndex] = useState(0); 19 | const [scrollSnaps, setScrollSnaps] = useState([]); 20 | 21 | const onDotButtonClick = useCallback( 22 | (index: number) => { 23 | if (!emblaApi) return; 24 | emblaApi.scrollTo(index); 25 | }, 26 | [emblaApi], 27 | ); 28 | 29 | const onInit = useCallback((emblaApi: EmblaCarouselType) => { 30 | setScrollSnaps(emblaApi.scrollSnapList()); 31 | }, []); 32 | 33 | const onSelect = useCallback((emblaApi: EmblaCarouselType) => { 34 | setSelectedIndex(emblaApi.selectedScrollSnap()); 35 | }, []); 36 | 37 | useEffect(() => { 38 | if (!emblaApi) return; 39 | 40 | onInit(emblaApi); 41 | onSelect(emblaApi); 42 | emblaApi.on("reInit", onInit).on("reInit", onSelect).on("select", onSelect); 43 | }, [emblaApi, onInit, onSelect]); 44 | 45 | return { 46 | selectedIndex, 47 | scrollSnaps, 48 | onDotButtonClick, 49 | }; 50 | }; 51 | 52 | type PropType = ComponentPropsWithRef<"button">; 53 | 54 | export const DotButton: React.FC = (props) => { 55 | const { children, ...restProps } = props; 56 | 57 | return ( 58 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /app/lib/meta.ts: -------------------------------------------------------------------------------- 1 | import type { MetaDescriptor } from "react-router"; 2 | import ogImageSrc from "~/assets/images/social-main.jpg"; 3 | 4 | type MetaOptions = { 5 | title?: string; 6 | description?: string; 7 | image?: string; 8 | url?: string; 9 | type?: string; 10 | siteName?: string; 11 | }; 12 | 13 | /** 14 | * Generate standardized meta tags for SEO and social sharing 15 | * @param options Optional values to customize meta tags 16 | * @returns Array of meta tag objects for Remix meta function 17 | */ 18 | export function generateMeta(options: MetaOptions = {}): MetaDescriptor[] { 19 | const { 20 | title, 21 | description = "Soft wear for engineers of all kinds", 22 | image = ogImageSrc, 23 | url, 24 | type = "website", 25 | siteName = "The Remix Store", 26 | } = options; 27 | 28 | // Make image URL absolute if it's not already 29 | const imageUrl = image.startsWith("http") 30 | ? image 31 | : url 32 | ? image.startsWith("/") 33 | ? `${url}${image}` 34 | : `${url}/${image}` 35 | : image; 36 | 37 | return [ 38 | { title: title ? `${title} | ${siteName}` : siteName }, 39 | { name: "description", content: description }, 40 | // Open Graph tags 41 | { property: "og:type", content: type }, 42 | { property: "og:title", content: title }, 43 | { property: "og:description", content: description }, 44 | { property: "og:image", content: imageUrl }, 45 | ...(url ? [{ property: "og:url", content: url }] : []), 46 | { property: "og:site_name", content: siteName }, 47 | // Twitter Card tags 48 | { name: "twitter:card", content: "summary_large_image" }, 49 | { name: "twitter:title", content: title }, 50 | { name: "twitter:description", content: description }, 51 | { name: "twitter:image", content: imageUrl }, 52 | ]; 53 | } 54 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | import { 6 | createContentSecurityPolicy, 7 | type HydrogenRouterContextProvider, 8 | } from "@shopify/hydrogen"; 9 | 10 | export default async function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | reactRouterContext: EntryContext, 15 | context: HydrogenRouterContextProvider, 16 | ) { 17 | const { nonce, header, NonceProvider } = createContentSecurityPolicy({ 18 | fontSrc: ["'self'", "https://cdn.shopify.com"], 19 | imgSrc: [ 20 | "'self'", 21 | "data:", 22 | "https://cdn.shopify.com", 23 | context.env.PUBLIC_STORE_DOMAIN, 24 | ], 25 | defaultSrc: [ 26 | "'self'", 27 | "https://cdn.shopify.com", 28 | context.env.PUBLIC_STORE_DOMAIN, 29 | ], 30 | shop: { 31 | checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, 32 | storeDomain: context.env.PUBLIC_STORE_DOMAIN, 33 | }, 34 | }); 35 | 36 | const body = await renderToReadableStream( 37 | 38 | 43 | , 44 | { 45 | nonce, 46 | signal: request.signal, 47 | onError(error) { 48 | console.error(error); 49 | responseStatusCode = 500; 50 | }, 51 | }, 52 | ); 53 | 54 | if (isbot(request.headers.get("user-agent"))) { 55 | await body.allReady; 56 | } 57 | 58 | responseHeaders.set("Content-Type", "text/html"); 59 | responseHeaders.set("Content-Security-Policy", header); 60 | 61 | return new Response(body, { 62 | headers: responseHeaders, 63 | status: responseStatusCode, 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | // Virtual entry point for the app 2 | import { storefrontRedirect } from "@shopify/hydrogen"; 3 | import { createRequestHandler } from "@shopify/hydrogen/oxygen"; 4 | import { createHydrogenRouterContext } from "~/lib/context"; 5 | 6 | /** 7 | * Export a fetch handler in module format. 8 | */ 9 | export default { 10 | async fetch( 11 | request: Request, 12 | env: Env, 13 | executionContext: ExecutionContext, 14 | ): Promise { 15 | try { 16 | const hydrogenContext = await createHydrogenRouterContext( 17 | request, 18 | env, 19 | executionContext, 20 | ); 21 | 22 | /** 23 | * Create a React Router request handler and pass 24 | * Hydrogen's Storefront client to the loader context. 25 | */ 26 | const handleRequest = createRequestHandler({ 27 | // eslint-disable-next-line import/no-unresolved 28 | build: await import("virtual:react-router/server-build"), 29 | mode: process.env.NODE_ENV, 30 | getLoadContext: () => hydrogenContext, 31 | }); 32 | 33 | const response = await handleRequest(request); 34 | 35 | if (hydrogenContext.session.isPending) { 36 | response.headers.set( 37 | "Set-Cookie", 38 | await hydrogenContext.session.commit(), 39 | ); 40 | } 41 | 42 | if (response.status === 404) { 43 | /** 44 | * Check for redirects only when there's a 404 from the app. 45 | * If the redirect doesn't exist, then `storefrontRedirect` 46 | * will pass through the 404 response. 47 | */ 48 | return storefrontRedirect({ 49 | request, 50 | response, 51 | storefront: hydrogenContext.storefront, 52 | }); 53 | } 54 | 55 | return response; 56 | } catch (error) { 57 | console.error(error); 58 | return new Response("An unexpected error occurred", { status: 500 }); 59 | } 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /app/components/carousel/arrow-buttons.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | type ComponentPropsWithRef, 3 | useCallback, 4 | useEffect, 5 | useState, 6 | } from "react"; 7 | import type { EmblaCarouselType } from "embla-carousel"; 8 | 9 | type UsePrevNextButtonsType = { 10 | prevBtnDisabled: boolean; 11 | nextBtnDisabled: boolean; 12 | onPrevButtonClick: () => void; 13 | onNextButtonClick: () => void; 14 | }; 15 | 16 | export const usePrevNextButtons = ( 17 | emblaApi: EmblaCarouselType | undefined, 18 | ): UsePrevNextButtonsType => { 19 | const [prevBtnDisabled, setPrevBtnDisabled] = useState(true); 20 | const [nextBtnDisabled, setNextBtnDisabled] = useState(true); 21 | 22 | const onPrevButtonClick = useCallback(() => { 23 | if (!emblaApi) return; 24 | emblaApi.scrollPrev(); 25 | }, [emblaApi]); 26 | 27 | const onNextButtonClick = useCallback(() => { 28 | if (!emblaApi) return; 29 | emblaApi.scrollNext(); 30 | }, [emblaApi]); 31 | 32 | const onSelect = useCallback((emblaApi: EmblaCarouselType) => { 33 | setPrevBtnDisabled(!emblaApi.canScrollPrev()); 34 | setNextBtnDisabled(!emblaApi.canScrollNext()); 35 | }, []); 36 | 37 | useEffect(() => { 38 | if (!emblaApi) return; 39 | 40 | onSelect(emblaApi); 41 | emblaApi.on("reInit", onSelect).on("select", onSelect); 42 | }, [emblaApi, onSelect]); 43 | 44 | return { 45 | prevBtnDisabled, 46 | nextBtnDisabled, 47 | onPrevButtonClick, 48 | onNextButtonClick, 49 | }; 50 | }; 51 | 52 | type PropType = ComponentPropsWithRef<"button">; 53 | 54 | export const PrevButton: React.FC = (props) => { 55 | const { children, ...restProps } = props; 56 | 57 | return ( 58 | 61 | ); 62 | }; 63 | 64 | export const NextButton: React.FC = (props) => { 65 | const { children, ...restProps } = props; 66 | 67 | return ( 68 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /app/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | import { cn } from "~/lib/cn"; 4 | 5 | function Popover({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return ; 9 | } 10 | 11 | function PopoverTrigger({ 12 | ...props 13 | }: React.ComponentProps) { 14 | return ; 15 | } 16 | 17 | function PopoverClose({ 18 | ...props 19 | }: React.ComponentProps) { 20 | return ; 21 | } 22 | 23 | function PopoverContent({ 24 | className, 25 | align = "center", 26 | sideOffset = 8, 27 | ...props 28 | }: React.ComponentProps) { 29 | return ( 30 | 31 | 46 | 47 | ); 48 | } 49 | 50 | function PopoverAnchor({ 51 | ...props 52 | }: React.ComponentProps) { 53 | return ; 54 | } 55 | 56 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }; 57 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | 4 | import { cn } from "~/lib/cn"; 5 | 6 | const DropdownMenu = DropdownMenuPrimitive.Root; 7 | 8 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 9 | 10 | const DropdownMenuContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, sideOffset = 12, ...props }, ref) => ( 14 | 15 | 25 | 26 | )); 27 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 28 | 29 | const DropdownMenuItem = React.forwardRef< 30 | React.ElementRef, 31 | React.ComponentPropsWithoutRef & { 32 | inset?: boolean; 33 | } 34 | >(({ className, inset, ...props }, ref) => ( 35 | 44 | )); 45 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 46 | 47 | export { 48 | DropdownMenu, 49 | DropdownMenuTrigger, 50 | DropdownMenuContent, 51 | DropdownMenuItem, 52 | }; 53 | -------------------------------------------------------------------------------- /app/components/store-wide-sale.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { useRouteLoaderData } from "react-router"; 3 | import { clsx } from "clsx"; 4 | import type { RootLoader } from "~/root"; 5 | 6 | export function useStoreWideSale() { 7 | const data = useRouteLoaderData("root"); 8 | 9 | return data?.header.storeWideSale; 10 | } 11 | 12 | function formatEndDate(dateString: string) { 13 | const date = new Date(dateString); 14 | return date 15 | .toLocaleDateString("en-US", { 16 | month: "short", 17 | day: "numeric", 18 | }) 19 | .replace(" ", "."); 20 | } 21 | 22 | export function StoreWideSaleMarquee() { 23 | const saleData = useStoreWideSale(); 24 | 25 | if (!saleData) return null; 26 | 27 | const marqueeText = `${saleData.title} ${saleData.description}${saleData.endDateTime ? ` now thru ${formatEndDate(saleData.endDateTime)}` : ""}`; 28 | 29 | return ( 30 |
34 |
39 | 58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).cart.$lines.tsx: -------------------------------------------------------------------------------- 1 | import { href, redirect } from "react-router"; 2 | import type { Route } from "./+types/($locale).cart.$lines"; 3 | 4 | /** 5 | * Automatically creates a new cart based on the URL and redirects straight to checkout. 6 | * Expected URL structure: 7 | * ```js 8 | * /cart/: 9 | * 10 | * ``` 11 | * 12 | * More than one `:` separated by a comma, can be supplied in the URL, for 13 | * carts with more than one product variant. 14 | * 15 | * @example 16 | * Example path creating a cart with two product variants, different quantities, and a discount code in the querystring: 17 | * ```js 18 | * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD 19 | * 20 | * ``` 21 | */ 22 | export async function loader({ request, context, params }: Route.LoaderArgs) { 23 | const { cart } = context; 24 | const { lines } = params; 25 | if (!lines) return redirect(href("/:locale?/cart")); 26 | const linesMap = lines.split(",").map((line) => { 27 | const lineDetails = line.split(":"); 28 | const variantId = lineDetails[0]; 29 | const quantity = parseInt(lineDetails[1], 10); 30 | 31 | return { 32 | merchandiseId: `gid://shopify/ProductVariant/${variantId}`, 33 | quantity, 34 | }; 35 | }); 36 | 37 | const url = new URL(request.url); 38 | const searchParams = new URLSearchParams(url.search); 39 | 40 | const discount = searchParams.get("discount"); 41 | const discountArray = discount ? [discount] : []; 42 | 43 | // create a cart 44 | const result = await cart.create({ 45 | lines: linesMap, 46 | discountCodes: discountArray, 47 | }); 48 | 49 | const cartResult = result.cart; 50 | 51 | if (result.errors?.length || !cartResult) { 52 | throw new Response("Link may be expired. Try checking the URL.", { 53 | status: 410, 54 | }); 55 | } 56 | 57 | // Update cart id in cookie 58 | const headers = cart.setCartId(cartResult.id); 59 | 60 | // redirect to checkout 61 | if (cartResult.checkoutUrl) { 62 | return redirect(cartResult.checkoutUrl, { headers }); 63 | } else { 64 | throw new Error("No checkout URL found"); 65 | } 66 | } 67 | 68 | export default function Component() { 69 | return null; 70 | } 71 | -------------------------------------------------------------------------------- /app/routes/pages/($locale).collections.$handle.tsx: -------------------------------------------------------------------------------- 1 | import { data } from "react-router"; 2 | import { Analytics } from "@shopify/hydrogen"; 3 | 4 | import { getCollectionQuery } from "~/lib/data/collection.server"; 5 | import { getFilterQueryVariables } from "~/lib/filters/query-variables.server"; 6 | import { generateMeta } from "~/lib/meta"; 7 | import { ProductGrid } from "~/components/product-grid"; 8 | import { PageTitle } from "~/components/page-title"; 9 | 10 | import ogImageSrc from "~/assets/images/social-collections.jpg"; 11 | import type { Route } from "./+types/($locale).collections.$handle"; 12 | 13 | export function meta({ data, matches }: Route.MetaArgs) { 14 | if (!data) return generateMeta(); 15 | 16 | const { collection } = data; 17 | const { siteUrl } = matches[0].data; 18 | 19 | return generateMeta({ 20 | title: collection.seo?.title || collection.title, 21 | url: siteUrl, 22 | image: ogImageSrc, 23 | }); 24 | } 25 | 26 | export async function loader({ params, request, context }: Route.LoaderArgs) { 27 | const { handle } = params; 28 | const { storefront } = context; 29 | 30 | const url = new URL(request.url); 31 | const { searchParams } = url; 32 | const variables = { handle, ...getFilterQueryVariables(searchParams) }; 33 | 34 | const collection = await getCollectionQuery(storefront, { 35 | variables: { 36 | ...variables, 37 | first: 15, 38 | }, 39 | }); 40 | 41 | return data({ collection }); 42 | } 43 | 44 | export default function Collection({ loaderData }: Route.ComponentProps) { 45 | let { collection } = loaderData; 46 | 47 | return ( 48 |
49 | 50 | {collection.title} 51 | 52 | 53 | 63 | 64 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /app/components/ui/blur-image.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef, useState } from "react"; 2 | import { Image as HydrogenImage } from "@shopify/hydrogen"; 3 | import { clsx } from "clsx"; 4 | import type { ProductImageFragment } from "storefrontapi.generated"; 5 | 6 | // I hate this, why can't I just get the props from HydrogenImage? Why is data optional? 7 | type BlurImageProps = Parameters[0] & { 8 | data: ProductImageFragment; 9 | }; 10 | 11 | /** 12 | * BlurImage shows a blurred, low-res preview while the main image loads. 13 | * It uses Shopify's CDN to request a tiny version for the preview. 14 | */ 15 | export default function BlurImage({ 16 | className, 17 | alt, 18 | data, 19 | ...props 20 | }: BlurImageProps) { 21 | const imageRef = useRef(null); 22 | const [loadState, setLoadState] = useState<"pending" | "loaded" | "error">( 23 | "pending", 24 | ); 25 | 26 | // Add ?width=32 for a tiny preview 27 | const url = data.url; 28 | const previewUrl = url.includes("?") ? `${url}&width=32` : `${url}?width=32`; 29 | 30 | useLayoutEffect(() => { 31 | const node = imageRef.current; 32 | if (!node) return; 33 | if (loadState !== "pending") return; 34 | 35 | if (node.complete) { 36 | setLoadState("loaded"); 37 | return; 38 | } 39 | 40 | node.onload = () => { 41 | setLoadState("loaded"); 42 | }; 43 | 44 | node.onerror = () => { 45 | setLoadState("error"); 46 | }; 47 | 48 | return () => { 49 | node.onload = null; 50 | node.onerror = null; 51 | }; 52 | }, [loadState]); 53 | 54 | return ( 55 |
56 | {/* Blurred preview image */} 57 | {alt} 67 | {/* Full image */} 68 | 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /app/components/page-title.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { cn } from "~/lib/cn"; 3 | import { useScrollPercentage } from "~/lib/hooks"; 4 | 5 | interface PageTitleProps { 6 | children: string; 7 | className?: string; 8 | } 9 | 10 | export function PageTitle({ children, className }: PageTitleProps) { 11 | let ref = useRef(null); 12 | let scrollPercentage = useScrollPercentage(ref); 13 | let translatePercent = Math.round(Math.min(scrollPercentage * 2, 1) * 80); 14 | 15 | return ( 16 |
23 |
24 |

25 | {children} 26 |

27 | 36 | 45 | 54 | 63 | 72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-store", 3 | "private": true, 4 | "sideEffects": false, 5 | "version": "2024.4.7", 6 | "type": "module", 7 | "scripts": { 8 | "build": "shopify hydrogen build --codegen", 9 | "dev": "shopify hydrogen dev --codegen", 10 | "preview": "shopify hydrogen preview --build", 11 | "lint": "eslint --no-error-on-unmatched-pattern .", 12 | "typecheck": "react-router typegen && tsc --noEmit", 13 | "codegen": "shopify hydrogen codegen", 14 | "format": "prettier --write .", 15 | "test": "vitest", 16 | "test:ui": "vitest --ui", 17 | "test:coverage": "vitest run --coverage" 18 | }, 19 | "dependencies": { 20 | "@radix-ui/react-dropdown-menu": "^2.1.15", 21 | "@radix-ui/react-popover": "^1.1.14", 22 | "@shopify/cli-hydrogen": "^11.1.5", 23 | "@shopify/hydrogen": "^2025.7.0", 24 | "@tailwindcss/vite": "^4.1.11", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "embla-carousel": "^8.6.0", 28 | "embla-carousel-react": "^8.6.0", 29 | "graphql": "^16.12.0", 30 | "graphql-tag": "^2.12.6", 31 | "isbot": "^5.1.32", 32 | "react": "^18.3.1", 33 | "react-dom": "^18.3.1", 34 | "react-router": "7.9.6", 35 | "tailwind-merge": "^3.3.1", 36 | "zod": "^4.0.15" 37 | }, 38 | "devDependencies": { 39 | "@eslint/compat": "^1.3.1", 40 | "@eslint/eslintrc": "^3.3.3", 41 | "@eslint/js": "^9.39.1", 42 | "@graphql-codegen/cli": "5.0.7", 43 | "@react-router/dev": "7.9.6", 44 | "@react-router/fs-routes": "7.9.6", 45 | "@shopify/cli": "^3.87.4", 46 | "@shopify/hydrogen-codegen": "^0.3.3", 47 | "@shopify/mini-oxygen": "^4.0.0", 48 | "@shopify/oxygen-workers-types": "^4.2.0", 49 | "@testing-library/dom": "^10.4.1", 50 | "@testing-library/jest-dom": "^6.9.1", 51 | "@testing-library/react": "^16.3.0", 52 | "@testing-library/user-event": "^14.6.1", 53 | "@total-typescript/ts-reset": "^0.6.1", 54 | "@types/eslint": "^9.6.1", 55 | "@types/node": "^24.10.1", 56 | "@types/react": "^18.3.21", 57 | "@types/react-dom": "^18.3.7", 58 | "@typescript-eslint/eslint-plugin": "^8.49.0", 59 | "@typescript-eslint/parser": "^8.49.0", 60 | "@vitest/coverage-v8": "^4.0.15", 61 | "@vitest/ui": "^4.0.15", 62 | "eslint": "^9.39.1", 63 | "eslint-config-prettier": "^10.1.8", 64 | "eslint-import-resolver-typescript": "^4.4.4", 65 | "eslint-plugin-eslint-comments": "^3.2.0", 66 | "eslint-plugin-import": "^2.32.0", 67 | "eslint-plugin-jest": "^29.2.1", 68 | "eslint-plugin-jsx-a11y": "^6.10.2", 69 | "eslint-plugin-react": "^7.37.5", 70 | "eslint-plugin-react-hooks": "^5.2.0", 71 | "globals": "^16.5.0", 72 | "happy-dom": "^20.0.11", 73 | "prettier": "^3.7.4", 74 | "tailwindcss": "^4.1.11", 75 | "tw-animate-css": "^1.3.6", 76 | "typescript": "^5.9.3", 77 | "vite": "^6.3.5", 78 | "vite-plugin-icons-spritesheet": "^3.0.1", 79 | "vite-tsconfig-paths": "^5.1.4", 80 | "vitest": "^4.0.15" 81 | }, 82 | "engines": { 83 | "node": ">=20.0.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/lib/filters/query-variables.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { type CollectionQueryVariables } from "storefrontapi.generated"; 3 | import { type ProductFilter } from "@shopify/hydrogen/storefront-api-types"; 4 | import { 5 | type SortKey, 6 | SORT_KEY, 7 | FILTER, 8 | getSort, 9 | getAvailable, 10 | getPrice, 11 | getProductTypes, 12 | } from "."; 13 | 14 | /** 15 | * Parses the search params, redirecting if invalid 16 | * If the search params are valid, returns filters as query variables for the 17 | * collection query 18 | */ 19 | export function getFilterQueryVariables( 20 | searchParams: URLSearchParams, 21 | ): Omit { 22 | // Get the valid values from the search params, redirecting if invalid 23 | let isValid = true; 24 | let searchParamsCopy = new URLSearchParams(searchParams); 25 | const sort = getSort(searchParams); 26 | if (!sort.isValid) { 27 | isValid = false; 28 | searchParamsCopy.delete(SORT_KEY); 29 | } 30 | const available = getAvailable(searchParams); 31 | if (!available.isValid) { 32 | isValid = false; 33 | searchParamsCopy.delete(FILTER.AVAILABLE); 34 | } 35 | const minPrice = getPrice(searchParams, FILTER.PRICE_MIN); 36 | if (!minPrice.isValid) { 37 | isValid = false; 38 | searchParamsCopy.delete(FILTER.PRICE_MIN); 39 | } 40 | const maxPrice = getPrice(searchParams, FILTER.PRICE_MAX); 41 | if (!maxPrice.isValid) { 42 | isValid = false; 43 | searchParamsCopy.delete(FILTER.PRICE_MAX); 44 | } 45 | const productTypes = getProductTypes(searchParams); 46 | if (!productTypes.isValid) { 47 | isValid = false; 48 | searchParamsCopy.delete(FILTER.PRODUCT_TYPE); 49 | for (const productType of productTypes.value) { 50 | searchParamsCopy.append(FILTER.PRODUCT_TYPE, productType); 51 | } 52 | } 53 | 54 | if (!isValid) { 55 | throw redirect(`?${searchParamsCopy.toString()}`); 56 | } 57 | 58 | // Build the filters object for the collection query 59 | 60 | const filters: ProductFilter[] = []; 61 | if (typeof available.value === "boolean") { 62 | filters.push({ available: available.value }); 63 | } 64 | 65 | const price: ProductFilter["price"] = {}; 66 | const min = minPrice.value; 67 | const max = maxPrice.value; 68 | if (typeof min === "number" || typeof max === "number") { 69 | if (typeof min === "number") { 70 | price.min = min; 71 | } 72 | if (typeof max === "number") { 73 | price.max = max; 74 | } 75 | filters.push({ price }); 76 | } 77 | 78 | productTypes.value.forEach((productType) => { 79 | filters.push({ productType }); 80 | }); 81 | 82 | return { 83 | ...(sort.value ? sortMap.get(sort.value) : {}), 84 | filters, 85 | }; 86 | } 87 | 88 | const sortMap = new Map< 89 | SortKey, 90 | Pick 91 | >([ 92 | ["price-high-to-low", { sortKey: "PRICE", reverse: true }], 93 | ["price-low-to-high", { sortKey: "PRICE", reverse: false }], 94 | ["newest", { sortKey: "CREATED", reverse: true }], 95 | ["best-selling", { sortKey: "BEST_SELLING", reverse: false }], 96 | ]); 97 | -------------------------------------------------------------------------------- /app/lib/data/header.server.ts: -------------------------------------------------------------------------------- 1 | import type { Storefront } from "@shopify/hydrogen"; 2 | import type { HeaderQuery, ShopFragment } from "storefrontapi.generated"; 3 | import { MENU_FRAGMENT } from "../fragments"; 4 | 5 | export type StoreWideSaleData = { 6 | title: string; 7 | description: string; 8 | endDateTime?: string; 9 | }; 10 | 11 | export type HeaderData = { 12 | shop: ShopFragment; 13 | menu: HeaderQuery["menu"]; 14 | storeWideSale?: StoreWideSaleData; 15 | }; 16 | 17 | export async function getHeaderData( 18 | storefront: Storefront, 19 | variables: { headerMenuHandle: string }, 20 | ): Promise { 21 | const { shop, menu, errors } = await storefront.query(HEADER_QUERY, { 22 | cache: storefront.CacheLong(), 23 | variables, 24 | }); 25 | 26 | if (errors) { 27 | console.error(errors); 28 | throw new Error("Failed to fetch header data"); 29 | } 30 | 31 | if (!shop) { 32 | throw new Response("Shop data not found", { status: 404 }); 33 | } 34 | 35 | // Parse and validate store wide sale data 36 | let storeWideSale: StoreWideSaleData | undefined = undefined; 37 | 38 | if (shop.storeWideSale?.reference) { 39 | let { title, description, endDateTime } = shop.storeWideSale.reference; 40 | 41 | // Check if the sale is still active 42 | if ( 43 | !endDateTime?.value || 44 | Date.now() < new Date(endDateTime.value).getTime() 45 | ) { 46 | storeWideSale = { 47 | title: title?.value ?? "", 48 | description: description?.value ?? "", 49 | endDateTime: endDateTime?.value ?? undefined, 50 | }; 51 | } 52 | } 53 | 54 | return { 55 | shop: { 56 | id: shop.id, 57 | name: shop.name, 58 | description: shop.description, 59 | primaryDomain: shop.primaryDomain, 60 | brand: shop.brand, 61 | }, 62 | menu, 63 | storeWideSale, 64 | }; 65 | } 66 | 67 | const HEADER_QUERY = `#graphql 68 | fragment Shop on Shop { 69 | id 70 | name 71 | description 72 | primaryDomain { 73 | url 74 | } 75 | brand { 76 | logo { 77 | image { 78 | url 79 | } 80 | } 81 | } 82 | } 83 | 84 | query Header( 85 | $country: CountryCode 86 | $headerMenuHandle: String! 87 | $language: LanguageCode 88 | ) @inContext(language: $language, country: $country) { 89 | shop { 90 | ...Shop 91 | # Metafield is a custom field that is used to store info about a storewide sale. 92 | storeWideSale: metafield(namespace: "custom", key: "storewide_sale") { 93 | reference { 94 | ... on Metaobject { 95 | title: field(key: "title") { 96 | value 97 | } 98 | description: field(key: "description") { 99 | value 100 | } 101 | endDateTime: field(key: "end_date_and_time") { 102 | value 103 | } 104 | } 105 | } 106 | } 107 | } 108 | menu(handle: $headerMenuHandle) { 109 | ...Menu 110 | } 111 | } 112 | ${MENU_FRAGMENT} 113 | ` as const; 114 | -------------------------------------------------------------------------------- /app/lib/data/hero.server.ts: -------------------------------------------------------------------------------- 1 | import type { Storefront } from "@shopify/hydrogen"; 2 | import type { ProductImageFragment } from "storefrontapi.generated"; 3 | import { PRODUCT_IMAGE_FRAGMENT } from "./product.server"; 4 | 5 | export type HeroData = { 6 | masthead: ProductImageFragment; 7 | assetImages: Array<{ 8 | image: ProductImageFragment; 9 | }>; 10 | product: { 11 | handle: string; 12 | title: string; 13 | }; 14 | }; 15 | 16 | export async function getHeroData(storefront: Storefront): Promise { 17 | let { hero, errors } = await storefront.query(HERO_QUERY, { 18 | cache: storefront.CacheLong(), 19 | }); 20 | 21 | if (errors) { 22 | console.error(errors); 23 | throw new Error("Failed to fetch hero data"); 24 | } 25 | 26 | if (!hero) { 27 | throw new Response("Hero data not found", { status: 404 }); 28 | } 29 | 30 | let mastheadReference = hero?.masthead?.reference; 31 | if ( 32 | mastheadReference?.__typename !== "MediaImage" || 33 | !mastheadReference.image 34 | ) { 35 | throw new Response("Hero masthead image not found", { status: 500 }); 36 | } 37 | 38 | let product = hero?.product?.reference; 39 | if (product?.__typename !== "Product") { 40 | throw new Response("Hero product not found", { status: 500 }); 41 | } 42 | 43 | let assetImagesNodes = hero?.assetImages?.references?.nodes; 44 | if (!assetImagesNodes || assetImagesNodes.length === 0) { 45 | throw new Response("Hero asset images not found", { status: 500 }); 46 | } 47 | 48 | let assetImages = assetImagesNodes.map((node) => { 49 | if (node.__typename !== "MediaImage" || !node.image) { 50 | throw new Response("Hero asset image not found", { status: 500 }); 51 | } 52 | 53 | let url = `${node.image.url}?width=1600&height=900&crop=center`; 54 | 55 | return { image: { ...node.image, url } }; 56 | }); 57 | 58 | return { 59 | masthead: mastheadReference.image, 60 | assetImages, 61 | product: { 62 | handle: product.handle, 63 | title: product.title, 64 | }, 65 | }; 66 | } 67 | 68 | let HERO_QUERY = `#graphql 69 | ${PRODUCT_IMAGE_FRAGMENT} 70 | query Hero ( 71 | $country: CountryCode 72 | $language: LanguageCode 73 | ) @inContext(country: $country, language: $language) { 74 | hero: metaobject(handle: {handle: "hero_1", type: "hero"}) { 75 | masthead: field(key: "masthead") { 76 | reference { 77 | __typename 78 | ... on MediaImage { 79 | id 80 | alt 81 | image { 82 | ...ProductImage 83 | } 84 | } 85 | } 86 | } 87 | assetImages: field(key: "asset_images") { 88 | references(first: 100) { 89 | nodes { 90 | __typename 91 | ... on MediaImage { 92 | id 93 | alt 94 | image { 95 | ...ProductImage 96 | } 97 | } 98 | } 99 | } 100 | } 101 | product: field(key: "product") { 102 | reference { 103 | __typename 104 | ... on Product { 105 | handle 106 | title 107 | } 108 | } 109 | } 110 | } 111 | } 112 | ` as const; 113 | -------------------------------------------------------------------------------- /app/lib/data/lookbook.server.ts: -------------------------------------------------------------------------------- 1 | import type { Storefront } from "@shopify/hydrogen"; 2 | import type { ProductImageFragment } from "storefrontapi.generated"; 3 | import type { MoneyV2 } from "@shopify/hydrogen/customer-account-api-types"; 4 | import { PRODUCT_IMAGE_FRAGMENT } from "./product.server"; 5 | import { getFocalPoint } from "~/lib/image-utils"; 6 | 7 | export type LookbookEntry = { 8 | image: ProductImageFragment & { focalPoint?: { x: number; y: number } }; 9 | product?: { 10 | handle: string; 11 | title: string; 12 | price: MoneyV2; 13 | }; 14 | }; 15 | 16 | export async function getLookbookEntries( 17 | storefront: Storefront, 18 | ): Promise { 19 | let { lookbookEntries, errors } = await storefront.query(LOOKBOOK_QUERY, { 20 | cache: storefront.CacheLong(), 21 | }); 22 | 23 | if (errors) { 24 | console.error(errors); 25 | throw new Error("Failed to fetch lookbook entries"); 26 | } 27 | 28 | // This assumes that the the lookbook entry handles are in order: 29 | // e.g. "lookbook_entry_1", "lookbook_entry_2", "lookbook_entry_3", etc. 30 | return lookbookEntries.nodes 31 | .sort((a, b) => a.handle.localeCompare(b.handle)) 32 | .map((entry) => { 33 | let lookbookImage = entry.fields.find( 34 | (field) => field.reference?.__typename === "MediaImage", 35 | )?.reference; 36 | 37 | if (lookbookImage?.__typename !== "MediaImage" || !lookbookImage.image) { 38 | throw new Response("Lookbook image not found", { status: 500 }); 39 | } 40 | 41 | let product = entry.fields.find( 42 | (field) => field.reference?.__typename === "Product", 43 | )?.reference; 44 | 45 | let focalPoint = getFocalPoint(lookbookImage.presentation?.asJson); 46 | 47 | return { 48 | image: { 49 | ...lookbookImage.image, 50 | focalPoint, 51 | }, 52 | ...(product?.__typename === "Product" && { 53 | product: { 54 | handle: product.handle, 55 | title: product.title, 56 | price: product.priceRange.minVariantPrice, 57 | }, 58 | }), 59 | }; 60 | }); 61 | } 62 | 63 | let LOOKBOOK_QUERY = `#graphql 64 | ${PRODUCT_IMAGE_FRAGMENT} 65 | query LookbookImages ( 66 | $country: CountryCode 67 | $language: LanguageCode 68 | ) @inContext(country: $country, language: $language) { 69 | lookbookEntries: metaobjects(type: "lookbook_entry", first: 5) { 70 | nodes { 71 | handle 72 | fields { 73 | __typename 74 | reference { 75 | __typename 76 | ... on MediaImage { 77 | id 78 | alt 79 | presentation { 80 | id 81 | asJson(format: IMAGE) 82 | } 83 | image { 84 | ...ProductImage 85 | } 86 | } 87 | ... on Product { 88 | id 89 | handle 90 | title 91 | priceRange { 92 | minVariantPrice { 93 | amount 94 | currencyCode 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | ` as const; 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Store 2 | 3 | Welcome to the Remix Store built with Shopify, React Router, and Hydrogen! 4 | 5 | This is the codebase behind **[shop.remix.run](https://shop.remix.run)**. Run it locally to explore how to build a production headless Shopify store with modern web technologies. 6 | 7 | ## Getting Started 8 | 9 | ### Install dependencies 10 | 11 | ```sh 12 | npm install 13 | ``` 14 | 15 | ## Local development 16 | 17 | ### Environment setup 18 | 19 | Copy the example environment file to create your local environment: 20 | 21 | ```bash 22 | cp .env.example .env 23 | ``` 24 | 25 | ⚠️ **Important:** This connects to the live production store. Any purchases will charge real money and ship actual Remix merch. 26 | 27 | ```bash 28 | npm run dev 29 | ``` 30 | 31 | You'll have a local version of the Remix Store running with real product data, inventory, and checkout functionality. 32 | 33 | ## Building for production 34 | 35 | ```bash 36 | npm run build 37 | ``` 38 | 39 | ### Connecting to the Shopify Store 40 | 41 | If you've never setup the Hydrogen CLI, run the following command 42 | 43 | ```sh 44 | npx shopify hydrogen shortcut 45 | ``` 46 | 47 | If you have access to the Shopify store, go ahead and link via hydrogen 48 | 49 | ```sh 50 | h2 link 51 | ``` 52 | 53 | ```sh 54 | h2 pull 55 | ``` 56 | 57 | ## Data Structures in Shopify Admin 58 | 59 | Eventually these docs might make it somewhere else, but just trying to capture some quirks about how 60 | Shopify Admin feeds into this project. 61 | 62 | ### Hero 63 | 64 | The hero component uses data from the Hero metaobject with 3 fields: 65 | 66 | 1. Masthead 67 | 2. Asset Images (frames for animation) 68 | 3. Product link 69 | 70 | See [hero.server.ts](app/lib/data/hero.server.ts) for the GraphQL query. 71 | 72 | ### Lookbook 73 | 74 | Uses "Lookbook Entry" metaobjects with: 75 | 76 | 1. Image 77 | 2. Product (optional) 78 | 79 | See [lookbook.server.ts](app/lib/data/lookbook.server.ts) for implementation. 80 | 81 | ### Product Metafields 82 | 83 | Products use custom metafields under "Product metafields": 84 | 85 | 1. Description 86 | 2. Technical Description 87 | 88 | ### Store-wide sale 89 | 90 | The store-wide sales require 2 things in Shopify Admin: 91 | 92 | 1. A metaobject with the following fields: 93 | 1. Title 94 | 2. Description 95 | 3. End date 96 | 97 | 2. An automatic discount 98 | 99 | The data is fetched in [header.server.ts](app/lib/data/header.server.ts) and accessed via `useCartDiscounts` defined in [cart](app/components/cart.tsx). 100 | 101 | Note: there is a 1 hour cache on the header data, so updates will not be live without a redeploy. 102 | 103 | We use metafields instead of the default description to access rich text data via GraphQL. 104 | 105 | ## Contributing 106 | 107 | This is the production codebase for shop.remix.run. We welcome feedback and bug reports via GitHub issues. 108 | 109 | See an issue you'd like to fix? Please open a PR! 110 | 111 | ## License 112 | 113 | MIT License - see [LICENSE.md](LICENSE.md) for details. 114 | 115 | ## Related Resources 116 | 117 | - [Hydrogen Documentation](https://shopify.dev/docs/api/hydrogen) 118 | - [React Router Documentation](https://reactrouter.com/) 119 | 120 | --- 121 | 122 | Built with ❤️ by the [Remix](https://remix.run) team 123 | -------------------------------------------------------------------------------- /app/routes/pages/[robots.txt].tsx: -------------------------------------------------------------------------------- 1 | import { parseGid } from "@shopify/hydrogen"; 2 | import type { Route } from "./+types/[robots.txt]"; 3 | 4 | export async function loader({ request, context }: Route.LoaderArgs) { 5 | const url = new URL(request.url); 6 | 7 | const { shop } = await context.storefront.query(ROBOTS_QUERY); 8 | 9 | const shopId = parseGid(shop.id).id; 10 | const body = robotsTxtData({ url: url.origin, shopId }); 11 | 12 | return new Response(body, { 13 | status: 200, 14 | headers: { 15 | "Content-Type": "text/plain", 16 | 17 | "Cache-Control": `max-age=${60 * 60 * 24}`, 18 | }, 19 | }); 20 | } 21 | 22 | function robotsTxtData({ url, shopId }: { shopId?: string; url?: string }) { 23 | const sitemapUrl = url ? `${url}/sitemap.xml` : undefined; 24 | 25 | return ` 26 | User-agent: * 27 | ${generalDisallowRules({ sitemapUrl, shopId })} 28 | 29 | # Google adsbot ignores robots.txt unless specifically named! 30 | User-agent: adsbot-google 31 | Disallow: /checkouts/ 32 | Disallow: /checkout 33 | Disallow: /carts 34 | Disallow: /orders 35 | ${shopId ? `Disallow: /${shopId}/checkouts` : ""} 36 | ${shopId ? `Disallow: /${shopId}/orders` : ""} 37 | Disallow: /*?*oseid=* 38 | Disallow: /*preview_theme_id* 39 | Disallow: /*preview_script_id* 40 | 41 | User-agent: Nutch 42 | Disallow: / 43 | 44 | User-agent: AhrefsBot 45 | Crawl-delay: 10 46 | ${generalDisallowRules({ sitemapUrl, shopId })} 47 | 48 | User-agent: AhrefsSiteAudit 49 | Crawl-delay: 10 50 | ${generalDisallowRules({ sitemapUrl, shopId })} 51 | 52 | User-agent: MJ12bot 53 | Crawl-Delay: 10 54 | 55 | User-agent: Pinterest 56 | Crawl-delay: 1 57 | `.trim(); 58 | } 59 | 60 | /** 61 | * This function generates disallow rules that generally follow what Shopify's 62 | * Online Store has as defaults for their robots.txt 63 | */ 64 | function generalDisallowRules({ 65 | shopId, 66 | sitemapUrl, 67 | }: { 68 | shopId?: string; 69 | sitemapUrl?: string; 70 | }) { 71 | return `Disallow: /admin 72 | Disallow: /cart 73 | Disallow: /orders 74 | Disallow: /checkouts/ 75 | Disallow: /checkout 76 | ${shopId ? `Disallow: /${shopId}/checkouts` : ""} 77 | ${shopId ? `Disallow: /${shopId}/orders` : ""} 78 | Disallow: /carts 79 | Disallow: /account 80 | Disallow: /collections/*sort_by* 81 | Disallow: /*/collections/*sort_by* 82 | Disallow: /collections/*+* 83 | Disallow: /collections/*%2B* 84 | Disallow: /collections/*%2b* 85 | Disallow: /*/collections/*+* 86 | Disallow: /*/collections/*%2B* 87 | Disallow: /*/collections/*%2b* 88 | Disallow: */collections/*filter*&*filter* 89 | Disallow: /blogs/*+* 90 | Disallow: /blogs/*%2B* 91 | Disallow: /blogs/*%2b* 92 | Disallow: /*/blogs/*+* 93 | Disallow: /*/blogs/*%2B* 94 | Disallow: /*/blogs/*%2b* 95 | Disallow: /*?*oseid=* 96 | Disallow: /*preview_theme_id* 97 | Disallow: /*preview_script_id* 98 | Disallow: /policies/ 99 | Disallow: /*/*?*ls=*&ls=* 100 | Disallow: /*/*?*ls%3D*%3Fls%3D* 101 | Disallow: /*/*?*ls%3d*%3fls%3d* 102 | Disallow: /search 103 | Allow: /search/ 104 | Disallow: /search/?* 105 | Disallow: /apple-app-site-association 106 | Disallow: /.well-known/shopify/monorail 107 | ${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ""}`; 108 | } 109 | 110 | const ROBOTS_QUERY = `#graphql 111 | query StoreRobots($country: CountryCode, $language: LanguageCode) 112 | @inContext(country: $country, language: $language) { 113 | shop { 114 | id 115 | } 116 | } 117 | ` as const; 118 | -------------------------------------------------------------------------------- /app/lib/data/collection.server.ts: -------------------------------------------------------------------------------- 1 | import type { Storefront } from "@shopify/hydrogen"; 2 | import { PRODUCT_IMAGE_FRAGMENT } from "./product.server"; 3 | 4 | import type { 5 | CollectionQueryVariables, 6 | CollectionProductFragment, 7 | CollectionQuery, 8 | } from "storefrontapi.generated"; 9 | import type { MoneyV2, PageInfo } from "@shopify/hydrogen/storefront-api-types"; 10 | export type CollectionProductData = Pick< 11 | CollectionProductFragment, 12 | "id" | "handle" | "title" | "images" 13 | > & { 14 | price: MoneyV2; 15 | }; 16 | 17 | export type CollectionData = Pick< 18 | NonNullable, 19 | "id" | "handle" | "title" | "description" | "seo" 20 | > & { 21 | productsPageInfo: PageInfo; 22 | products: CollectionProductData[]; 23 | }; 24 | 25 | export async function getCollectionQuery( 26 | storefront: Storefront, 27 | { variables }: { variables: CollectionQueryVariables }, 28 | ): Promise { 29 | let { collection } = await storefront.query(COLLECTION_QUERY, { 30 | variables, 31 | }); 32 | 33 | if (!collection) { 34 | throw new Response("Collection not found", { 35 | status: 404, 36 | }); 37 | } 38 | 39 | let products = collection.products.nodes; 40 | 41 | return { 42 | ...collection, 43 | productsPageInfo: collection.products.pageInfo, 44 | products: products.map((fullProductNode) => { 45 | const { priceRange, ...product } = fullProductNode; 46 | return { 47 | id: product.id, 48 | handle: product.handle, 49 | title: product.title, 50 | images: product.images, 51 | price: priceRange.maxVariantPrice, 52 | }; 53 | }), 54 | }; 55 | } 56 | 57 | const COLLECTION_PRODUCT_FRAGMENT = `#graphql 58 | fragment MoneyProductItem on MoneyV2 { 59 | amount 60 | currencyCode 61 | } 62 | fragment CollectionProduct on Product { 63 | id 64 | handle 65 | title 66 | priceRange { 67 | minVariantPrice { 68 | ...MoneyProductItem 69 | } 70 | maxVariantPrice { 71 | ...MoneyProductItem 72 | } 73 | } 74 | images(first: 2) { 75 | nodes { 76 | ...ProductImage 77 | } 78 | } 79 | } 80 | ${PRODUCT_IMAGE_FRAGMENT} 81 | ` as const; 82 | 83 | // NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection 84 | const COLLECTION_QUERY = `#graphql 85 | ${COLLECTION_PRODUCT_FRAGMENT} 86 | query Collection( 87 | $handle: String! 88 | $country: CountryCode 89 | $language: LanguageCode 90 | $first: Int 91 | $last: Int 92 | $startCursor: String 93 | $endCursor: String 94 | $sortKey: ProductCollectionSortKeys 95 | $reverse: Boolean 96 | $filters: [ProductFilter!] 97 | ) @inContext(country: $country, language: $language) { 98 | collection(handle: $handle) { 99 | id 100 | handle 101 | title 102 | description 103 | seo { 104 | title 105 | } 106 | products( 107 | first: $first, 108 | last: $last, 109 | before: $startCursor, 110 | after: $endCursor 111 | sortKey: $sortKey 112 | reverse: $reverse 113 | filters: $filters 114 | ) { 115 | nodes { 116 | ...CollectionProduct 117 | } 118 | pageInfo { 119 | hasPreviousPage 120 | hasNextPage 121 | endCursor 122 | startCursor 123 | } 124 | } 125 | } 126 | } 127 | ` as const; 128 | -------------------------------------------------------------------------------- /app/lib/data/policy.server.ts: -------------------------------------------------------------------------------- 1 | import type { Storefront } from "@shopify/hydrogen"; 2 | import type { Shop } from "@shopify/hydrogen/storefront-api-types"; 3 | 4 | type PolicyKey = keyof Pick< 5 | Shop, 6 | "privacyPolicy" | "shippingPolicy" | "termsOfService" | "refundPolicy" 7 | >; 8 | 9 | export async function getPolicyData( 10 | storefront: Storefront, 11 | { handle }: { handle: string }, 12 | ) { 13 | if (handle === "contact-information") { 14 | const data = await storefront.query(CONTACT_PAGE_QUERY, { 15 | variables: { 16 | handle: "contact", 17 | language: storefront.i18n?.language, 18 | }, 19 | cache: storefront.CacheLong(), 20 | }); 21 | 22 | const page = data.page; 23 | if (!page) { 24 | throw new Response("Could not find contact information", { status: 404 }); 25 | } 26 | 27 | return { 28 | body: page.body, 29 | handle: "contact-information", 30 | id: page.id, 31 | title: page.title, 32 | url: page.onlineStoreUrl, 33 | }; 34 | } 35 | 36 | const policyKey = transformHandleToKey(handle); 37 | 38 | const data = await storefront.query(POLICY_CONTENT_QUERY, { 39 | variables: { 40 | privacyPolicy: false, 41 | shippingPolicy: false, 42 | termsOfService: false, 43 | refundPolicy: false, 44 | [policyKey]: true, 45 | language: storefront.i18n?.language, 46 | }, 47 | cache: storefront.CacheLong(), 48 | }); 49 | 50 | const policy = data.shop?.[policyKey]; 51 | 52 | if (!policy) { 53 | throw new Response("Could not find the policy", { status: 404 }); 54 | } 55 | 56 | return policy; 57 | } 58 | 59 | function transformHandleToKey(handle: string): PolicyKey { 60 | // Transform kebab-case to camelCase 61 | const key = handle.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 62 | 63 | if (!isPolicyKey(key)) { 64 | throw new Response(`Invalid policy handle: ${handle}`, { status: 404 }); 65 | } 66 | 67 | // Type assertion is safe because we've validated the handle 68 | return key; 69 | } 70 | 71 | function isPolicyKey(handle: string): handle is PolicyKey { 72 | return [ 73 | "refundPolicy", 74 | "privacyPolicy", 75 | "shippingPolicy", 76 | "termsOfService", 77 | ].includes(handle); 78 | } 79 | 80 | const POLICY_CONTENT_QUERY = `#graphql 81 | fragment Policy on ShopPolicy { 82 | body 83 | handle 84 | id 85 | title 86 | url 87 | } 88 | query Policy( 89 | $country: CountryCode 90 | $language: LanguageCode 91 | $privacyPolicy: Boolean! 92 | $refundPolicy: Boolean! 93 | $shippingPolicy: Boolean! 94 | $termsOfService: Boolean! 95 | ) @inContext(language: $language, country: $country) { 96 | shop { 97 | privacyPolicy @include(if: $privacyPolicy) { 98 | ...Policy 99 | } 100 | shippingPolicy @include(if: $shippingPolicy) { 101 | ...Policy 102 | } 103 | termsOfService @include(if: $termsOfService) { 104 | ...Policy 105 | } 106 | refundPolicy @include(if: $refundPolicy) { 107 | ...Policy 108 | } 109 | } 110 | } 111 | ` as const; 112 | 113 | const CONTACT_PAGE_QUERY = `#graphql 114 | query ContactPage($handle: String!, $language: LanguageCode) @inContext(language: $language) { 115 | page(handle: $handle) { 116 | body 117 | id 118 | title 119 | onlineStoreUrl 120 | } 121 | } 122 | ` as const; 123 | -------------------------------------------------------------------------------- /app/lib/fragments.ts: -------------------------------------------------------------------------------- 1 | const MERCHANDISE_PRODUCT_FRAGMENT = `#graphql 2 | fragment MerchadiseProduct on Product { 3 | handle 4 | title 5 | id 6 | vendor 7 | } 8 | ` as const; 9 | 10 | // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart 11 | export const CART_QUERY_FRAGMENT = `#graphql 12 | fragment Money on MoneyV2 { 13 | currencyCode 14 | amount 15 | } 16 | fragment CartLine on CartLine { 17 | id 18 | quantity 19 | attributes { 20 | key 21 | value 22 | } 23 | cost { 24 | totalAmount { 25 | ...Money 26 | } 27 | amountPerQuantity { 28 | ...Money 29 | } 30 | compareAtAmountPerQuantity { 31 | ...Money 32 | } 33 | } 34 | merchandise { 35 | ... on ProductVariant { 36 | id 37 | availableForSale 38 | compareAtPrice { 39 | ...Money 40 | } 41 | price { 42 | ...Money 43 | } 44 | requiresShipping 45 | title 46 | image { 47 | id 48 | url 49 | altText 50 | width 51 | height 52 | 53 | } 54 | product { 55 | ...MerchadiseProduct 56 | } 57 | selectedOptions { 58 | name 59 | value 60 | } 61 | } 62 | } 63 | } 64 | fragment CartApiQuery on Cart { 65 | updatedAt 66 | id 67 | checkoutUrl 68 | totalQuantity 69 | buyerIdentity { 70 | countryCode 71 | customer { 72 | id 73 | email 74 | firstName 75 | lastName 76 | displayName 77 | } 78 | email 79 | phone 80 | } 81 | lines(first: $numCartLines) { 82 | nodes { 83 | ...CartLine 84 | } 85 | } 86 | cost { 87 | subtotalAmount { 88 | ...Money 89 | } 90 | totalAmount { 91 | ...Money 92 | } 93 | totalDutyAmount { 94 | ...Money 95 | } 96 | totalTaxAmount { 97 | ...Money 98 | } 99 | } 100 | discountAllocations { 101 | discountedAmount { 102 | ...Money 103 | } 104 | } 105 | note 106 | attributes { 107 | key 108 | value 109 | } 110 | discountCodes { 111 | code 112 | applicable 113 | } 114 | } 115 | ${MERCHANDISE_PRODUCT_FRAGMENT} 116 | ` as const; 117 | 118 | export const MENU_FRAGMENT = `#graphql 119 | fragment MenuItem on MenuItem { 120 | id 121 | resourceId 122 | tags 123 | title 124 | type 125 | url 126 | } 127 | fragment ChildMenuItem on MenuItem { 128 | ...MenuItem 129 | } 130 | fragment ParentMenuItem on MenuItem { 131 | ...MenuItem 132 | items { 133 | ...ChildMenuItem 134 | } 135 | } 136 | fragment Menu on Menu { 137 | id 138 | title 139 | items { 140 | ...ParentMenuItem 141 | } 142 | } 143 | ` as const; 144 | 145 | export const FOOTER_QUERY = `#graphql 146 | query Footer( 147 | $country: CountryCode 148 | $language: LanguageCode 149 | ) @inContext(language: $language, country: $country) { 150 | menu(handle: "footer") { 151 | ...Menu 152 | } 153 | } 154 | ${MENU_FRAGMENT} 155 | ` as const; 156 | 157 | export const PRODUCT_SIDEBAR_MENU_QUERY = `#graphql 158 | query ProductSidebarMenu( 159 | $country: CountryCode 160 | $language: LanguageCode 161 | ) @inContext(language: $language, country: $country) { 162 | menu(handle: "product-sidebar-menu") { 163 | ...Menu 164 | } 165 | } 166 | ${MENU_FRAGMENT} 167 | ` as const; 168 | -------------------------------------------------------------------------------- /app/routes/pages/components.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet } from "react-router"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuTrigger, 5 | DropdownMenuItem, 6 | DropdownMenuContent, 7 | } from "~/components/ui/dropdown-menu"; 8 | import { cn } from "~/lib/cn"; 9 | import { generateMeta } from "~/lib/meta"; 10 | import type { Route } from "./+types/components"; 11 | 12 | export function meta({ data, matches }: Route.MetaArgs) { 13 | const selectedComponent = data?.selectedComponent; 14 | const title = selectedComponent 15 | ? `${selectedComponent} | Component Library` 16 | : "Component Library"; 17 | 18 | // Try to get siteUrl from root data if available 19 | const siteUrl = matches[0]?.data?.siteUrl; 20 | 21 | return generateMeta({ 22 | title: title.charAt(0).toUpperCase() + title.slice(1), 23 | description: "Component library for the Remix Store", 24 | url: siteUrl, 25 | }); 26 | } 27 | 28 | export async function loader({ request }: Route.LoaderArgs) { 29 | // TODO: turn this on only for the most prodest of environments 30 | if (process.env.NODE_ENV === "production") { 31 | throw new Response("Not found", { status: 404 }); 32 | } 33 | 34 | const componentFiles = import.meta.glob("../pages/components.*.tsx", { 35 | eager: true, 36 | }); 37 | 38 | const paths = Object.keys(componentFiles).map((path) => { 39 | const routePath = path.replace(/.*components\.(.*)\.tsx$/, "$1"); 40 | return routePath; 41 | }); 42 | 43 | const url = new URL(request.url); 44 | const selectedComponent = url.pathname.split("/").at(-1); 45 | 46 | // Return the paths and selectedComponent as loader data 47 | return { componentRoutes: paths, selectedComponent }; 48 | } 49 | 50 | export default function Components({ loaderData }: Route.ComponentProps) { 51 | const { componentRoutes, selectedComponent } = loaderData; 52 | 53 | return ( 54 |
55 | 80 |
81 | 82 |
83 |
84 | ); 85 | } 86 | 87 | export const shouldRevalidate = () => true; 88 | 89 | export function Section({ 90 | title, 91 | className, 92 | children, 93 | }: { 94 | title: string; 95 | className?: string; 96 | children: React.ReactNode; 97 | }) { 98 | return ( 99 |
100 |

{title}

101 |
{children}
102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /app/lib/hooks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A collection of custom hooks, they can get moved out if it becomes unwieldy 3 | */ 4 | 5 | import { 6 | useEffect, 7 | useState, 8 | useLayoutEffect as React_useLayoutEffect, 9 | useSyncExternalStore, 10 | } from "react"; 11 | 12 | // Taken from https://github.com/sergiodxa/remix-utils/blob/main/src/react/use-hydrated.ts#L25 13 | 14 | function subscribe() { 15 | return () => {}; 16 | } 17 | 18 | export function useHydrated() { 19 | return useSyncExternalStore( 20 | subscribe, 21 | () => true, 22 | () => false, 23 | ); 24 | } 25 | 26 | // ---- useLayoutEffect ---- 27 | const canUseDOM = !!( 28 | typeof window !== "undefined" && 29 | window.document && 30 | window.document.createElement 31 | ); 32 | 33 | export const useLayoutEffect = canUseDOM ? React_useLayoutEffect : useEffect; 34 | 35 | function getMediaQuery() { 36 | if (typeof window === "undefined") return undefined; 37 | return window.matchMedia("(prefers-reduced-motion: reduce)"); 38 | } 39 | 40 | export function usePrefersReducedMotion() { 41 | let [prefersReducedMotion, setPrefersReducedMotion] = useState(() => 42 | Boolean(getMediaQuery()?.matches), 43 | ); 44 | 45 | useEffect(() => { 46 | // Set initial value 47 | let mediaQuery = getMediaQuery(); 48 | if (!mediaQuery) return; 49 | 50 | // Update state when preference changes 51 | let handleChange = (event: MediaQueryListEvent) => { 52 | setPrefersReducedMotion(event.matches); 53 | }; 54 | 55 | // Modern browsers support addEventListener on MediaQueryList 56 | mediaQuery.addEventListener("change", handleChange); 57 | 58 | return () => { 59 | mediaQuery.removeEventListener("change", handleChange); 60 | }; 61 | }, []); 62 | 63 | return prefersReducedMotion; 64 | } 65 | 66 | /** 67 | * Hook that calculates what percentage of an element has been scrolled past 68 | * Respects prefers-reduced-motion setting 69 | * @param ref - React ref to the element to measure 70 | * @returns A number between 0 and 1 representing the percentage scrolled past 71 | */ 72 | export function useScrollPercentage(ref: React.RefObject) { 73 | let [scrollPercentage, setScrollPercentage] = useState(0); 74 | let prefersReducedMotion = usePrefersReducedMotion(); 75 | 76 | useLayoutEffect(() => { 77 | // If user prefers reduced motion, don't update scroll percentage 78 | if (prefersReducedMotion) return; 79 | 80 | let rafId: number; 81 | 82 | let calculateVisibility = () => { 83 | rafId = requestAnimationFrame(() => { 84 | if (!ref.current) return; 85 | 86 | const height = ref.current.offsetHeight; 87 | const rect = ref.current.getBoundingClientRect(); 88 | const scrollY = window.scrollY; 89 | const elementTop = scrollY + rect.top; 90 | 91 | // If we've scrolled past the element, it's 0% visible 92 | if (scrollY >= elementTop + height) { 93 | return; 94 | } 95 | 96 | let percentage = 97 | 1 - 98 | Math.max(0, Math.min(1, (elementTop + height - scrollY) / height)); 99 | setScrollPercentage(percentage); 100 | }); 101 | }; 102 | 103 | // Calculate initial visibility 104 | calculateVisibility(); 105 | 106 | // Set up scroll listener 107 | window.addEventListener("scroll", calculateVisibility, { passive: true }); 108 | // Only need resize if window height affects calculations 109 | window.addEventListener("resize", calculateVisibility); 110 | 111 | return () => { 112 | window.removeEventListener("scroll", calculateVisibility); 113 | window.removeEventListener("resize", calculateVisibility); 114 | cancelAnimationFrame(rafId); 115 | }; 116 | }, [ref, prefersReducedMotion]); 117 | 118 | return scrollPercentage; 119 | } 120 | -------------------------------------------------------------------------------- /app/lib/data/product.server.ts: -------------------------------------------------------------------------------- 1 | import type { Storefront } from "@shopify/hydrogen"; 2 | 3 | import type { ProductQueryVariables } from "storefrontapi.generated"; 4 | import { PRODUCT_SIDEBAR_MENU_QUERY } from "../fragments"; 5 | 6 | export async function getProductData( 7 | storefront: Storefront, 8 | { variables }: { variables: ProductQueryVariables }, 9 | ) { 10 | const { product } = await storefront.query(PRODUCT_QUERY, { variables }); 11 | 12 | if (!product?.id) { 13 | throw new Response("Product not found", { status: 404 }); 14 | } 15 | 16 | // Note: we only support filter options for product variants 17 | if (product.options) { 18 | product.options = product.options.filter( 19 | (option) => option.name === "Size", 20 | ); 21 | } 22 | 23 | return { 24 | ...product, 25 | subscribeIfBackInStock: product.subscribeIfBackInStock?.value === "true", 26 | }; 27 | } 28 | 29 | export type MenuItem = { 30 | label: string; 31 | to: string; 32 | }; 33 | 34 | export async function getProductMenu(storefront: Storefront) { 35 | let data = await storefront.query(PRODUCT_SIDEBAR_MENU_QUERY, { 36 | cache: storefront.CacheLong(), 37 | }); 38 | 39 | let menu: MenuItem[] = []; 40 | 41 | if (!data.menu) { 42 | return menu; 43 | } 44 | 45 | for (let item of data.menu.items) { 46 | if (!item.url) continue; 47 | menu.push({ 48 | label: item.title, 49 | to: item.url, 50 | }); 51 | } 52 | return menu; 53 | } 54 | 55 | export const PRODUCT_IMAGE_FRAGMENT = `#graphql 56 | fragment ProductImage on Image { 57 | id 58 | altText 59 | url 60 | width 61 | height 62 | } 63 | ` as const; 64 | 65 | const PRODUCT_VARIANT_FRAGMENT = `#graphql 66 | fragment ProductVariant on ProductVariant { 67 | availableForSale 68 | compareAtPrice { 69 | amount 70 | currencyCode 71 | } 72 | id 73 | image { 74 | ...ProductImage 75 | } 76 | price { 77 | amount 78 | currencyCode 79 | } 80 | product { 81 | title 82 | handle 83 | } 84 | selectedOptions { 85 | name 86 | value 87 | } 88 | sku 89 | title 90 | unitPrice { 91 | amount 92 | currencyCode 93 | } 94 | } 95 | ${PRODUCT_IMAGE_FRAGMENT} 96 | ` as const; 97 | 98 | const PRODUCT_DETAIL_FRAGMENT = `#graphql 99 | fragment Product on Product { 100 | id 101 | title 102 | vendor 103 | handle 104 | encodedVariantExistence 105 | encodedVariantAvailability 106 | category { 107 | name 108 | } 109 | options { 110 | name 111 | optionValues { 112 | name 113 | firstSelectableVariant { 114 | ...ProductVariant 115 | } 116 | swatch { 117 | color 118 | image { 119 | previewImage { 120 | url 121 | } 122 | } 123 | } 124 | } 125 | } 126 | selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { 127 | ...ProductVariant 128 | } 129 | adjacentVariants (selectedOptions: $selectedOptions) { 130 | ...ProductVariant 131 | } 132 | images(first: 5) { 133 | nodes { 134 | ...ProductImage 135 | } 136 | } 137 | seo { 138 | description 139 | title 140 | } 141 | customDescription: metafield(key: "description", namespace: "custom") { 142 | value 143 | } 144 | technicalDescription: metafield(key: "technical_description", namespace: "custom") { 145 | value 146 | } 147 | subscribeIfBackInStock: metafield(key: "subscribe_if_back_in_stock", namespace: "custom") { 148 | value 149 | } 150 | availableForSale 151 | } 152 | ${PRODUCT_VARIANT_FRAGMENT} 153 | ` as const; 154 | 155 | const PRODUCT_QUERY = `#graphql 156 | query Product( 157 | $country: CountryCode 158 | $handle: String! 159 | $language: LanguageCode 160 | $selectedOptions: [SelectedOptionInput!]! 161 | ) @inContext(country: $country, language: $language) { 162 | product(handle: $handle) { 163 | ...Product 164 | } 165 | } 166 | ${PRODUCT_DETAIL_FRAGMENT} 167 | ` as const; 168 | -------------------------------------------------------------------------------- /app/components/snow-field.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { clsx } from "clsx"; 3 | import { usePrefersReducedMotion } from "~/lib/hooks"; 4 | 5 | type Particle = { 6 | x: number; 7 | y: number; 8 | radius: number; 9 | vy: number; 10 | vx: number; 11 | alpha: number; 12 | }; 13 | 14 | /** 15 | * Lightweight canvas snow effect tuned for gentle, low-cost particles. 16 | */ 17 | const DENSITY = 0.00008; 18 | const SIZE_RANGE: [number, number] = [0.8, 2.2]; 19 | const SPEED_RANGE: [number, number] = [0.18, 0.5]; 20 | const OPACITY_RANGE: [number, number] = [0.25, 0.9]; 21 | const DRIFT = 0.1; 22 | 23 | export function SnowField({ className }: { className?: string }) { 24 | let canvasRef = useRef(null); 25 | let prefersReducedMotion = usePrefersReducedMotion(); 26 | 27 | useEffect(() => { 28 | let canvas = canvasRef.current; 29 | if (!canvas) return; 30 | 31 | let ctx = canvas.getContext("2d"); 32 | if (!ctx) return; 33 | 34 | let particles: Particle[] = []; 35 | let frameId = 0; 36 | let width = 0; 37 | let height = 0; 38 | let dpr = window.devicePixelRatio || 1; 39 | 40 | let resize = () => { 41 | width = canvas.clientWidth; 42 | height = canvas.clientHeight; 43 | if (!width || !height) return; 44 | 45 | canvas.width = Math.round(width * dpr); 46 | canvas.height = Math.round(height * dpr); 47 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 48 | 49 | let targetCount = Math.max(12, Math.floor(width * height * DENSITY)); 50 | 51 | if (particles.length > targetCount) { 52 | particles.length = targetCount; 53 | } else { 54 | while (particles.length < targetCount) { 55 | particles.push(createParticle({ width, height })); 56 | } 57 | } 58 | }; 59 | 60 | let drawParticles = (move = false) => { 61 | ctx.clearRect(0, 0, width, height); 62 | 63 | for (let p of particles) { 64 | if (move) { 65 | p.y += p.vy; 66 | p.x += p.vx; 67 | 68 | if (p.y - p.radius > height) { 69 | resetParticle(p, { width }); 70 | } 71 | 72 | if (p.x < -p.radius) p.x = width + p.radius; 73 | else if (p.x > width + p.radius) p.x = -p.radius; 74 | } 75 | 76 | ctx.globalAlpha = p.alpha; 77 | ctx.beginPath(); 78 | ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); 79 | ctx.fillStyle = "white"; 80 | ctx.fill(); 81 | } 82 | }; 83 | 84 | let render = () => { 85 | drawParticles(true); 86 | frameId = window.requestAnimationFrame(render); 87 | }; 88 | 89 | resize(); 90 | let resizeObserver = new ResizeObserver(() => { 91 | resize(); 92 | if (prefersReducedMotion) { 93 | drawParticles(false); 94 | } 95 | }); 96 | resizeObserver.observe(canvas); 97 | 98 | if (prefersReducedMotion) { 99 | drawParticles(false); 100 | } else { 101 | frameId = window.requestAnimationFrame(render); 102 | } 103 | 104 | return () => { 105 | resizeObserver.disconnect(); 106 | window.cancelAnimationFrame(frameId); 107 | }; 108 | }, [prefersReducedMotion]); 109 | 110 | return ( 111 |