├── public ├── robots.txt └── favicon.ico ├── remix.env.d.ts ├── multi-reporter-config.json ├── app ├── models │ ├── request-response-cache.server.ts │ ├── language.ts │ ├── ecommerce-provider.server.ts │ ├── request-response-caches │ │ └── swr-redis-cache.server.ts │ └── ecommerce-providers │ │ └── shopify.server.ts ├── utils │ ├── redirect.server.ts │ ├── use-no-flash.ts │ └── use-scroll-swipe.ts ├── entry.client.tsx ├── components │ ├── client-only.tsx │ ├── cta-banner.tsx │ ├── checkout-form.tsx │ ├── scrolling-product-list.tsx │ ├── cdp-product-grid-item.tsx │ ├── icons.tsx │ ├── language-dialog.tsx │ ├── wishlist-popover.tsx │ ├── cart-popover.tsx │ ├── cart-listitem.tsx │ ├── footer.tsx │ ├── wishlist-listitem.tsx │ ├── three-product-grid.tsx │ ├── product-details.tsx │ └── navbar.tsx ├── root.tsx ├── route-containers │ ├── boundaries │ │ ├── generic-error-boundary.tsx │ │ └── generic-catch-boundary.tsx │ ├── generic-page │ │ ├── generic-page.server.ts │ │ └── generic-page.component.tsx │ ├── pdp │ │ ├── pdp.component.tsx │ │ └── pdp.server.ts │ ├── home │ │ ├── home.server.ts │ │ └── home.component.tsx │ ├── wishlist │ │ ├── wishlist.component.tsx │ │ └── wishlist.server.ts │ ├── cart │ │ ├── cart.component.tsx │ │ └── cart.server.ts │ ├── layout │ │ ├── layout.server.ts │ │ └── layout.component.tsx │ └── cdp │ │ ├── cdp.server.ts │ │ └── cdp.component.tsx ├── redis.server.ts ├── routes │ ├── index.ts │ ├── $lang │ │ ├── index.ts │ │ ├── search.ts │ │ ├── cart.ts │ │ ├── $.ts │ │ ├── wishlist.ts │ │ └── product.$slug.ts │ ├── search.ts │ ├── cart.ts │ ├── wishlist.ts │ ├── $.ts │ ├── api │ │ └── image.ts │ ├── product.$slug.ts │ └── actions │ │ ├── checkout.ts │ │ └── set-language.ts ├── commerce.server.ts ├── styles │ └── global.css ├── entry.server.tsx ├── images │ └── remix-glow.svg ├── session.server.ts └── translations.server.tsx ├── .gitignore ├── cypress ├── fixtures │ └── example.json ├── parallel-weights.json ├── support │ ├── e2e.js │ └── commands.js ├── e2e │ ├── language.cy.ts │ ├── cart.cy.ts │ ├── wishlist.cy.ts │ └── search.cy.ts └── plugins │ └── index.js ├── .dockerignore ├── remix.config.js ├── docker-compose.yml ├── cypress.config.ts ├── .env.example ├── tsconfig.json ├── fly.toml ├── Dockerfile ├── README.md ├── package.json └── tailwind.config.js /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/remix-ecommerce/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /multi-reporter-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporterEnabled": "cypress-parallel/json-stream.reporter.js, cypress-parallel/simple-spec.reporter.js" 3 | } -------------------------------------------------------------------------------- /app/models/request-response-cache.server.ts: -------------------------------------------------------------------------------- 1 | export interface RequestResponseCache { 2 | (request: Request, maxAgeSeconds: number): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | /.cache 5 | /build 6 | /public/build 7 | .env 8 | 9 | /cypress/videos 10 | /cypress/screenshots 11 | /runner-results -------------------------------------------------------------------------------- /app/models/language.ts: -------------------------------------------------------------------------------- 1 | export type Language = "en" | "es"; 2 | 3 | export function validateLanguage(language: any): language is Language { 4 | return language === "en" || language === "es"; 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /.env 3 | /build 4 | /public/build 5 | /node_modules 6 | 7 | /.env.example 8 | /docker-compose.yml 9 | /README.md 10 | /.dockerignore 11 | /.gitignore 12 | /Dockerfile 13 | /fly.toml -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | ignoredRouteFiles: [".*"], 6 | serverModuleFormat: "cjs", 7 | tailwind: true, 8 | future: { 9 | unstable_dev: true, 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | redis: 4 | image: "redis:alpine" 5 | command: redis-server --requirepass remixrocks 6 | expose: 7 | - "6379" 8 | volumes: 9 | - ./.cache/redis:/data 10 | ports: 11 | - "6379:6379" 12 | -------------------------------------------------------------------------------- /cypress/parallel-weights.json: -------------------------------------------------------------------------------- 1 | {"cypress/integration/cart.spec.ts":{"time":18175,"weight":9},"cypress/integration/language.spec.ts":{"time":13865,"weight":7},"cypress/integration/search.spec.ts":{"time":23558,"weight":12},"cypress/integration/wishlist.spec.ts":{"time":18693,"weight":10}} -------------------------------------------------------------------------------- /app/utils/redirect.server.ts: -------------------------------------------------------------------------------- 1 | export function validateRedirect( 2 | redirect: string | null | undefined, 3 | defaultRediret: string 4 | ) { 5 | if (redirect?.startsWith("/") && redirect[1] !== "/") { 6 | return redirect; 7 | } 8 | 9 | return defaultRediret; 10 | } 11 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { hydrateRoot } from "react-dom/client"; 3 | import { RemixBrowser } from "@remix-run/react"; 4 | 5 | React.startTransition(() => { 6 | hydrateRoot( 7 | document, 8 | 9 | 10 | 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /app/components/client-only.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { ReactNode } from "react"; 3 | 4 | export function ClientOnly({ children }: { children: ReactNode }) { 5 | let [mounted, setMounted] = useState(false); 6 | useEffect(() => { 7 | setMounted(true); 8 | }, []); 9 | return mounted ? <>{children} : null; 10 | } 11 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | // We've imported your old cypress plugins here. 6 | // You may want to clean this up later by importing these. 7 | setupNodeEvents(on, config) { 8 | return require('./cypress/plugins/index.js')(on, config) 9 | }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import Component, { 2 | ErrorBoundary, 3 | CatchBoundary, 4 | links, 5 | meta, 6 | shouldRevalidate, 7 | } from "~/route-containers/layout/layout.component"; 8 | import { loader } from "~/route-containers/layout/layout.server"; 9 | 10 | export default Component; 11 | export { ErrorBoundary, CatchBoundary, loader, links, meta, shouldRevalidate }; 12 | -------------------------------------------------------------------------------- /app/route-containers/boundaries/generic-error-boundary.tsx: -------------------------------------------------------------------------------- 1 | export function GenericErrorBoundary({ error }: { error: Error }) { 2 | console.error(error); 3 | 4 | return ( 5 |
6 |
7 |

An unknown error occured.

8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Used to encrypt cookies 2 | ENCRYPTION_KEY="rofl1234" 3 | 4 | # Used for swr request-response cache by providers 5 | REDIS_URL="redis://:remixrocks@localhost:6379" 6 | 7 | # Credentials for the shopify provider 8 | # SHOPIFY_STORE="next-js-store" 9 | # SHOPIFY_STOREFRONT_ACCESS_TOKEN="ef7d41c7bf7e1c214074d0d3047bcd7b" 10 | SHOPIFY_STORE="thatsferntastic" 11 | SHOPIFY_STOREFRONT_ACCESS_TOKEN="25b59da8d066f0e64bec37246b8c2971" 12 | -------------------------------------------------------------------------------- /app/redis.server.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "redis"; 2 | 3 | if (!process.env.REDIS_URL) { 4 | throw new Error("REDIS_URL environment variable is not set"); 5 | } 6 | 7 | declare global { 8 | var redisClient: ReturnType; 9 | } 10 | 11 | let redisClient = (global.redisClient = 12 | global.redisClient || 13 | createClient({ 14 | url: process.env.REDIS_URL, 15 | })); 16 | 17 | export default redisClient; 18 | -------------------------------------------------------------------------------- /app/routes/index.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/home/home.component"; 2 | import { loader } from "~/route-containers/home/home.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; -------------------------------------------------------------------------------- /app/routes/$lang/index.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/home/home.component"; 2 | import { loader } from "~/route-containers/home/home.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; -------------------------------------------------------------------------------- /app/routes/search.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/cdp/cdp.component"; 2 | import { loader } from "~/route-containers/cdp/cdp.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/$lang/search.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/cdp/cdp.component"; 2 | import { loader } from "~/route-containers/cdp/cdp.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; 12 | -------------------------------------------------------------------------------- /app/route-containers/boundaries/generic-catch-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { useCatch } from "@remix-run/react"; 2 | 3 | export function GenericCatchBoundary() { 4 | let caught = useCatch(); 5 | let message = caught.statusText; 6 | if (typeof caught.data === "string") { 7 | message = caught.data; 8 | } 9 | 10 | return ( 11 |
12 |
13 |

{message}

14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/routes/cart.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/cart/cart.component"; 2 | import { action, headers, loader } from "~/route-containers/cart/cart.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { action, headers, loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/$lang/cart.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/cart/cart.component"; 2 | import { action, headers, loader } from "~/route-containers/cart/cart.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { action, headers, loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/wishlist.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/wishlist/wishlist.component"; 2 | import { action, headers, loader } from "~/route-containers/wishlist/wishlist.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { action, headers, loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/$.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | meta, 3 | } from "~/route-containers/generic-page/generic-page.component"; 4 | import { loader } from "~/route-containers/generic-page/generic-page.server"; 5 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 6 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 7 | 8 | export default Component; 9 | export { loader, meta }; 10 | export { 11 | GenericCatchBoundary as CatchBoundary, 12 | GenericErrorBoundary as ErrorBoundary, 13 | }; 14 | -------------------------------------------------------------------------------- /app/routes/$lang/$.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | meta, 3 | } from "~/route-containers/generic-page/generic-page.component"; 4 | import { loader } from "~/route-containers/generic-page/generic-page.server"; 5 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 6 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 7 | 8 | export default Component; 9 | export { loader, meta }; 10 | export { 11 | GenericCatchBoundary as CatchBoundary, 12 | GenericErrorBoundary as ErrorBoundary, 13 | }; -------------------------------------------------------------------------------- /app/routes/$lang/wishlist.ts: -------------------------------------------------------------------------------- 1 | import Component from "~/route-containers/wishlist/wishlist.component"; 2 | import { action, headers, loader } from "~/route-containers/wishlist/wishlist.server"; 3 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 4 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 5 | 6 | export default Component; 7 | export { action, headers, loader }; 8 | export { 9 | GenericCatchBoundary as CatchBoundary, 10 | GenericErrorBoundary as ErrorBoundary, 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/api/image.ts: -------------------------------------------------------------------------------- 1 | import { type LoaderArgs } from "@remix-run/node"; 2 | import { imageLoader, DiskCache, fetchResolver } from "remix-image/server"; 3 | import { sharpTransformer } from "remix-image-sharp"; 4 | 5 | export function loader({ request }: LoaderArgs) { 6 | let url = new URL("/", request.url); 7 | 8 | return imageLoader( 9 | { 10 | selfUrl: url.href, 11 | resolver: async (asset, url, options, basePath) => { 12 | return await fetchResolver(asset, url, options, basePath); 13 | }, 14 | transformer: sharpTransformer, 15 | }, 16 | request 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/routes/product.$slug.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | meta, 3 | unstable_shouldReload, 4 | } from "~/route-containers/pdp/pdp.component"; 5 | import { action, headers, loader } from "~/route-containers/pdp/pdp.server"; 6 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 7 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 8 | 9 | export default Component; 10 | export { action, headers, loader, meta, unstable_shouldReload }; 11 | export { 12 | GenericCatchBoundary as CatchBoundary, 13 | GenericErrorBoundary as ErrorBoundary, 14 | }; 15 | -------------------------------------------------------------------------------- /app/routes/$lang/product.$slug.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | meta, 3 | unstable_shouldReload, 4 | } from "~/route-containers/pdp/pdp.component"; 5 | import { action, headers, loader } from "~/route-containers/pdp/pdp.server"; 6 | import { GenericCatchBoundary } from "~/route-containers/boundaries/generic-catch-boundary"; 7 | import { GenericErrorBoundary } from "~/route-containers/boundaries/generic-error-boundary"; 8 | 9 | export default Component; 10 | export { action, headers, loader, meta, unstable_shouldReload }; 11 | export { 12 | GenericCatchBoundary as CatchBoundary, 13 | GenericErrorBoundary as ErrorBoundary, 14 | }; 15 | -------------------------------------------------------------------------------- /app/route-containers/generic-page/generic-page.server.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | 4 | import commerce from "~/commerce.server"; 5 | import { getSession } from "~/session.server"; 6 | 7 | export async function loader({ request, params }: LoaderArgs) { 8 | let session = await getSession(request, params); 9 | let lang = session.getLanguage(); 10 | 11 | let page = await commerce.getPage(lang, params["*"]!); 12 | 13 | if (!page) { 14 | throw json("Page not found", { status: 404 }); 15 | } 16 | 17 | return json({ 18 | page, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "remix.env.d.ts", 4 | "app/**/*.ts", 5 | "app/**/*.tsx", 6 | "cypress/**/*.ts" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 10 | "types": ["cypress"], 11 | "esModuleInterop": true, 12 | "jsx": "react-jsx", 13 | "module": "ESNext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "target": "ES2019", 17 | "strict": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "~/*": ["./app/*"] 21 | }, 22 | "noEmit": true, 23 | "allowJs": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "isolatedModules": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/commerce.server.ts: -------------------------------------------------------------------------------- 1 | import { createShopifyProvider } from "./models/ecommerce-providers/shopify.server"; 2 | import { createSwrRedisCache } from "./models/request-response-caches/swr-redis-cache.server"; 3 | 4 | import redisClient from "./redis.server"; 5 | 6 | if (!process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN) { 7 | throw new Error( 8 | "SHOPIFY_STOREFRONT_ACCESS_TOKEN environment variable is not set" 9 | ); 10 | } 11 | 12 | let commerce = createShopifyProvider({ 13 | shop: process.env.SHOPIFY_STORE!, 14 | storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN, 15 | maxAgeSeconds: 60, 16 | cache: createSwrRedisCache({ 17 | redisClient, 18 | }), 19 | }); 20 | 21 | export default commerce; 22 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/e2e/language.cy.ts: -------------------------------------------------------------------------------- 1 | describe("language", () => { 2 | it("defaults to english", () => { 3 | cy.clearCookies(); 4 | cy.visit("http://localhost:3000/"); 5 | cy.get("html").should("have.attr", "lang", "en"); 6 | }); 7 | 8 | it("should have spanish on /es route", () => { 9 | cy.visit("http://localhost:3000/es/"); 10 | cy.get("html").should("have.attr", "lang", "es"); 11 | }); 12 | 13 | it("has spanish after selecting", () => { 14 | cy.visit("http://localhost:3000/"); 15 | cy.get("html").should("have.attr", "lang", "en"); 16 | cy.get("[data-testid=language-selector]").click(); 17 | cy.get("button[value=es]").click(); 18 | cy.url().should("include", "/es"); 19 | cy.visit("http://localhost:3000/"); 20 | cy.get("html").should("have.attr", "lang", "es"); 21 | }); 22 | }); 23 | 24 | export {}; 25 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /app/utils/use-no-flash.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function useNoFlash( 4 | value: Value, 5 | { delay, showFor }: { delay: number; showFor: number } 6 | ) { 7 | let [state, setState] = React.useState<{ setAt: number; value: Value }>({ 8 | setAt: Date.now(), 9 | value, 10 | }); 11 | let valueToUse = "value" in state ? state.value : value; 12 | 13 | React.useEffect(() => { 14 | if (value === state.value) { 15 | return; 16 | } 17 | 18 | let stillNeedToShowFor = showFor - (Date.now() - state.setAt); 19 | let totalDelay = delay + stillNeedToShowFor; 20 | 21 | let timeout = setTimeout(() => { 22 | setState({ 23 | setAt: Date.now(), 24 | value, 25 | }); 26 | }, totalDelay); 27 | 28 | return () => { 29 | clearTimeout(timeout); 30 | }; 31 | }, [value, state]); 32 | 33 | return valueToUse; 34 | } 35 | -------------------------------------------------------------------------------- /app/routes/actions/checkout.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import commerce from "~/commerce.server"; 5 | import { getSession } from "~/session.server"; 6 | 7 | export let action: ActionFunction = async ({ request, params }) => { 8 | let session = await getSession(request, params); 9 | let lang = session.getLanguage(); 10 | 11 | try { 12 | let cart = await session.getCart(); 13 | let checkoutUrl = await commerce.getCheckoutUrl(lang, cart); 14 | 15 | return redirect(checkoutUrl); 16 | } catch (error) { 17 | console.error(error); 18 | return redirect(`/${lang}/cart`); 19 | } 20 | }; 21 | 22 | export let loader: LoaderFunction = async ({ request, params }) => { 23 | let session = await getSession(request, params); 24 | let lang = session.getLanguage(); 25 | return redirect(`/${lang}/cart`); 26 | }; 27 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /app/route-containers/generic-page/generic-page.component.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "@remix-run/react"; 2 | import { UseDataFunctionReturn } from "@remix-run/react/dist/components"; 3 | 4 | import type { loader } from "./generic-page.server"; 5 | 6 | export const meta = ({ 7 | data, 8 | }: { 9 | data?: UseDataFunctionReturn; 10 | }) => { 11 | return data?.page?.title 12 | ? { 13 | title: data.page.title, 14 | description: data.page.summary, 15 | } 16 | : { 17 | title: "Remix Ecommerce", 18 | description: "An example ecommerce site built with Remix.", 19 | }; 20 | }; 21 | 22 | export default function GenericPage() { 23 | let { 24 | page: { body }, 25 | } = useLoaderData(); 26 | return ( 27 |
28 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for remix-ecommerce on 2021-12-29T20:35:36-08:00 2 | 3 | app = "remix-ecommerce" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 1 7 | processes = [] 8 | 9 | [deploy] 10 | # release_command = "npx -y prisma migrate deploy" 11 | strategy = "rolling" 12 | 13 | [env] 14 | NODE_ENV = "production" 15 | PORT = 8080 16 | 17 | [mounts] 18 | destination = "/app/.cache" 19 | source = "remix_commerce_volume" 20 | 21 | [experimental] 22 | allowed_public_ports = [] 23 | auto_rollback = true 24 | 25 | [[services]] 26 | http_checks = [] 27 | internal_port = 8080 28 | processes = ["app"] 29 | protocol = "tcp" 30 | script_checks = [] 31 | 32 | [services.concurrency] 33 | hard_limit = 25 34 | soft_limit = 20 35 | type = "connections" 36 | 37 | [[services.ports]] 38 | handlers = ["http"] 39 | port = 80 40 | 41 | [[services.ports]] 42 | handlers = ["tls", "http"] 43 | port = 443 44 | 45 | [[services.tcp_checks]] 46 | grace_period = "1s" 47 | interval = "1s" 48 | restart_limit = 0 49 | timeout = "2s" 50 | -------------------------------------------------------------------------------- /app/route-containers/pdp/pdp.component.tsx: -------------------------------------------------------------------------------- 1 | import type { ShouldReloadFunction } from "@remix-run/react"; 2 | import { useLoaderData } from "@remix-run/react"; 3 | 4 | import { ProductDetails } from "~/components/product-details"; 5 | 6 | import type { loader } from "./pdp.server"; 7 | 8 | export let unstable_shouldReload: ShouldReloadFunction = ({ prevUrl, url }) => { 9 | return prevUrl.toString() !== url.toString(); 10 | }; 11 | 12 | export const meta = ({ data }: { data: any }) => { 13 | return data?.product?.title 14 | ? { 15 | title: data.product.title, 16 | description: data.product.description, 17 | } 18 | : { 19 | title: "Remix Ecommerce", 20 | description: "An example ecommerce site built with Remix.", 21 | }; 22 | }; 23 | 24 | export default function ProductDetailPage() { 25 | let { product, translations } = useLoaderData(); 26 | 27 | return ( 28 |
29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .three-product-grid-primary, 6 | .three-product-grid-secondary { 7 | grid-template-columns: 1fr 1fr 1fr; 8 | grid-template-rows: 1fr 1fr; 9 | gap: 0px 0px; 10 | } 11 | .three-product-grid-primary { 12 | grid-template-areas: 13 | "item-0 item-0 item-1" 14 | "item-0 item-0 item-2"; 15 | } 16 | .three-product-grid-secondary { 17 | grid-template-areas: 18 | "item-1 item-0 item-0" 19 | "item-2 item-0 item-0"; 20 | } 21 | .three-product-grid__item-0 { 22 | grid-area: item-0; 23 | } 24 | .three-product-grid__item-1 { 25 | grid-area: item-1; 26 | } 27 | .three-product-grid__item-2 { 28 | grid-area: item-2; 29 | } 30 | 31 | .product-details-grid { 32 | grid-template-columns: 1fr 1fr 1fr; 33 | grid-template-rows: 1fr 1fr; 34 | gap: 0px 0px; 35 | 36 | grid-template-areas: 37 | "media media details" 38 | "media media details"; 39 | } 40 | .product-details-grid__media { 41 | grid-area: media; 42 | } 43 | .product-details-grid__details { 44 | grid-area: details; 45 | } 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-bullseye-slim as base 2 | 3 | ENV NODE_ENV=production 4 | ENV PORT=8080 5 | 6 | FROM base as builder 7 | 8 | RUN apt-get update 9 | RUN apt-get install build-essential python3 -y 10 | 11 | # install all node_modules, including dev 12 | FROM builder as deps 13 | 14 | RUN mkdir /app/ 15 | WORKDIR /app/ 16 | 17 | ADD package.json package-lock.json ./ 18 | RUN npm install --production=false 19 | 20 | # install only production modules 21 | FROM deps as production-deps 22 | 23 | WORKDIR /app/ 24 | 25 | RUN npm prune --production=true 26 | 27 | ## build the app 28 | FROM deps as build 29 | 30 | WORKDIR /app/ 31 | 32 | ADD . . 33 | RUN npm run build 34 | 35 | ## copy over assets required to run the app 36 | FROM base 37 | 38 | RUN mkdir /app/ 39 | WORKDIR /app/ 40 | 41 | # ADD prisma . 42 | 43 | COPY --from=production-deps /app/node_modules /app/node_modules 44 | COPY --from=production-deps /app/package.json /app/package.json 45 | COPY --from=production-deps /app/package-lock.json /app/package-lock.json 46 | COPY --from=build /app/build /app/build 47 | COPY --from=build /app/public /app/public 48 | 49 | EXPOSE 8080 50 | 51 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /app/routes/actions/set-language.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { getSession } from "~/session.server"; 5 | import { validateLanguage } from "~/models/language"; 6 | 7 | export let action: ActionFunction = async ({ request, params }) => { 8 | let [session, body] = await Promise.all([ 9 | getSession(request, params), 10 | request.text(), 11 | ]); 12 | 13 | let formData = new URLSearchParams(body); 14 | let lang = formData.get("lang"); 15 | let redirectTo = formData.get("redirect"); 16 | 17 | if (!redirectTo?.startsWith("/")) { 18 | redirectTo = "/"; 19 | } 20 | 21 | if (validateLanguage(lang)) { 22 | session.setLanguage(lang); 23 | 24 | let [, urlLang] = redirectTo.split("/", 2); 25 | if (validateLanguage(urlLang)) { 26 | redirectTo = redirectTo.replace(`/${urlLang}`, `/${lang}`); 27 | } else { 28 | redirectTo = `/${lang}${redirectTo}`; 29 | } 30 | } 31 | 32 | return redirect(redirectTo, { 33 | headers: { 34 | "Set-Cookie": await session.commitSession(), 35 | }, 36 | }); 37 | }; 38 | 39 | export let loader: LoaderFunction = () => { 40 | return redirect("/"); 41 | }; 42 | -------------------------------------------------------------------------------- /cypress/e2e/cart.cy.ts: -------------------------------------------------------------------------------- 1 | describe("cart", () => { 2 | it("can add to cart", () => { 3 | cy.clearCookies(); 4 | cy.visit("http://localhost:3000/en/"); 5 | 6 | cy.get("[data-testid=cart-count]").should("not.exist"); 7 | cy.get("a[href^='/en/product/']").first().click(); 8 | let options = cy.get("[data-testid=product-option]"); 9 | options.each((option) => { 10 | cy.wrap(option).find("button").first().click(); 11 | cy.wrap(option).find("button[aria-selected=true]").should("exist"); 12 | }); 13 | cy.get("[data-testid=add-to-cart]").click(); 14 | cy.get("[data-testid=cart-count]").contains("1"); 15 | cy.get("[data-testid=cart-link]").click(); 16 | }); 17 | 18 | it("can increment cart item", () => { 19 | cy.get("[data-testid=increment-cart]").first().click(); 20 | cy.get("[data-testid=cart-count]").contains("2"); 21 | }); 22 | 23 | it("can decrement cart item", () => { 24 | cy.get("[data-testid=decrement-cart]").first().click(); 25 | cy.get("[data-testid=cart-count]").contains("1"); 26 | }); 27 | 28 | it("can remove from cart", () => { 29 | cy.get("[data-testid=remove-from-cart]").first().click(); 30 | cy.get("[data-testid=cart-count]").should("not.exist"); 31 | }); 32 | }); 33 | 34 | export {}; 35 | -------------------------------------------------------------------------------- /app/route-containers/home/home.server.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | 4 | import commerce from "~/commerce.server"; 5 | import { getTranslations } from "~/translations.server"; 6 | import { getSession } from "~/session.server"; 7 | 8 | export async function loader({ request, params }: LoaderArgs) { 9 | let session = await getSession(request, params); 10 | let lang = session.getLanguage(); 11 | let [featuredProducts, wishlist] = await Promise.all([ 12 | commerce.getFeaturedProducts(lang), 13 | session.getWishlist(), 14 | ]); 15 | 16 | let wishlistHasProduct = new Set( 17 | wishlist.map((item) => item.productId) 18 | ); 19 | 20 | return json({ 21 | featuredProducts: featuredProducts.map( 22 | ({ formattedPrice, id, image, slug, title, defaultVariantId }) => ({ 23 | favorited: wishlistHasProduct.has(id), 24 | formattedPrice, 25 | id, 26 | defaultVariantId, 27 | image, 28 | title, 29 | to: `/${lang}/product/${slug}`, 30 | }) 31 | ), 32 | translations: getTranslations(lang, [ 33 | "MockCTADescription", 34 | "MockCTAHeadline", 35 | "MockCTALink", 36 | "Add to wishlist", 37 | "Remove from wishlist", 38 | ]), 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Please check out the Hydrogen project for Remix ecomm best practices. I am not maintaining this and will probably never look at this repo again. 2 | 3 | # Welcome to Remix! 4 | 5 | - [Remix Docs](https://remix.run/docs) 6 | 7 | ## Development 8 | 9 | From your terminal: 10 | 11 | ```sh 12 | npm run dev 13 | ``` 14 | 15 | This starts your app in development mode, rebuilding assets on file changes. 16 | 17 | ## Deployment 18 | 19 | First, build your app for production: 20 | 21 | ```sh 22 | npm run build 23 | ``` 24 | 25 | Then run the app in production mode: 26 | 27 | ```sh 28 | npm start 29 | ``` 30 | 31 | Now you'll need to pick a host to deploy it to. 32 | 33 | ### DIY 34 | 35 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 36 | 37 | Make sure to deploy the output of `remix build` 38 | 39 | - `build/` 40 | - `public/build/` 41 | 42 | ### Using a Template 43 | 44 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 45 | 46 | ```sh 47 | cd .. 48 | # create a new project, and pick a pre-configured host 49 | npx create-remix@latest 50 | cd my-new-remix-app 51 | # remove the new project's app (not the old one!) 52 | rm -rf app 53 | # copy your app over 54 | cp -R ../my-old-remix-app/app app 55 | ``` 56 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import { renderToPipeableStream } from "react-dom/server"; 3 | import { RemixServer } from "@remix-run/react"; 4 | import type { EntryContext } from "@remix-run/node"; 5 | import { Response, Headers } from "@remix-run/node"; 6 | import isbot from "isbot"; 7 | 8 | const ABORT_DELAY = 5000; 9 | 10 | export default function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | remixContext: EntryContext 15 | ) { 16 | const callbackName = isbot(request.headers.get("user-agent")) 17 | ? "onAllReady" 18 | : "onShellReady"; 19 | 20 | return new Promise((resolve, reject) => { 21 | let didError = false; 22 | 23 | const { pipe, abort } = renderToPipeableStream( 24 | , 25 | { 26 | [callbackName]() { 27 | let body = new PassThrough(); 28 | 29 | responseHeaders.set("Content-Type", "text/html"); 30 | 31 | resolve( 32 | new Response(body, { 33 | status: didError ? 500 : responseStatusCode, 34 | headers: responseHeaders, 35 | }) 36 | ); 37 | pipe(body); 38 | }, 39 | onShellError(err) { 40 | reject(err); 41 | }, 42 | onError(error) { 43 | didError = true; 44 | console.error(error); 45 | }, 46 | } 47 | ); 48 | setTimeout(abort, ABORT_DELAY); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /cypress/e2e/wishlist.cy.ts: -------------------------------------------------------------------------------- 1 | describe("wishlist", () => { 2 | it("can add to wishlist", () => { 3 | cy.clearCookies(); 4 | cy.visit("http://localhost:3000/en/"); 5 | 6 | cy.get("[data-testid=wishlist-count]").should("not.exist"); 7 | cy.get("[data-testid=add-to-wishlist]").first().click(); 8 | cy.get("[data-testid=wishlist-count]").contains("1"); 9 | }); 10 | 11 | it("can increment item in wishlist", () => { 12 | cy.get("[data-testid=wishlist-link]").click(); 13 | cy.get("[data-testid=increment-wishlist]").first().click(); 14 | cy.get("[data-testid=wishlist-count]").contains("2"); 15 | }); 16 | 17 | it("can decrement item in wishlist", () => { 18 | cy.get("[data-testid=decrement-wishlist]").first().click(); 19 | cy.get("[data-testid=wishlist-count]").contains("1"); 20 | }); 21 | 22 | it("can remove item from wishlist", () => { 23 | cy.get("[data-testid=remove-from-wishlist]").first().click(); 24 | cy.get("[data-testid=wishlist-count]").should("not.exist"); 25 | }); 26 | 27 | it("can transfer item to cart", () => { 28 | cy.clearCookies(); 29 | cy.visit("http://localhost:3000/en/"); 30 | 31 | cy.get("[data-testid=wishlist-count]").should("not.exist"); 32 | cy.get("[data-testid=add-to-wishlist]").first().click(); 33 | cy.get("[data-testid=wishlist-count]").contains("1"); 34 | cy.get("[data-testid=wishlist-link]").click(); 35 | cy.get("[data-testid=move-to-cart]").first().click(); 36 | cy.get("[data-testid=wishlist-count]").should("not.exist"); 37 | cy.get("[data-testid=cart-count]").contains("1"); 38 | cy.get("[data-testid=close-wishlist]").click(); 39 | }); 40 | }); 41 | 42 | export {}; 43 | -------------------------------------------------------------------------------- /app/route-containers/wishlist/wishlist.component.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "@remix-run/react"; 2 | 3 | import { WishlistListItem } from "~/components/wishlist-listitem"; 4 | import { WishlistIcon } from "~/components/icons"; 5 | 6 | import type { loader } from "./wishlist.server"; 7 | 8 | export default function Wishlist() { 9 | let { wishlist, translations } = useLoaderData(); 10 | 11 | return ( 12 |
13 |

{translations.Wishlist}

14 | {!wishlist ? ( 15 |
16 | 17 | 18 | 19 |

20 | {translations["Your wishlist is empty"]} 21 |

22 |
23 | ) : ( 24 | <> 25 |
    26 | {wishlist.map((item) => ( 27 | 38 | ))} 39 |
40 | 41 | )} 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/components/cta-banner.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { Link } from "@remix-run/react"; 3 | import type { To } from "react-router-dom"; 4 | import cn from "classnames"; 5 | 6 | export function CtaBanner({ 7 | ctaText, 8 | ctaTo, 9 | description, 10 | headline, 11 | variant = "primary", 12 | }: { 13 | ctaText: ReactNode; 14 | ctaTo: To; 15 | description: ReactNode; 16 | headline: ReactNode; 17 | variant: "primary" | "secondary"; 18 | }) { 19 | return ( 20 |
26 |

{headline}

27 |
28 |

{description}

29 |

30 | 31 | 32 | {ctaText} 33 | 34 | 40 | 46 | 47 | 48 |

49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/route-containers/cart/cart.component.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "@remix-run/react"; 2 | 3 | import { CartListItem } from "~/components/cart-listitem"; 4 | import { CheckoutForm } from "~/components/checkout-form"; 5 | import { CartIcon } from "~/components/icons"; 6 | 7 | import type { loader } from "./cart.server"; 8 | 9 | export default function Cart() { 10 | let { cart, translations } = useLoaderData(); 11 | 12 | return ( 13 |
14 |

{translations.Cart}

15 | {!cart?.items ? ( 16 |
17 | 18 | 19 | 20 |

21 | {translations["Your cart is empty"]} 22 |

23 |
24 | ) : ( 25 | <> 26 |
    27 | {cart.items.map((item) => ( 28 | 38 | ))} 39 |
40 | 41 | 46 | 47 | )} 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "build": "remix build", 8 | "clean": "rimraf ./public/build ./build ./.cache/content-v2 ./.cache/index-v5", 9 | "dev": "remix dev", 10 | "test": "cypress-parallel -s cy:run -t 4 -d ./cypress/e2e", 11 | "cy:run": "cypress run --config video=false", 12 | "cypress": "cypress open", 13 | "start": "remix-serve build", 14 | "postinstall": "run-s setup:*", 15 | "setup:install-sharp": "(cd ./node_modules/sharp && npm run install)" 16 | }, 17 | "dependencies": { 18 | "@headlessui/react": "^1.4.2", 19 | "@next-boost/hybrid-disk-cache": "^0.3.0", 20 | "@remix-run/css-bundle": "1.16.0-pre.1", 21 | "@remix-run/node": "1.16.0-pre.1", 22 | "@remix-run/react": "1.16.0-pre.1", 23 | "@remix-run/serve": "1.16.0-pre.1", 24 | "@shopify/shopify-api": "^2.0.0", 25 | "classnames": "^2.3.1", 26 | "decimal.js": "^10.3.1", 27 | "isbot": "^3.5.0", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "redis": "^4.0.1", 31 | "remix-image": "^1.4.0", 32 | "remix-image-sharp": "^0.1.4" 33 | }, 34 | "devDependencies": { 35 | "@remix-run/dev": "1.16.0-pre.1", 36 | "@tailwindcss/typography": "^0.5.0", 37 | "@types/react": "^18.0.15", 38 | "@types/react-dom": "^18.0.6", 39 | "@types/sharp": "^0.32.0", 40 | "autoprefixer": "^10.4.0", 41 | "cssnano": "^5.0.14", 42 | "cypress": "^12.11.0", 43 | "cypress-parallel": "^0.12.0", 44 | "dotenv-cli": "^4.1.1", 45 | "npm-run-all": "^4.1.5", 46 | "postcss": "^8.4.5", 47 | "postcss-cli": "^9.1.0", 48 | "postcss-load-config": "^3.1.0", 49 | "rimraf": "^3.0.2", 50 | "tailwindcss": "^3.0.7", 51 | "tailwindcss-named-groups": "^0.0.5", 52 | "typescript": "^4.1.2" 53 | }, 54 | "engines": { 55 | "node": ">=14" 56 | }, 57 | "sideEffects": false, 58 | "prettier": {} 59 | } 60 | -------------------------------------------------------------------------------- /app/components/checkout-form.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from "@remix-run/react"; 2 | import cn from "classnames"; 3 | 4 | import type { CartInfo } from "~/models/ecommerce-provider.server"; 5 | import { PickTranslations } from "~/translations.server"; 6 | 7 | export function CheckoutForm({ 8 | className, 9 | cart, 10 | translations, 11 | }: { 12 | className: string; 13 | cart: CartInfo; 14 | translations: PickTranslations< 15 | "Subtotal" | "Taxes" | "Shipping" | "Total" | "Proceed to checkout" 16 | >; 17 | }) { 18 | return ( 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
{translations.Subtotal}{cart.formattedSubTotal}
{translations.Taxes}{cart.formattedTaxes}
{translations.Shipping}{cart.formattedShipping}
36 |
37 | 38 | 39 | 40 | 41 | 44 | 45 | 46 |
{translations.Total} 42 | {cart.formattedTotal} 43 |
47 |
48 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/route-containers/home/home.component.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useLoaderData } from "@remix-run/react"; 3 | 4 | import { CtaBanner } from "~/components/cta-banner"; 5 | import { ThreeProductGrid } from "~/components/three-product-grid"; 6 | import { ScrollingProductList } from "~/components/scrolling-product-list"; 7 | 8 | import type { loader } from "./home.server"; 9 | 10 | function chunkProducts(start: number, goal: number, products: T[]) { 11 | let slice = products.slice(start, start + 3); 12 | 13 | if (products.length < goal) return slice; 14 | for (let i = start + 3; slice.length < goal; i++) { 15 | slice.push(products[i % products.length]); 16 | } 17 | 18 | return slice; 19 | } 20 | 21 | export default function IndexPage() { 22 | let { featuredProducts, translations } = useLoaderData(); 23 | 24 | return ( 25 |
26 | chunkProducts(0, 3, featuredProducts), 30 | [featuredProducts] 31 | )} 32 | translations={translations} 33 | /> 34 | chunkProducts(3, 3, featuredProducts), 38 | [featuredProducts] 39 | )} 40 | /> 41 | 48 | chunkProducts(6, 3, featuredProducts), 52 | [featuredProducts] 53 | )} 54 | translations={translations} 55 | /> 56 | chunkProducts(9, 3, featuredProducts), 60 | [featuredProducts] 61 | )} 62 | /> 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/route-containers/pdp/pdp.server.ts: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, HeadersFunction, LoaderArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | 4 | import commerce from "~/commerce.server"; 5 | import { addToCart, getSession } from "~/session.server"; 6 | import { getTranslations } from "~/translations.server"; 7 | import { validateRedirect } from "~/utils/redirect.server"; 8 | 9 | export let headers: HeadersFunction = ({ actionHeaders }) => { 10 | return actionHeaders; 11 | }; 12 | 13 | export async function action({ request, params }: ActionArgs) { 14 | let [body, session] = await Promise.all([ 15 | request.text(), 16 | getSession(request, params), 17 | ]); 18 | let formData = new URLSearchParams(body); 19 | let redirectTo = validateRedirect( 20 | formData.get("redirect"), 21 | `/product/${params.slug}` 22 | ); 23 | let variantId = formData.get("variantId"); 24 | if (!variantId) { 25 | return redirect(redirectTo); 26 | } 27 | 28 | let cart = await session.getCart(); 29 | cart = addToCart(cart, variantId, 1); 30 | await session.setCart(cart); 31 | return redirect(redirectTo, { 32 | headers: { 33 | "Set-Cookie": await session.commitSession(), 34 | }, 35 | }); 36 | } 37 | 38 | export async function loader({ request, params }: LoaderArgs) { 39 | let url = new URL(request.url); 40 | 41 | let slug = params.slug?.trim(); 42 | if (!slug) { 43 | throw json("Product not found", { status: 404 }); 44 | } 45 | 46 | let selectedOptions = Array.from(url.searchParams.entries()).map( 47 | ([name, value]) => ({ 48 | name, 49 | value, 50 | }) 51 | ); 52 | 53 | let session = await getSession(request, params); 54 | let lang = session.getLanguage(); 55 | let product = await commerce.getProduct(lang, slug, selectedOptions); 56 | 57 | if (!product) { 58 | throw json(`Product "${slug}" not found`, { status: 404 }); 59 | } 60 | 61 | return json({ 62 | product, 63 | translations: getTranslations(lang, [ 64 | "Add to cart", 65 | "Sold out", 66 | "Added!", 67 | "Adding", 68 | ]), 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /app/route-containers/layout/layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | 4 | import commerce from "~/commerce.server"; 5 | import { getSession } from "~/session.server"; 6 | import { getTranslations } from "~/translations.server"; 7 | 8 | function time(label: string) { 9 | console.time(label); 10 | return (r: T) => { 11 | console.timeEnd(label); 12 | return r; 13 | }; 14 | } 15 | 16 | export async function loader({ request, params }: LoaderArgs) { 17 | let session = await getSession(request, params); 18 | let lang = session.getLanguage(); 19 | let [categories, pages, cart, wishlist] = await Promise.all([ 20 | commerce.getCategories(lang, 2).then(time("get categories root")), 21 | commerce.getPages(lang).then(time("get pages root")), 22 | session 23 | .getCart() 24 | .then((cartItems) => commerce.getCartInfo(lang, cartItems)) 25 | .then(time("get cart info root")), 26 | session 27 | .getWishlist() 28 | .then((wishlistItems) => commerce.getWishlistInfo(lang, wishlistItems)) 29 | .then(time("get wishlist info root")), 30 | ]).then(time("get all root")); 31 | 32 | let translations = getTranslations(lang, [ 33 | "All", 34 | "Cart", 35 | "Close", 36 | "Close Menu", 37 | "Home", 38 | "Open Menu", 39 | "Search for products...", 40 | "Store Name", 41 | "Wishlist", 42 | "Looks like your language doesn't match", 43 | "Would you like to switch to $1?", 44 | "Yes", 45 | "No", 46 | "Your cart is empty", 47 | "Quantity: $1", 48 | "Remove from cart", 49 | "Subtract item", 50 | "Add item", 51 | "Proceed to checkout", 52 | "Subtotal", 53 | "Total", 54 | "Taxes", 55 | "Shipping", 56 | "Your wishlist is empty", 57 | "Remove from wishlist", 58 | "Move to cart", 59 | ]); 60 | 61 | return json({ 62 | cart, 63 | lang, 64 | pages: [ 65 | { 66 | id: "home", 67 | title: translations.Home, 68 | to: `/${lang}`, 69 | }, 70 | ...(await pages).map(({ id, slug, title }) => ({ 71 | id, 72 | title, 73 | to: `/${lang}/${slug}`, 74 | })), 75 | ], 76 | categories: [ 77 | ...(await categories).map(({ name, slug }) => ({ 78 | name, 79 | to: `${lang}/search?category=${slug}`, 80 | })), 81 | ], 82 | translations, 83 | wishlist, 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /app/route-containers/cart/cart.server.ts: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, HeadersFunction, LoaderArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | 4 | import { updateCartItem, removeCartItem, getSession } from "~/session.server"; 5 | import { getTranslations } from "~/translations.server"; 6 | import commerce from "~/commerce.server"; 7 | import { validateRedirect } from "~/utils/redirect.server"; 8 | 9 | export let headers: HeadersFunction = ({ actionHeaders }) => { 10 | return actionHeaders; 11 | }; 12 | 13 | export async function action({ request, params }: ActionArgs) { 14 | let [body, session] = await Promise.all([ 15 | request.text(), 16 | getSession(request, params), 17 | ]); 18 | 19 | let formData = new URLSearchParams(body); 20 | let redirectTo = validateRedirect(formData.get("redirect"), "/cart"); 21 | let action = formData.get("_action"); 22 | 23 | try { 24 | let cart = await session.getCart(); 25 | 26 | switch (action) { 27 | case "set-quantity": { 28 | let variantId = formData.get("variantId"); 29 | let quantityStr = formData.get("quantity"); 30 | if (!variantId || !quantityStr) { 31 | break; 32 | } 33 | let quantity = Number.parseInt(quantityStr, 10); 34 | cart = updateCartItem(cart, variantId, quantity); 35 | break; 36 | } 37 | case "delete": { 38 | let variantId = formData.get("variantId"); 39 | if (!variantId) { 40 | break; 41 | } 42 | cart = removeCartItem(cart, variantId); 43 | break; 44 | } 45 | } 46 | 47 | await session.setCart(cart); 48 | return redirect(redirectTo, { 49 | headers: { 50 | "Set-Cookie": await session.commitSession(), 51 | }, 52 | }); 53 | } catch (error) { 54 | console.error(error); 55 | } 56 | 57 | return redirect(redirectTo); 58 | } 59 | 60 | export async function loader({ request, params }: LoaderArgs) { 61 | let session = await getSession(request, params); 62 | let lang = session.getLanguage(); 63 | let cart = await session 64 | .getCart() 65 | .then((cartItems) => commerce.getCartInfo(lang, cartItems)); 66 | 67 | return json({ 68 | cart, 69 | translations: getTranslations(lang, [ 70 | "Cart", 71 | "Add item", 72 | "Remove from cart", 73 | "Subtract item", 74 | "Quantity: $1", 75 | "Your cart is empty", 76 | "Subtotal", 77 | "Taxes", 78 | "Shipping", 79 | "Total", 80 | "Proceed to checkout", 81 | ]), 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /app/route-containers/cdp/cdp.server.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import type { To } from "react-router-dom"; 4 | 5 | import commerce from "~/commerce.server"; 6 | import { getSession } from "~/session.server"; 7 | import { getTranslations } from "~/translations.server"; 8 | 9 | type CDPProduct = { 10 | id: string; 11 | title: string; 12 | formattedPrice: string; 13 | favorited: boolean; 14 | image: string; 15 | to: To; 16 | defaultVariantId: string; 17 | }; 18 | 19 | function time(label: string) { 20 | // console.time(label); 21 | return (r: T) => { 22 | // console.timeEnd(label); 23 | return r; 24 | }; 25 | } 26 | 27 | export async function loader({ request, params }: LoaderArgs) { 28 | console.time("get session"); 29 | let session = await getSession(request, params); 30 | console.timeEnd("get session"); 31 | let lang = session.getLanguage(); 32 | let url = new URL(request.url); 33 | 34 | let category = url.searchParams.get("category") || undefined; 35 | let sort = url.searchParams.get("sort") || undefined; 36 | let search = url.searchParams.get("q") || undefined; 37 | let cursor = url.searchParams.get("cursor") || undefined; 38 | let nocache = url.searchParams.has("nocache"); 39 | 40 | let [categories, sortByOptions, productsPage, wishlist] = await Promise.all([ 41 | commerce.getCategories(lang, 250, nocache).then(time("get categories")), 42 | commerce.getSortByOptions(lang).then(time("get sort by options")), 43 | commerce 44 | .getProducts(lang, category, sort, search, cursor, 30, nocache) 45 | .then(time("get products page")), 46 | session.getWishlist().then(time("get wishlist")), 47 | ]); 48 | 49 | let wishlistHasProduct = new Set( 50 | wishlist.map((item) => item.productId) 51 | ); 52 | 53 | let translations = getTranslations(lang, [ 54 | "Add to wishlist", 55 | "Remove from wishlist", 56 | ]); 57 | 58 | return json({ 59 | category, 60 | sort, 61 | categories, 62 | search, 63 | sortByOptions, 64 | hasNextPage: productsPage.hasNextPage, 65 | nextPageCursor: productsPage.nextPageCursor, 66 | products: productsPage.products.map((product) => ({ 67 | favorited: wishlistHasProduct.has(product.id), 68 | formattedPrice: product.formattedPrice, 69 | id: product.id, 70 | image: product.image, 71 | title: product.title, 72 | to: `/${lang}/product/${product.slug}`, 73 | defaultVariantId: product.defaultVariantId, 74 | })), 75 | translations, 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /app/images/remix-glow.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 20 | 21 | 22 | 31 | 32 | 38 | 39 | 40 | 41 | 45 | 50 | 56 | 57 | 58 | 59 | 63 | 68 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /app/utils/use-scroll-swipe.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import type { RefObject } from "react"; 3 | 4 | export type SwipeScrollOptions = { 5 | sliderRef: RefObject; 6 | reliants?: any[]; 7 | onMove?: () => void; 8 | }; 9 | 10 | export function useSwipeScroll({ 11 | sliderRef, 12 | reliants = [], 13 | onMove, 14 | }: SwipeScrollOptions) { 15 | useEffect(() => { 16 | let pos = { top: 0, left: 0, x: 0, y: 0 }; 17 | 18 | const slider = sliderRef.current!; 19 | let isDown = false; 20 | let dragged = false; 21 | 22 | const onClick = (event: MouseEvent) => { 23 | if (dragged) { 24 | event.preventDefault(); 25 | event.stopPropagation(); 26 | event.stopImmediatePropagation(); 27 | } 28 | 29 | dragged = false; 30 | }; 31 | 32 | const onMouseDown = (event: MouseEvent) => { 33 | if (typeof (event as any)?.persist === "function") { 34 | (event as any)?.persist(); 35 | } 36 | isDown = true; 37 | pos = { 38 | // The current scroll 39 | left: slider.scrollLeft, 40 | top: slider.scrollTop, 41 | // Get the current mouse position 42 | x: event.clientX, 43 | y: event.clientY, 44 | }; 45 | slider.classList.add("active"); 46 | }; 47 | 48 | const onMouseLeave = () => { 49 | isDown = false; 50 | slider.classList.remove("active"); 51 | }; 52 | 53 | const onMouseUp = () => { 54 | isDown = false; 55 | slider.classList.remove("active"); 56 | 57 | if (dragged) { 58 | return false; 59 | } 60 | }; 61 | 62 | const onMouseMove = (event: MouseEvent) => { 63 | if (!isDown) return; 64 | event.preventDefault(); 65 | 66 | // TODO: potentially add buffer zone for registering a drag if clicks are an issue 67 | dragged = true; 68 | 69 | // How far the mouse has been moved 70 | const dx = event.clientX - pos.x; 71 | const dy = event.clientY - pos.y; 72 | 73 | // Scroll the element 74 | slider.scrollTop = pos.top - dy; 75 | slider.scrollLeft = pos.left - dx; 76 | 77 | onMove?.(); 78 | 79 | if (dragged) { 80 | return false; 81 | } 82 | }; 83 | 84 | slider.addEventListener("click", onClick); 85 | slider.addEventListener("mousedown", onMouseDown); 86 | slider.addEventListener("mouseleave", onMouseLeave); 87 | slider.addEventListener("mouseup", onMouseUp); 88 | slider.addEventListener("mousemove", onMouseMove); 89 | 90 | return () => { 91 | slider.removeEventListener("click", onClick); 92 | slider.removeEventListener("mousedown", onMouseDown); 93 | slider.removeEventListener("mouseleave", onMouseLeave); 94 | slider.removeEventListener("mouseup", onMouseUp); 95 | slider.removeEventListener("mousemove", onMouseMove); 96 | }; 97 | }, [...reliants]); 98 | } 99 | -------------------------------------------------------------------------------- /app/models/ecommerce-provider.server.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from "./language"; 2 | 3 | export interface CartItem { 4 | variantId: string; 5 | quantity: number; 6 | } 7 | 8 | export interface FullCartItem extends CartItem { 9 | info: Product; 10 | } 11 | 12 | export interface CartInfo { 13 | formattedTaxes: string; 14 | formattedTotal: string; 15 | formattedShipping: string; 16 | formattedSubTotal: string; 17 | items: FullCartItem[]; 18 | } 19 | 20 | export interface WishlistItem { 21 | productId: string; 22 | variantId: string; 23 | quantity: number; 24 | } 25 | 26 | export interface FullWishlistItem extends WishlistItem { 27 | info: Product; 28 | } 29 | 30 | export interface Category { 31 | name: string; 32 | slug: string; 33 | } 34 | 35 | export interface Product { 36 | id: string; 37 | title: string; 38 | formattedPrice: string; 39 | formattedOptions?: string; 40 | image: string; 41 | slug: string; 42 | defaultVariantId: string; 43 | } 44 | 45 | export interface ProductOption { 46 | name: string; 47 | values: string[]; 48 | } 49 | 50 | export interface FullProduct extends Product { 51 | description?: string; 52 | descriptionHtml?: string; 53 | images: string[]; 54 | availableForSale: boolean; 55 | selectedVariantId?: string; 56 | options: ProductOption[]; 57 | } 58 | 59 | export interface Page { 60 | id: string; 61 | slug: string; 62 | title: string; 63 | } 64 | 65 | export interface FullPage extends Page { 66 | body: string; 67 | summary: string; 68 | } 69 | 70 | export interface SortByOption { 71 | label: string; 72 | value: string; 73 | } 74 | 75 | export interface SelectedProductOption { 76 | name: string; 77 | value: string; 78 | } 79 | 80 | export interface ProductsResult { 81 | hasNextPage: boolean; 82 | nextPageCursor?: string; 83 | products: Product[]; 84 | } 85 | 86 | export interface EcommerceProvider { 87 | getCartInfo( 88 | locale: Language, 89 | items: CartItem[] 90 | ): Promise; 91 | getCategories( 92 | language: Language, 93 | count: number, 94 | nocache?: boolean 95 | ): Promise; 96 | getCheckoutUrl(language: Language, items: CartItem[]): Promise; 97 | getFeaturedProducts(language: Language): Promise; 98 | getPage(language: Language, slug: string): Promise; 99 | getPages(language: Language): Promise; 100 | getProduct( 101 | language: Language, 102 | slug: string, 103 | selectedOptions?: SelectedProductOption[] 104 | ): Promise; 105 | getProducts( 106 | language: Language, 107 | category?: string, 108 | sort?: string, 109 | search?: string, 110 | cursor?: string, 111 | perPage?: number, 112 | nocache?: boolean 113 | ): Promise; 114 | getSortByOptions(language: Language): Promise; 115 | getWishlistInfo( 116 | locale: Language, 117 | items: WishlistItem[] 118 | ): Promise; 119 | } 120 | -------------------------------------------------------------------------------- /app/components/scrolling-product-list.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import type { ReactNode } from "react"; 3 | import type { To } from "react-router-dom"; 4 | import { Link } from "@remix-run/react"; 5 | import Image from "remix-image"; 6 | import cn from "classnames"; 7 | 8 | export type ScrollingProductListProduct = { 9 | id: string; 10 | title: ReactNode; 11 | image: string; 12 | to: To; 13 | }; 14 | 15 | function ScrollingProductItem({ 16 | title, 17 | image, 18 | to, 19 | disabled, 20 | }: { 21 | title: ReactNode; 22 | image: string; 23 | to: To; 24 | disabled?: boolean; 25 | }) { 26 | return ( 27 |
  • 28 | 34 | 43 |
    44 |

    45 | {title} 46 |

    47 |
    48 | 49 |
  • 50 | ); 51 | } 52 | 53 | export function ScrollingProductList({ 54 | variant = "primary", 55 | products, 56 | }: { 57 | variant?: "primary" | "secondary"; 58 | products: ScrollingProductListProduct[]; 59 | }) { 60 | let items = useMemo( 61 | () => 62 | products 63 | .slice(0, 3) 64 | .map((product) => ( 65 | 71 | )), 72 | [products] 73 | ); 74 | 75 | let itemsDisabled = useMemo( 76 | () => 77 | products 78 | .slice(0, 3) 79 | .map((product) => ( 80 | 87 | )), 88 | [products] 89 | ); 90 | 91 | return ( 92 |
    98 |
    99 |
      {items}
    100 |
      104 | {itemsDisabled} 105 |
    106 |
    107 |
    108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | 3 | module.exports = { 4 | content: ["./app/**/*.tsx"], 5 | plugins: [ 6 | require("tailwindcss-named-groups"), 7 | require("@tailwindcss/typography"), 8 | ], 9 | theme: { 10 | ...defaultTheme, 11 | namedGroups: ["tpgi"], 12 | fontFamily: { 13 | ...defaultTheme.fontFamily, 14 | display: ['"Founders Grotesk", "Arial Black", sans-serif'], 15 | sans: ["Inter", ...defaultTheme.fontFamily.sans], 16 | mono: ["Source Code Pro", ...defaultTheme.fontFamily.mono], 17 | }, 18 | extend: { 19 | colors: { 20 | gray: { 21 | 50: "#f8fbfc", 22 | 100: "#eef2f8", 23 | 200: "#d0d0d0", 24 | 300: "#b7bcbe", 25 | 400: "#828282", 26 | 500: "#6a726d", 27 | 600: "#3f3f3f", 28 | 700: "#292929", 29 | 800: "#1e1e1e", 30 | 900: "#121212", 31 | }, 32 | red: { 33 | 50: "#fdfcfb", 34 | 100: "#fcf0ed", 35 | 200: "#f9ccdb", 36 | 300: "#f09eb7", 37 | 400: "#ee6e90", 38 | 500: "#f44250", 39 | brand: "#f44250", 40 | 600: "#d03150", 41 | 700: "#aa253a", 42 | 800: "#7d1a26", 43 | 900: "#4d1014", 44 | }, 45 | yellow: { 46 | 50: "#faf9f0", 47 | 100: "#f8ef9f", 48 | 200: "#fecc1b", 49 | brand: "#fecc1b", 50 | 300: "#d3be33", 51 | 400: "#a69719", 52 | 500: "#837a0b", 53 | 600: "#686207", 54 | 700: "#514a07", 55 | 800: "#373307", 56 | 900: "#271f06", 57 | }, 58 | green: { 59 | 50: "#f2f6f1", 60 | 100: "#e0f0de", 61 | 200: "#b6e7b5", 62 | 300: "#6bd968", 63 | brand: "#6bd968", 64 | 400: "#33ad4e", 65 | 500: "#22942c", 66 | 600: "#1e7e1f", 67 | 700: "#1b611b", 68 | 800: "#144317", 69 | 900: "#0f2913", 70 | }, 71 | aqua: { 72 | 50: "#ecf4f3", 73 | 100: "#c9eff0", 74 | 200: "#3defe9", 75 | brand: "#3defe9", 76 | 300: "#54cfb7", 77 | 400: "#1db28b", 78 | 500: "#149963", 79 | 600: "#13844c", 80 | 700: "#13663e", 81 | 800: "#0f4630", 82 | 900: "#0b2c25", 83 | }, 84 | blue: { 85 | 50: "#f5f9fb", 86 | 100: "#dff0fc", 87 | 200: "#bcdcf9", 88 | 300: "#8ebbf0", 89 | 400: "#3992ff", 90 | brand: "#3992ff", 91 | 500: "#4d71da", 92 | 600: "#3f55c9", 93 | 700: "#3240a7", 94 | 800: "#232b7a", 95 | 900: "#141b4e", 96 | }, 97 | pink: { 98 | 50: "#fcfbfb", 99 | 100: "#f9eef5", 100 | 200: "#f4caec", 101 | 300: "#e79fd7", 102 | 400: "#e571be", 103 | 500: "#d83bd2", 104 | brand: "#d83bd2", 105 | 600: "#c1338b", 106 | 700: "#9a2769", 107 | 800: "#701c45", 108 | 900: "#441325", 109 | }, 110 | }, 111 | animation: { 112 | marquee: "marquee 30s linear infinite", 113 | marquee2: "marquee2 30s linear infinite", 114 | }, 115 | keyframes: { 116 | marquee: { 117 | "0%": { transform: "translateX(0%)" }, 118 | "100%": { transform: "translateX(-100%)" }, 119 | }, 120 | marquee2: { 121 | "0%": { transform: "translateX(100%)" }, 122 | "100%": { transform: "translateX(0%)" }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | variants: { 128 | animation: ["motion-safe", "motion-reduce"], 129 | }, 130 | }; 131 | -------------------------------------------------------------------------------- /app/route-containers/wishlist/wishlist.server.ts: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, HeadersFunction, LoaderArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | 4 | import { 5 | updateCartItem, 6 | addToWishlist, 7 | updateWishlistItem, 8 | removeWishlistItem, 9 | getSession, 10 | } from "~/session.server"; 11 | import { getTranslations } from "~/translations.server"; 12 | import commerce from "~/commerce.server"; 13 | import { validateRedirect } from "~/utils/redirect.server"; 14 | 15 | export let headers: HeadersFunction = ({ actionHeaders }) => { 16 | return actionHeaders; 17 | }; 18 | 19 | export async function action({ request, params }: ActionArgs) { 20 | let [body, session] = await Promise.all([ 21 | request.text(), 22 | getSession(request, params), 23 | ]); 24 | 25 | let formData = new URLSearchParams(body); 26 | let redirectTo = validateRedirect(formData.get("redirect"), "/wishlist"); 27 | let action = formData.get("_action"); 28 | 29 | try { 30 | let wishlist = await session.getWishlist(); 31 | 32 | switch (action) { 33 | case "add": { 34 | let productId = formData.get("productId"); 35 | let variantId = formData.get("variantId"); 36 | if (!productId || !variantId) { 37 | break; 38 | } 39 | wishlist = addToWishlist(wishlist, productId, variantId, 1); 40 | break; 41 | } 42 | case "set-quantity": { 43 | let productId = formData.get("productId"); 44 | let variantId = formData.get("variantId"); 45 | let quantityStr = formData.get("quantity"); 46 | if (!productId || !variantId || !quantityStr) { 47 | break; 48 | } 49 | let quantity = Number.parseInt(quantityStr, 10); 50 | wishlist = updateWishlistItem(wishlist, productId, variantId, quantity); 51 | break; 52 | } 53 | case "delete": { 54 | let variantId = formData.get("variantId"); 55 | if (!variantId) { 56 | break; 57 | } 58 | wishlist = removeWishlistItem(wishlist, variantId); 59 | break; 60 | } 61 | case "move-to-cart": { 62 | let variantId = formData.get("variantId"); 63 | if (!variantId) { 64 | break; 65 | } 66 | let wishlistItem = wishlist.find( 67 | (item) => item.variantId === variantId 68 | ); 69 | if (!wishlistItem) { 70 | break; 71 | } 72 | let cart = await session.getCart(); 73 | let existingCartItem = cart.find( 74 | (item) => item.variantId === variantId 75 | ); 76 | wishlist = removeWishlistItem(wishlist, variantId); 77 | cart = updateCartItem( 78 | cart, 79 | wishlistItem.variantId, 80 | wishlistItem.quantity + (existingCartItem?.quantity || 0) 81 | ); 82 | await session.setCart(cart); 83 | } 84 | } 85 | 86 | await session.setWishlist(wishlist); 87 | return redirect(redirectTo, { 88 | headers: { 89 | "Set-Cookie": await session.commitSession(), 90 | }, 91 | }); 92 | } catch (error) { 93 | console.error(error); 94 | } 95 | 96 | return redirect(redirectTo); 97 | } 98 | 99 | export async function loader({ request, params }: LoaderArgs) { 100 | let session = await getSession(request, params); 101 | let lang = session.getLanguage(); 102 | let wishlist = await session 103 | .getWishlist() 104 | .then((wishlistItems) => commerce.getWishlistInfo(lang, wishlistItems)); 105 | 106 | return json({ 107 | wishlist, 108 | translations: getTranslations(lang, [ 109 | "Add item", 110 | "Remove from wishlist", 111 | "Subtract item", 112 | "Quantity: $1", 113 | "Your wishlist is empty", 114 | "Wishlist", 115 | "Move to cart", 116 | ]), 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /cypress/e2e/search.cy.ts: -------------------------------------------------------------------------------- 1 | describe("search", () => { 2 | it("works on desktop from landing page", () => { 3 | cy.viewport("macbook-16"); 4 | cy.visit("http://localhost:3000/en"); 5 | cy.get("[data-testid=search-input]").type("shirt"); 6 | cy.get("[data-testid=search-input]").type("{enter}"); 7 | cy.get("[data-testid=search-results-label]").contains(`"shirt"`); 8 | }); 9 | 10 | it("works on mobile from landing page", () => { 11 | cy.viewport("iphone-6"); 12 | cy.visit("http://localhost:3000/en"); 13 | cy.get("[data-testid=mobile-search-input]").type("shirt"); 14 | cy.get("[data-testid=mobile-search-input]").type("{enter}"); 15 | cy.get("[data-testid=search-results-label]").contains(`"shirt"`); 16 | }); 17 | 18 | it("can filter results further on desktop", () => { 19 | cy.viewport("macbook-16"); 20 | cy.visit("http://localhost:3000/en/search?q=shirt"); 21 | cy.get("[data-testid=sort-by-link]").first().click(); 22 | cy.url().should("include", "q=shirt"); 23 | cy.url().should("include", "sort="); 24 | }); 25 | 26 | it("can filter results further on mobile", () => { 27 | cy.viewport("iphone-6"); 28 | cy.visit("http://localhost:3000/en/search?q=shirt"); 29 | cy.get("[data-testid=sort-by-select]").select(0); 30 | cy.url().should("include", "q=shirt"); 31 | cy.url().should("include", "sort="); 32 | }); 33 | 34 | it("selecting a category removes query on desktop", () => { 35 | cy.viewport("macbook-16"); 36 | cy.visit("http://localhost:3000/en/search?q=shirt"); 37 | cy.get("[data-testid=category-link]").first().click(); 38 | cy.url().should("not.include", "q=shirt"); 39 | cy.url().should("include", "category="); 40 | }); 41 | 42 | it("selecting a category removes query on mobile", () => { 43 | cy.viewport("iphone-6"); 44 | cy.visit("http://localhost:3000/en/search?q=shirt"); 45 | cy.get("[data-testid=category-select]").select(0); 46 | cy.url().should("not.include", "q=shirt"); 47 | cy.url().should("include", "category="); 48 | }); 49 | 50 | it("can sort category on desktop", () => { 51 | cy.viewport("macbook-16"); 52 | cy.visit("http://localhost:3000/en/search"); 53 | cy.get("[data-testid=category-link]").first().click(); 54 | cy.get("[data-testid=sort-by-link]").first().click(); 55 | cy.url().should("include", "category="); 56 | cy.url().should("include", "sort="); 57 | }); 58 | 59 | it("can sort category on mobile", () => { 60 | cy.viewport("iphone-6"); 61 | cy.visit("http://localhost:3000/en/search"); 62 | cy.get("[data-testid=category-select]").select(0); 63 | cy.get("[data-testid=sort-by-select]").select(0); 64 | cy.url().should("include", "category="); 65 | cy.url().should("include", "sort="); 66 | }); 67 | 68 | it("another search clears query string on desktop", () => { 69 | cy.viewport("macbook-16"); 70 | cy.visit("http://localhost:3000/en/search"); 71 | cy.get("[data-testid=category-link]").first().click(); 72 | cy.get("[data-testid=sort-by-link]").first().click(); 73 | cy.url().should("include", "category="); 74 | cy.url().should("include", "sort="); 75 | cy.get("[data-testid=search-input]").type("shirt"); 76 | cy.get("[data-testid=search-input]").type("{enter}"); 77 | cy.get("[data-testid=search-results-label]").contains(`"shirt"`); 78 | cy.url().should("not.include", "category="); 79 | cy.url().should("not.include", "sort="); 80 | cy.url().should("include", "q=shirt"); 81 | }); 82 | 83 | it("another search clears query string on mobile", () => { 84 | cy.viewport("iphone-6"); 85 | cy.visit("http://localhost:3000/en/search"); 86 | cy.get("[data-testid=category-select]").select(0); 87 | cy.get("[data-testid=sort-by-select]").select(0); 88 | cy.url().should("include", "category="); 89 | cy.url().should("include", "sort="); 90 | cy.get("[data-testid=mobile-search-input]").type("shirt"); 91 | cy.get("[data-testid=mobile-search-input]").type("{enter}"); 92 | cy.get("[data-testid=search-results-label]").contains(`"shirt"`); 93 | cy.url().should("not.include", "category="); 94 | cy.url().should("not.include", "sort="); 95 | cy.url().should("include", "q=shirt"); 96 | }); 97 | }); 98 | 99 | export {}; 100 | -------------------------------------------------------------------------------- /app/models/request-response-caches/swr-redis-cache.server.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { createClient } from "redis"; 3 | 4 | import type { RequestResponseCache } from "../request-response-cache.server"; 5 | 6 | type CachedResponse = { 7 | status: number; 8 | statusText: string; 9 | body: string; 10 | headers: [string, string][]; 11 | }; 12 | 13 | export let createSwrRedisCache = ({ 14 | redisClient, 15 | }: { 16 | redisClient: ReturnType; 17 | }): RequestResponseCache => { 18 | let connectionPromise: Promise; 19 | if (!redisClient.isOpen) { 20 | connectionPromise = redisClient.connect(); 21 | } 22 | 23 | return async (request, maxAgeSeconds) => { 24 | await connectionPromise; 25 | 26 | let method = request.method.toLowerCase(); 27 | 28 | let hash = createHash("sha256"); 29 | hash.update(method); 30 | hash.update(request.url); 31 | for (let header of request.headers) { 32 | hash.update(header[0]); 33 | hash.update(header[1]); 34 | } 35 | let body: string | null = null; 36 | if (method !== "get" && method !== "head" && request.body) { 37 | body = await request.clone().text(); 38 | } 39 | if (typeof body === "string") { 40 | hash.update(body); 41 | } 42 | let key = hash.digest("hex"); 43 | 44 | let stillGoodKey = `swr:stillgood:${key}`; 45 | let responseKey = `swr:response:${key}`; 46 | 47 | let cachedStillGoodPromise = redisClient 48 | .get(stillGoodKey) 49 | .then((cachedStillGood) => { 50 | if (!cachedStillGood) { 51 | return false; 52 | } 53 | return true; 54 | }) 55 | .catch(() => false); 56 | 57 | let response = await redisClient 58 | .get(responseKey) 59 | .then(async (cachedResponseString) => { 60 | if (!cachedResponseString) { 61 | return null; 62 | } 63 | 64 | let cachedResponseJson = JSON.parse( 65 | cachedResponseString 66 | ) as CachedResponse; 67 | 68 | if (cachedResponseJson.status !== 200) { 69 | return null; 70 | } 71 | 72 | let cachedResponse = new Response(cachedResponseJson.body, { 73 | status: cachedResponseJson.status, 74 | statusText: cachedResponseJson.statusText, 75 | headers: cachedResponseJson.headers, 76 | }); 77 | 78 | if (await cachedStillGoodPromise) { 79 | cachedResponse.headers.set("X-SWR-Cache", "hit"); 80 | } else { 81 | cachedResponse.headers.set("X-SWR-Cache", "stale"); 82 | 83 | (async () => { 84 | let responseToCache = await fetch(request.clone()); 85 | if (responseToCache.status === 200) { 86 | let toCache: CachedResponse = { 87 | status: responseToCache.status, 88 | statusText: responseToCache.statusText, 89 | headers: Array.from(responseToCache.headers), 90 | body: await responseToCache.text(), 91 | }; 92 | 93 | await redisClient.set(responseKey, JSON.stringify(toCache)); 94 | await redisClient.setEx(stillGoodKey, maxAgeSeconds, "true"); 95 | } 96 | })().catch((error) => { 97 | console.error("Failed to revalidate", error); 98 | }); 99 | } 100 | 101 | return cachedResponse; 102 | }) 103 | .catch(() => null); 104 | 105 | if (!response) { 106 | response = await fetch(request.clone()); 107 | let responseToCache = response.clone(); 108 | response.headers.set("X-SWR-Cache", "miss"); 109 | 110 | if (responseToCache.status === 200) { 111 | (async () => { 112 | let toCache: CachedResponse = { 113 | status: responseToCache.status, 114 | statusText: responseToCache.statusText, 115 | headers: Array.from(responseToCache.headers), 116 | body: await responseToCache.text(), 117 | }; 118 | 119 | await redisClient.set(responseKey, JSON.stringify(toCache)); 120 | await redisClient.setEx(stillGoodKey, maxAgeSeconds, "true"); 121 | })().catch((error) => { 122 | console.error("Failed to seed cache", error); 123 | }); 124 | } 125 | } 126 | 127 | return response; 128 | }; 129 | }; 130 | -------------------------------------------------------------------------------- /app/components/cdp-product-grid-item.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import { Form, Link, useLocation } from "@remix-run/react"; 3 | import cn from "classnames"; 4 | import type { To } from "react-router-dom"; 5 | import Image from "remix-image"; 6 | 7 | import { WishlistIcon } from "~/components/icons"; 8 | import { PickTranslations } from "~/translations.server"; 9 | 10 | export type CdpGridItemProduct = { 11 | id: string; 12 | title: string; 13 | formattedPrice: string; 14 | favorited: boolean; 15 | image: string; 16 | to: To; 17 | defaultVariantId: string; 18 | }; 19 | 20 | export function CdpProductGridItem({ 21 | product, 22 | translations, 23 | }: { 24 | product: CdpGridItemProduct; 25 | translations: PickTranslations<"Add to wishlist" | "Remove from wishlist">; 26 | }) { 27 | let id = `three-product-grid-item-${useId()}`; 28 | let location = useLocation(); 29 | 30 | return ( 31 |
  • 32 |
    33 | 39 | 62 | 63 |
    64 |
    65 | 72 |

    73 | {product.title} 74 |

    75 |
    76 |

    77 | {product.formattedPrice} 78 |

    79 | 80 |
    81 | 87 | 92 | 98 | 104 | 105 | 121 |
    122 |
    123 |
    124 |
    125 |
  • 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /app/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "@remix-run/node"; 2 | import type { Params } from "react-router-dom"; 3 | 4 | import type { 5 | CartItem, 6 | WishlistItem, 7 | } from "./models/ecommerce-provider.server"; 8 | import { validateLanguage } from "./models/language"; 9 | import type { Language } from "./models/language"; 10 | 11 | if (!process.env.ENCRYPTION_KEY) { 12 | throw new Error("ENCRYPTION_KEY environment variable is not set"); 13 | } 14 | 15 | let sessionStorage = createCookieSessionStorage({ 16 | cookie: { 17 | name: "session", 18 | httpOnly: true, 19 | path: "/", 20 | sameSite: "lax", 21 | secrets: [process.env.ENCRYPTION_KEY], 22 | }, 23 | }); 24 | 25 | let cartSessionKey = "cart"; 26 | let langSessionKey = "language"; 27 | let wishlistSessionKey = "wishlist"; 28 | 29 | export async function getSession( 30 | input: Request | string | null | undefined, 31 | params: Params 32 | ) { 33 | let cookieHeader = 34 | !input || typeof input === "string" ? input : input.headers.get("Cookie"); 35 | let session = await sessionStorage.getSession(cookieHeader); 36 | 37 | return { 38 | commitSession() { 39 | return sessionStorage.commitSession(session); 40 | }, 41 | // TODO: Get and set cart from redis or something if user is logged in (could probably use a storage abstraction) 42 | async getCart(): Promise { 43 | let cart = JSON.parse(session.get(cartSessionKey) || "[]"); 44 | return cart; 45 | }, 46 | async setCart(cart: CartItem[]) { 47 | session.set(cartSessionKey, JSON.stringify(cart)); 48 | }, 49 | // TODO: Get and set wishlist from redis or something if user is logged in (could probably use a storage abstraction) 50 | async getWishlist(): Promise { 51 | let wishlist = JSON.parse(session.get(wishlistSessionKey) || "[]"); 52 | return wishlist; 53 | }, 54 | async setWishlist(wishlist: WishlistItem[]) { 55 | session.set(wishlistSessionKey, JSON.stringify(wishlist)); 56 | }, 57 | getLanguage(): Language { 58 | if (validateLanguage(params.lang)) { 59 | return params.lang; 60 | } 61 | 62 | return session.get(langSessionKey) || "en"; 63 | }, 64 | setLanguage(language: Language) { 65 | session.set(langSessionKey, language); 66 | }, 67 | }; 68 | } 69 | 70 | export function addToCart( 71 | cart: CartItem[], 72 | variantId: string, 73 | quantity: number 74 | ) { 75 | let added = false; 76 | for (let item of cart) { 77 | if (item.variantId === variantId) { 78 | item.quantity += quantity; 79 | added = true; 80 | break; 81 | } 82 | } 83 | if (!added) { 84 | cart.push({ variantId, quantity }); 85 | } 86 | return cart; 87 | } 88 | 89 | export function updateCartItem( 90 | cart: CartItem[], 91 | variantId: string, 92 | quantity: number 93 | ) { 94 | let updated = false; 95 | for (let item of cart) { 96 | if (item.variantId === variantId) { 97 | item.quantity = quantity; 98 | updated = true; 99 | break; 100 | } 101 | } 102 | if (!updated) { 103 | cart.push({ variantId, quantity }); 104 | } 105 | return cart; 106 | } 107 | 108 | export function removeCartItem(cart: CartItem[], variantId: string) { 109 | return cart.filter((item) => item.variantId !== variantId); 110 | } 111 | 112 | export function addToWishlist( 113 | wishlist: WishlistItem[], 114 | productId: string, 115 | variantId: string, 116 | quantity: number 117 | ) { 118 | let added = false; 119 | for (let item of wishlist) { 120 | if (item.variantId === variantId) { 121 | item.quantity += quantity; 122 | added = true; 123 | break; 124 | } 125 | } 126 | if (!added) { 127 | wishlist.push({ productId, variantId, quantity }); 128 | } 129 | return wishlist; 130 | } 131 | 132 | export function updateWishlistItem( 133 | wishlist: WishlistItem[], 134 | productId: string, 135 | variantId: string, 136 | quantity: number 137 | ) { 138 | let updated = false; 139 | for (let item of wishlist) { 140 | if (item.variantId === variantId) { 141 | item.quantity = quantity; 142 | updated = true; 143 | break; 144 | } 145 | } 146 | if (!updated) { 147 | wishlist.push({ productId, variantId, quantity }); 148 | } 149 | return wishlist; 150 | } 151 | 152 | export function removeWishlistItem( 153 | wishlist: WishlistItem[], 154 | variantId: string 155 | ) { 156 | return wishlist.filter((item) => item.variantId !== variantId); 157 | } 158 | -------------------------------------------------------------------------------- /app/components/icons.tsx: -------------------------------------------------------------------------------- 1 | // https://heroicons.com/ 2 | 3 | export function MenuIcon({ className }: { className: string }) { 4 | return ( 5 | 11 | 17 | 18 | ); 19 | } 20 | 21 | export function CloseIcon({ className }: { className: string }) { 22 | return ( 23 | 29 | 35 | 36 | ); 37 | } 38 | 39 | export function CartIcon({ className }: { className: string }) { 40 | return ( 41 | 47 | 53 | 54 | ); 55 | } 56 | 57 | export function WishlistIcon({ className }: { className: string }) { 58 | return ( 59 | 65 | 71 | 72 | ); 73 | } 74 | 75 | export function GithubIcon({ className }: { className: string }) { 76 | return ( 77 | 84 | 85 | 86 | ); 87 | } 88 | 89 | export function ChevronDown({ className }: { className: string }) { 90 | return ( 91 | 97 | 103 | 104 | ); 105 | } 106 | 107 | export function ChevronUp({ className }: { className: string }) { 108 | return ( 109 | 115 | 121 | 122 | ); 123 | } 124 | 125 | export function MinusIcon({ className }: { className: string }) { 126 | return ( 127 | 133 | 139 | 140 | ); 141 | } 142 | 143 | export function PlusIcon({ className }: { className: string }) { 144 | return ( 145 | 151 | 157 | 158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /app/components/language-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useState } from "react"; 2 | import { Form, useLocation } from "@remix-run/react"; 3 | import { Dialog, Transition } from "@headlessui/react"; 4 | 5 | import { Language, validateLanguage } from "~/models/language"; 6 | 7 | import { PickTranslations } from "~/translations.server"; 8 | 9 | export function LanguageDialog({ 10 | lang, 11 | translations, 12 | }: { 13 | lang: Language; 14 | translations: PickTranslations< 15 | | "Looks like your language doesn't match" 16 | | "Would you like to switch to $1?" 17 | | "Yes" 18 | | "No" 19 | >; 20 | }) { 21 | let location = useLocation(); 22 | let [browserLang, setBrowserLang] = useState(null); 23 | let closeLangModal = () => setBrowserLang(null); 24 | useEffect(() => { 25 | let modalShown = localStorage.getItem("lang-modal-shown"); 26 | let shouldShowModal = location.pathname === "/"; 27 | if (!modalShown && shouldShowModal) { 28 | let browserLang = navigator.language.split("-", 2)[0].toLowerCase(); 29 | if ( 30 | !modalShown && 31 | browserLang !== lang && 32 | validateLanguage(browserLang) 33 | ) { 34 | setBrowserLang(browserLang); 35 | localStorage.setItem("lang-modal-shown", "1"); 36 | } 37 | } 38 | }, [lang, location]); 39 | 40 | return ( 41 | 42 | 47 |
    48 | 57 | 58 | 59 | 60 | {/* This element is to trick the browser into centering the modal contents. */} 61 | 67 | 76 |
    77 | 78 | {translations?.["Looks like your language doesn't match"]} 79 | 80 |
    81 |

    82 | {translations?.["Would you like to switch to $1?"].replace( 83 | "$1", 84 | browserLang?.toUpperCase() || "" 85 | )} 86 |

    87 |
    88 | 89 |
    94 | 99 | 106 | 113 |
    114 |
    115 |
    116 |
    117 |
    118 |
    119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/translations.server.tsx: -------------------------------------------------------------------------------- 1 | import { Language } from "~/models/language"; 2 | 3 | export function getTranslations< 4 | RequestedTranslations extends keyof Translations 5 | >(lang: Language, requestedTranslations: Array) { 6 | let results: Record = {} as any; 7 | for (let translation of requestedTranslations) { 8 | results[translation] = translations[translation][lang]; 9 | } 10 | 11 | return results; 12 | } 13 | 14 | type Translations = typeof translations; 15 | export type PickTranslations = 16 | Record; 17 | 18 | let translations = { 19 | All: { 20 | en: "All", 21 | es: "Todo", 22 | }, 23 | Cart: { 24 | en: "Shopping Cart", 25 | es: "Carrito de compras", 26 | }, 27 | "Close Menu": { 28 | en: "Close Menu", 29 | es: "Cerrar menú", 30 | }, 31 | English: { 32 | en: "English", 33 | es: "Inglés", 34 | }, 35 | Home: { 36 | en: "Home", 37 | es: "Inicio", 38 | }, 39 | "Open Menu": { 40 | en: "Open Menu", 41 | es: "Abrir menú", 42 | }, 43 | "Search for products...": { 44 | en: "Search for products...", 45 | es: "Buscar productos...", 46 | }, 47 | Spanish: { 48 | en: "Spanish", 49 | es: "Español", 50 | }, 51 | "Store Name": { 52 | en: "Remix Ecommerce", 53 | es: "Remix Ecommerce", 54 | }, 55 | Wishlist: { 56 | en: "Wishlist", 57 | es: "Lista de deseos", 58 | }, 59 | "Price: High to low": { 60 | en: "Price: High to low", 61 | es: "Precio: de mayor a menor", 62 | }, 63 | "Price: Low to high": { 64 | en: "Price: Low to high", 65 | es: "Precio: de menor a mayor", 66 | }, 67 | "Latest arrivals": { 68 | en: "Latest arrivals", 69 | es: "Últimos arrivos", 70 | }, 71 | Trending: { 72 | en: "Trending", 73 | es: "Tendencias", 74 | }, 75 | "Looks like your language doesn't match": { 76 | en: "Looks like your language doesn't match", 77 | es: "Parece que tu idioma no coincide", 78 | }, 79 | "Would you like to switch to $1?": { 80 | en: "Would you like to switch to $1?", 81 | es: "¿Quieres cambiar a $1?", 82 | }, 83 | Yes: { 84 | en: "Yes", 85 | es: "Sí", 86 | }, 87 | No: { 88 | en: "No", 89 | es: "No", 90 | }, 91 | Close: { 92 | en: "Close", 93 | es: "Cerrar", 94 | }, 95 | "Your cart is empty": { 96 | en: "Your cart is empty", 97 | es: "Tu carrito está vacío", 98 | }, 99 | "Add to cart": { 100 | en: "Add to cart", 101 | es: "Añadir al carrito", 102 | }, 103 | Adding: { 104 | en: "Adding...", 105 | es: "Agregando...", 106 | }, 107 | "Added!": { 108 | en: "Added!", 109 | es: "¡Adicional!", 110 | }, 111 | "Sold out": { 112 | en: "Sold out", 113 | es: "No disponible", 114 | }, 115 | "Quantity: $1": { 116 | en: "Quantity: $1", 117 | es: "Cantidad: $1", 118 | }, 119 | "Remove from cart": { 120 | en: "Remove from cart", 121 | es: "Eliminar del carrito", 122 | }, 123 | "Subtract item": { 124 | en: "Subtract item", 125 | es: "Restar item", 126 | }, 127 | "Add item": { 128 | en: "Add item", 129 | es: "Añadir item", 130 | }, 131 | "Calculated at checkout": { 132 | en: "Calculated at checkout", 133 | es: "Calculado al pagar", 134 | }, 135 | "Proceed to checkout": { 136 | en: "Proceed to checkout", 137 | es: "Proceder a pagar", 138 | }, 139 | Subtotal: { 140 | en: "Subtotal", 141 | es: "Subtotal", 142 | }, 143 | Total: { 144 | en: "Total", 145 | es: "Total", 146 | }, 147 | Taxes: { 148 | en: "Taxes", 149 | es: "Impuestos", 150 | }, 151 | Shipping: { 152 | en: "Shipping", 153 | es: "Envío", 154 | }, 155 | "Your wishlist is empty": { 156 | en: "Your wishlist is empty", 157 | es: "Tu lista de deseos está vacía", 158 | }, 159 | "Remove from wishlist": { 160 | en: "Remove from wishlist", 161 | es: "Eliminar de la lista de deseos", 162 | }, 163 | "Move to cart": { 164 | en: "Move to cart", 165 | es: "Mover al carrito", 166 | }, 167 | "Add to wishlist": { 168 | en: "Add to wishlist", 169 | es: "Añadir a la lista de deseos", 170 | }, 171 | 172 | MockCTAHeadline: { 173 | en: "Dessert dragée halvah croissant.", 174 | es: "Dessert dragée halvah croissant.", 175 | }, 176 | MockCTADescription: { 177 | en: "Cupcake ipsum dolor sit amet lemon drops pastry cotton candy. Sweet carrot cake macaroon bonbon croissant fruitcake jujubes macaroon oat cake. Soufflé bonbon caramels jelly beans. Tiramisu sweet roll cheesecake pie carrot cake.", 178 | es: "Cupcake ipsum dolor sit amet lemon drops pastry cotton candy. Sweet carrot cake macaroon bonbon croissant fruitcake jujubes macaroon oat cake. Soufflé bonbon caramels jelly beans. Tiramisu sweet roll cheesecake pie carrot cake.", 179 | }, 180 | MockCTALink: { 181 | en: "Read it here", 182 | es: "Leerlo aquí", 183 | }, 184 | }; 185 | -------------------------------------------------------------------------------- /app/components/wishlist-popover.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useMemo } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | 4 | import type { FullWishlistItem } from "~/models/ecommerce-provider.server"; 5 | import { PickTranslations } from "~/translations.server"; 6 | 7 | import { CloseIcon, WishlistIcon } from "./icons"; 8 | 9 | import { WishlistListItem } from "./wishlist-listitem"; 10 | 11 | export function WishlistPopover({ 12 | wishlist, 13 | open, 14 | onClose, 15 | translations, 16 | }: { 17 | wishlist?: FullWishlistItem[] | null; 18 | open: boolean; 19 | onClose: () => void; 20 | translations: PickTranslations< 21 | | "Wishlist" 22 | | "Close" 23 | | "Your wishlist is empty" 24 | | "Quantity: $1" 25 | | "Remove from wishlist" 26 | | "Subtract item" 27 | | "Add item" 28 | | "Move to cart" 29 | >; 30 | }) { 31 | let wishlistCount = useMemo( 32 | () => wishlist?.reduce((acc, item) => acc + item.quantity, 0), 33 | [wishlist] 34 | ); 35 | 36 | return ( 37 | 38 | 39 |
    40 | 49 | 53 | 54 | 55 | 64 |
    65 |
    66 | 74 | 75 | 76 | {!!wishlistCount && ( 77 | 81 | {wishlistCount} 82 | 83 | )} 84 | 85 |
    86 |
    87 | {!wishlist ? ( 88 |
    89 | 90 | 91 | 92 | 96 | {translations["Your wishlist is empty"]} 97 | 98 |
    99 | ) : ( 100 | <> 101 |
    102 | 106 | {translations.Wishlist} 107 | 108 |
      109 | {wishlist.map((item) => ( 110 | 121 | ))} 122 |
    123 |
    124 | 125 | )} 126 |
    127 |
    128 |
    129 |
    130 |
    131 |
    132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /app/components/cart-popover.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useMemo } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | 4 | import type { CartInfo } from "~/models/ecommerce-provider.server"; 5 | import type { PickTranslations } from "~/translations.server"; 6 | 7 | import { CartIcon, CloseIcon } from "./icons"; 8 | 9 | import { CartListItem } from "./cart-listitem"; 10 | import { CheckoutForm } from "./checkout-form"; 11 | 12 | export function CartPopover({ 13 | cart, 14 | open, 15 | onClose, 16 | translations, 17 | }: { 18 | cart?: CartInfo | null; 19 | open: boolean; 20 | onClose: () => void; 21 | translations: PickTranslations< 22 | | "Cart" 23 | | "Close" 24 | | "Your cart is empty" 25 | | "Quantity: $1" 26 | | "Remove from cart" 27 | | "Subtract item" 28 | | "Add item" 29 | | "Proceed to checkout" 30 | | "Subtotal" 31 | | "Total" 32 | | "Taxes" 33 | | "Shipping" 34 | >; 35 | }) { 36 | let cartCount = useMemo( 37 | () => cart?.items?.reduce((acc, item) => acc + item.quantity, 0), 38 | [cart] 39 | ); 40 | 41 | return ( 42 | 43 | 44 |
    45 | 54 | 58 | 59 | 60 | 69 |
    70 |
    71 | 78 | 79 | 80 | {!!cartCount && ( 81 | 85 | {cartCount} 86 | 87 | )} 88 | 89 |
    90 |
    91 | {!cart?.items ? ( 92 |
    93 | 94 | 95 | 96 | 100 | {translations["Your cart is empty"]} 101 | 102 |
    103 | ) : ( 104 | <> 105 |
    106 | 110 | {translations.Cart} 111 | 112 |
      113 | {cart.items.map((item) => ( 114 | 124 | ))} 125 |
    126 |
    127 | 132 | 133 | )} 134 |
    135 |
    136 |
    137 |
    138 |
    139 |
    140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /app/components/cart-listitem.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { useFetcher, useLocation } from "@remix-run/react"; 3 | import Image from "remix-image"; 4 | import cn from "classnames"; 5 | 6 | import { PickTranslations } from "~/translations.server"; 7 | 8 | import { CloseIcon, MinusIcon, PlusIcon } from "./icons"; 9 | 10 | enum Actions { 11 | setQuantity = "set-quantity", 12 | delete = "delete", 13 | } 14 | 15 | export function CartListItem({ 16 | formattedOptions, 17 | formattedPrice, 18 | image, 19 | quantity, 20 | title, 21 | variantId, 22 | translations, 23 | }: { 24 | formattedOptions: ReactNode; 25 | formattedPrice: ReactNode; 26 | image: string; 27 | quantity: number; 28 | title: ReactNode; 29 | variantId: string; 30 | translations: PickTranslations< 31 | "Add item" | "Remove from cart" | "Subtract item" | "Quantity: $1" 32 | >; 33 | }) { 34 | let location = useLocation(); 35 | let fetcher = useFetcher(); 36 | let optimisticQuantity = quantity; 37 | let optimisticDeleting = false; 38 | 39 | if (fetcher.submission) { 40 | let values = Object.fromEntries(fetcher.submission.formData); 41 | if (typeof values.quantity === "string") { 42 | optimisticQuantity = parseInt(values.quantity, 10); 43 | } 44 | 45 | if (values._action === Actions.delete) { 46 | optimisticDeleting = true; 47 | } 48 | } 49 | 50 | return ( 51 | 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /app/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { Form, Link, useLocation } from "@remix-run/react"; 3 | import type { To } from "react-router-dom"; 4 | import Image from "remix-image"; 5 | import { Popover, Transition } from "@headlessui/react"; 6 | import cn from "classnames"; 7 | 8 | import { Language } from "~/models/language"; 9 | 10 | import { MexicoFlag, UnitedStatesFlag } from "./flags"; 11 | import { ChevronUp, GithubIcon } from "./icons"; 12 | 13 | export type FooterPage = { 14 | id: string; 15 | title: string; 16 | to: To; 17 | }; 18 | 19 | export function Footer({ 20 | lang, 21 | logoHref, 22 | pages, 23 | storeName, 24 | }: { 25 | lang: Language; 26 | logoHref: string; 27 | pages: FooterPage[]; 28 | storeName?: string; 29 | }) { 30 | let location = useLocation(); 31 | 32 | return ( 33 |
    34 |
    35 |
    36 |
    37 | 44 | {storeName ? ( 45 |

    {storeName}

    46 | ) : null} 47 |
    48 |
    49 |
      50 | {pages.map((page) => ( 51 |
    • 52 | 57 | {page.title} 58 | 59 |
    • 60 | ))} 61 |
    62 |
    63 |
    64 | 70 | View source 71 | 72 | 73 | 80 | {({ open, close }) => ( 81 | <> 82 | 87 | 88 | 92 | Change language 93 | {(() => { 94 | switch (lang) { 95 | case "es": 96 | return ( 97 | 98 | ); 99 | default: 100 | return ( 101 | 102 | ); 103 | } 104 | })()} 105 | 111 | 112 | 113 | 122 | 123 |
      124 |
    • 125 | 134 |
    • 135 |
    • 136 | 145 |
    • 146 |
    147 |
    148 |
    149 | 150 | )} 151 |
    152 |
    153 |
    154 |
    155 |
    156 |

    157 | © 2022 {storeName}. All rights reserved. 158 |

    159 |
    160 |
    161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /app/route-containers/layout/layout.component.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, lazy, useMemo, useState } from "react"; 2 | import type { ReactNode } from "react"; 3 | import type { 4 | LinksFunction, 5 | MetaFunction, 6 | SerializeFrom, 7 | } from "@remix-run/node"; 8 | import type { ShouldRevalidateFunction } from "@remix-run/react"; 9 | import { 10 | // Await, 11 | Links, 12 | LiveReload, 13 | Meta, 14 | Outlet, 15 | Scripts, 16 | ScrollRestoration, 17 | useMatches, 18 | } from "@remix-run/react"; 19 | import { cssBundleHref } from "@remix-run/css-bundle"; 20 | 21 | import { ClientOnly } from "~/components/client-only"; 22 | import { Footer } from "~/components/footer"; 23 | import { Navbar, NavbarCategory } from "~/components/navbar"; 24 | 25 | import logoHref from "~/images/remix-glow.svg"; 26 | import globalStylesheetHref from "~/styles/global.css"; 27 | 28 | import { GenericCatchBoundary } from "../boundaries/generic-catch-boundary"; 29 | import { GenericErrorBoundary } from "../boundaries/generic-error-boundary"; 30 | import type { loader } from "./layout.server"; 31 | 32 | let CartPopover = lazy(() => 33 | import("~/components/cart-popover").then(({ CartPopover }) => ({ 34 | default: CartPopover, 35 | })) 36 | ); 37 | let LanguageDialog = lazy(() => 38 | import("~/components/language-dialog").then(({ LanguageDialog }) => ({ 39 | default: LanguageDialog, 40 | })) 41 | ); 42 | let WishlistPopover = lazy(() => 43 | import("~/components/wishlist-popover").then(({ WishlistPopover }) => ({ 44 | default: WishlistPopover, 45 | })) 46 | ); 47 | 48 | export const meta: MetaFunction = () => { 49 | return { 50 | title: "Remix Ecommerce", 51 | description: "An example ecommerce site built with Remix.", 52 | }; 53 | }; 54 | 55 | export let links: LinksFunction = () => { 56 | return [ 57 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 58 | { 59 | rel: "stylesheet", 60 | href: globalStylesheetHref, 61 | }, 62 | ]; 63 | }; 64 | 65 | function Layout({ 66 | cart, 67 | wishlist, 68 | children, 69 | }: { 70 | children?: ReactNode; 71 | cart?: SerializeFrom["cart"]; 72 | wishlist?: SerializeFrom["wishlist"]; 73 | }) { 74 | let matches = useMatches(); 75 | let rootMatch = matches.find((match) => match.id === "root"); 76 | let loaderData = rootMatch?.data as SerializeFrom | undefined; 77 | 78 | let { categories, lang, pages, translations } = loaderData || { 79 | lang: "en", 80 | pages: [], 81 | }; 82 | 83 | let allCategories = useMemo(() => { 84 | let results: NavbarCategory[] = translations 85 | ? [ 86 | { 87 | name: translations.All, 88 | to: `/${lang}/search`, 89 | }, 90 | ] 91 | : []; 92 | 93 | if (categories) { 94 | results.push(...categories); 95 | } 96 | return results; 97 | }, [categories]); 98 | 99 | let [cartOpen, setCartOpen] = useState(false); 100 | let [wishlistOpen, setWishlistOpen] = useState(false); 101 | 102 | return ( 103 | <> 104 | setCartOpen(true)} 113 | onOpenWishlist={() => setWishlistOpen(true)} 114 | /> 115 |
    {children}
    116 |