From abb3ab5b73fee7985f13210081564a48e6da90c5 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 4 Jan 2022 17:09:39 -0800 Subject: [PATCH] initial commit --- .dockerignore | 13 + .env.example | 9 + .gitignore | 7 + Dockerfile | 51 + README.md | 53 + app/commerce.server.ts | 21 + app/components/cart-listitem.tsx | 139 + app/components/cart-popover.tsx | 141 + app/components/checkout-form.tsx | 57 + app/components/client-only.tsx | 10 + app/components/cta-banner.tsx | 52 + app/components/flags.tsx | 1683 ++ app/components/footer.tsx | 158 + app/components/icons.tsx | 159 + app/components/language-dialog.tsx | 120 + app/components/navbar.tsx | 207 + app/components/optimized-image.tsx | 56 + app/components/product-details.tsx | 195 + app/components/scrolling-product-list.tsx | 108 + app/components/search-container.ts | 3 + app/components/three-product-grid.tsx | 184 + .../boundaries/generic-catch-boundary.tsx | 17 + .../boundaries/generic-error-boundary.tsx | 11 + app/containers/cart/cart.component.tsx | 50 + app/containers/cart/cart.server.ts | 100 + app/containers/cdp/cdp.component.tsx | 202 + app/containers/cdp/cdp.server.ts | 60 + .../generic-page/generic-page.component.tsx | 29 + .../generic-page/generic-page.server.ts | 25 + app/containers/home/home.component.tsx | 63 + app/containers/home/home.server.ts | 40 + app/containers/layout/layout.component.tsx | 181 + app/containers/layout/layout.server.ts | 106 + app/containers/pdp/pdp.component.tsx | 32 + app/containers/pdp/pdp.server.ts | 67 + app/entry.client.tsx | 4 + app/entry.server.tsx | 24 + app/images/remix-glow.svg | 76 + app/models/ecommerce-provider.server.ts | 92 + .../ecommerce-providers/shopify.server.ts | 596 + app/models/language.ts | 5 + app/models/request-response-cache.server.ts | 3 + .../swr-redis-cache.server.ts | 120 + app/redis.server.ts | 17 + app/root.tsx | 10 + app/routes/$.ts | 13 + app/routes/$lang/$.ts | 13 + app/routes/$lang/cart.ts | 11 + app/routes/$lang/index.ts | 11 + app/routes/$lang/product.$slug.ts | 14 + app/routes/$lang/search.ts | 11 + app/routes/actions/checkout.ts | 26 + app/routes/actions/set-language.ts | 41 + app/routes/cart.ts | 11 + app/routes/index.ts | 11 + app/routes/product.$slug.ts | 14 + app/routes/resources/image.ts | 3 + app/routes/search.ts | 11 + app/session.server.ts | 98 + app/translations.server.tsx | 160 + app/utils/use-scroll-swipe.ts | 98 + docker-compose.yml | 11 + fly.toml | 49 + images/route.server.ts | 138 + package-lock.json | 15693 ++++++++++++++++ package.json | 56 + patches/@remix-run+server-runtime+1.1.1.patch | 40 + postcss.config.js | 7 + public/favicon.ico | Bin 0 -> 16958 bytes public/robots.txt | 2 + remix.config.js | 17 + remix.env.d.ts | 2 + styles/global.css | 45 + styles/global.server.ts | 19 + tailwind.config.js | 130 + tsconfig.json | 21 + 76 files changed, 22132 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/commerce.server.ts create mode 100644 app/components/cart-listitem.tsx create mode 100644 app/components/cart-popover.tsx create mode 100644 app/components/checkout-form.tsx create mode 100644 app/components/client-only.tsx create mode 100644 app/components/cta-banner.tsx create mode 100644 app/components/flags.tsx create mode 100644 app/components/footer.tsx create mode 100644 app/components/icons.tsx create mode 100644 app/components/language-dialog.tsx create mode 100644 app/components/navbar.tsx create mode 100644 app/components/optimized-image.tsx create mode 100644 app/components/product-details.tsx create mode 100644 app/components/scrolling-product-list.tsx create mode 100644 app/components/search-container.ts create mode 100644 app/components/three-product-grid.tsx create mode 100644 app/containers/boundaries/generic-catch-boundary.tsx create mode 100644 app/containers/boundaries/generic-error-boundary.tsx create mode 100644 app/containers/cart/cart.component.tsx create mode 100644 app/containers/cart/cart.server.ts create mode 100644 app/containers/cdp/cdp.component.tsx create mode 100644 app/containers/cdp/cdp.server.ts create mode 100644 app/containers/generic-page/generic-page.component.tsx create mode 100644 app/containers/generic-page/generic-page.server.ts create mode 100644 app/containers/home/home.component.tsx create mode 100644 app/containers/home/home.server.ts create mode 100644 app/containers/layout/layout.component.tsx create mode 100644 app/containers/layout/layout.server.ts create mode 100644 app/containers/pdp/pdp.component.tsx create mode 100644 app/containers/pdp/pdp.server.ts create mode 100644 app/entry.client.tsx create mode 100644 app/entry.server.tsx create mode 100644 app/images/remix-glow.svg create mode 100644 app/models/ecommerce-provider.server.ts create mode 100644 app/models/ecommerce-providers/shopify.server.ts create mode 100644 app/models/language.ts create mode 100644 app/models/request-response-cache.server.ts create mode 100644 app/models/request-response-caches/swr-redis-cache.server.ts create mode 100644 app/redis.server.ts create mode 100644 app/root.tsx create mode 100644 app/routes/$.ts create mode 100644 app/routes/$lang/$.ts create mode 100644 app/routes/$lang/cart.ts create mode 100644 app/routes/$lang/index.ts create mode 100644 app/routes/$lang/product.$slug.ts create mode 100644 app/routes/$lang/search.ts create mode 100644 app/routes/actions/checkout.ts create mode 100644 app/routes/actions/set-language.ts create mode 100644 app/routes/cart.ts create mode 100644 app/routes/index.ts create mode 100644 app/routes/product.$slug.ts create mode 100644 app/routes/resources/image.ts create mode 100644 app/routes/search.ts create mode 100644 app/session.server.ts create mode 100644 app/translations.server.tsx create mode 100644 app/utils/use-scroll-swipe.ts create mode 100644 docker-compose.yml create mode 100644 fly.toml create mode 100644 images/route.server.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 patches/@remix-run+server-runtime+1.1.1.patch create mode 100644 postcss.config.js create mode 100644 public/favicon.ico create mode 100644 public/robots.txt create mode 100644 remix.config.js create mode 100644 remix.env.d.ts create mode 100644 styles/global.css create mode 100644 styles/global.server.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59e2f50 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +/.cache +/.env +/build +/public/build +/node_modules + +/.env.example +/docker-compose.yml +/README.md +/.dockerignore +/.gitignore +/Dockerfile +/fly.toml \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57efad8 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Used to encrypt cookies +ENCRYPTION_KEY="rofl1234" + +# Used for swr request-response cache by providers +REDIS_URL="redis://:remixrocks@localhost:6379" + +# Credentials for the shopify provider +SHOPIFY_STORE="next-js-store" +SHOPIFY_STOREFRONT_ACCESS_TOKEN="ef7d41c7bf7e1c214074d0d3047bcd7b" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3c6f1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules + +/.cache +/build +/public/build +/app/styles/global.css +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6269c3b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +FROM node:17-bullseye-slim as base + +# install open ssl for prisma +# RUN apt-get update && apt-get install -y openssl + +ENV NODE_ENV=production +ENV PORT=8080 + +# install all node_modules, including dev +FROM base as deps + +RUN mkdir /app/ +WORKDIR /app/ + +# ADD prisma . +ADD patches . +ADD package.json package-lock.json ./ +RUN npm install --production=false + +# install only production modules +FROM deps as production-deps + +WORKDIR /app/ + +RUN npm prune --production=true + +## build the app +FROM deps as build + +WORKDIR /app/ + +ADD . . +RUN npm run build + +## copy over assets required to run the app +FROM base + +RUN mkdir /app/ +WORKDIR /app/ + +# ADD prisma . + +COPY --from=production-deps /app/node_modules /app/node_modules +COPY --from=production-deps /app/package.json /app/package.json +COPY --from=production-deps /app/package-lock.json /app/package-lock.json +COPY --from=build /app/build /app/build +COPY --from=build /app/public /app/public + +EXPOSE 8080 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9659e78 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `build/` +- `public/build/` + +### Using a Template + +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. + +```sh +cd .. +# create a new project, and pick a pre-configured host +npx create-remix@latest +cd my-new-remix-app +# remove the new project's app (not the old one!) +rm -rf app +# copy your app over +cp -R ../my-old-remix-app/app app +``` diff --git a/app/commerce.server.ts b/app/commerce.server.ts new file mode 100644 index 0000000..ac2c6d0 --- /dev/null +++ b/app/commerce.server.ts @@ -0,0 +1,21 @@ +import { createShopifyProvider } from "./models/ecommerce-providers/shopify.server"; +import { createSwrRedisCache } from "./models/request-response-caches/swr-redis-cache.server"; + +import redisClient from "./redis.server"; + +if (!process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN) { + throw new Error( + "SHOPIFY_STOREFRONT_ACCESS_TOKEN environment variable is not set" + ); +} + +let commerceProvider = createShopifyProvider({ + shop: process.env.SHOPIFY_STORE!, + storefrontAccessToken: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN, + maxAgeSeconds: 60, + cache: createSwrRedisCache({ + redisClient, + }), +}); + +export default commerceProvider; diff --git a/app/components/cart-listitem.tsx b/app/components/cart-listitem.tsx new file mode 100644 index 0000000..ef2963e --- /dev/null +++ b/app/components/cart-listitem.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react"; +import { useFetcher } from "remix"; +import cn from "classnames"; + +import { PickTranslations } from "~/translations.server"; + +import { OptimizedImage } from "./optimized-image"; + +import { CloseIcon, MinusIcon, PlusIcon } from "./icons"; + +export function CartListItem({ + formattedOptions, + formattedPrice, + image, + quantity, + title, + variantId, + translations, +}: { + formattedOptions: ReactNode; + formattedPrice: ReactNode; + image: string; + quantity: number; + title: ReactNode; + variantId: string; + translations: PickTranslations< + "Add item" | "Remove from cart" | "Subtract item" | "Quantity: $1" + >; +}) { + let { Form } = useFetcher(); + + return ( +
  • +
    +
    + +
    +
    +

    {title}

    + {formattedOptions ? ( +

    {formattedOptions}

    + ) : null} +
    +

    {formattedPrice}

    +
    +
    +
    + + + +
    +
    + + {translations["Quantity: $1"]?.replace("$1", quantity.toString())} + + {quantity} +
    +
    + + + + +
    +
    + + + + +
    +
    +
  • + ); +} diff --git a/app/components/cart-popover.tsx b/app/components/cart-popover.tsx new file mode 100644 index 0000000..ff76f07 --- /dev/null +++ b/app/components/cart-popover.tsx @@ -0,0 +1,141 @@ +import { Fragment } from "react"; +import { Form } from "remix"; +import { Dialog, Transition } from "@headlessui/react"; +import cn from "classnames"; + +import { CartInfo } from "~/models/ecommerce-provider.server"; +import { PickTranslations } from "~/translations.server"; + +import { CartIcon, CloseIcon } from "./icons"; + +import { CartListItem } from "./cart-listitem"; +import { CheckoutForm } from "./checkout-form"; + +export function CartPopover({ + cart, + cartCount, + open, + onClose, + translations, +}: { + cart?: CartInfo; + cartCount?: number; + open: boolean; + onClose: () => void; + translations: PickTranslations< + | "Cart" + | "Close" + | "Your cart is empty" + | "Quantity: $1" + | "Remove from cart" + | "Subtract item" + | "Add item" + | "Proceed to checkout" + | "Subtotal" + | "Total" + | "Taxes" + | "Shipping" + >; +}) { + return ( + + +
    + + + + + +
    +
    + + + {translations.Close} + + {!!cartCount && ( + + {cartCount} + + )} + +
    +
    + {!cart?.items ? ( +
    + + + + + {translations["Your cart is empty"]} + +
    + ) : ( + <> +
    + + {translations.Cart} + +
      + {cart.items.map((item) => ( + + ))} +
    +
    + + + )} +
    +
    +
    +
    +
    +
    + ); +} diff --git a/app/components/checkout-form.tsx b/app/components/checkout-form.tsx new file mode 100644 index 0000000..1432eaa --- /dev/null +++ b/app/components/checkout-form.tsx @@ -0,0 +1,57 @@ +import { Form } from "remix"; +import cn from "classnames"; + +import type { CartInfo } from "~/models/ecommerce-provider.server"; +import { PickTranslations } from "~/translations.server"; + +export function CheckoutForm({ + className, + cart, + translations, +}: { + className: string; + cart: CartInfo; + translations: PickTranslations< + "Subtotal" | "Taxes" | "Shipping" | "Total" | "Proceed to checkout" + >; +}) { + return ( +
    + + + + + + + + + + + + + + + +
    {translations.Subtotal}{cart.formattedSubTotal}
    {translations.Taxes}{cart.formattedTaxes}
    {translations.Shipping}{cart.formattedShipping}
    +
    + + + + + + + +
    {translations.Total} + {cart.formattedTotal} +
    +
    + +
    + ); +} diff --git a/app/components/client-only.tsx b/app/components/client-only.tsx new file mode 100644 index 0000000..1c612d5 --- /dev/null +++ b/app/components/client-only.tsx @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; + +export function ClientOnly({ children }: { children: ReactNode }) { + let [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + return mounted ? <>{children} : null; +} diff --git a/app/components/cta-banner.tsx b/app/components/cta-banner.tsx new file mode 100644 index 0000000..4a26c36 --- /dev/null +++ b/app/components/cta-banner.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; +import { Link } from "remix"; +import type { To } from "react-router-dom"; +import cn from "classnames"; + +export function CtaBanner({ + ctaText, + ctaTo, + description, + headline, + variant = "primary", +}: { + ctaText: ReactNode; + ctaTo: To; + description: ReactNode; + headline: ReactNode; + variant: "primary" | "secondary"; +}) { + return ( +
    +

    {headline}

    +
    +

    {description}

    +

    + + + {ctaText} + + + + + +

    +
    +
    + ); +} diff --git a/app/components/flags.tsx b/app/components/flags.tsx new file mode 100644 index 0000000..1f333bc --- /dev/null +++ b/app/components/flags.tsx @@ -0,0 +1,1683 @@ +// https://flagicons.lipis.dev/ + +export function UnitedStatesFlag({ className }: { className: string }) { + return ( + + + + + + + + + + + ); +} + +export function MexicoFlag({ className }: { className: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/components/footer.tsx b/app/components/footer.tsx new file mode 100644 index 0000000..c993409 --- /dev/null +++ b/app/components/footer.tsx @@ -0,0 +1,158 @@ +import { Fragment } from "react"; +import { Form, Link, useLocation } from "remix"; +import type { To } from "react-router-dom"; +import { Popover, Transition } from "@headlessui/react"; +import cn from "classnames"; + +import { Language } from "~/models/language"; + +import { MexicoFlag, UnitedStatesFlag } from "./flags"; +import { ChevronUp, GithubIcon } from "./icons"; +import { OptimizedImage } from "./optimized-image"; + +export type FooterPage = { + id: string | number; + title: string; + to: To; +}; + +export function Footer({ + lang, + logoHref, + pages, + storeName, +}: { + lang: Language; + logoHref: string; + pages: FooterPage[]; + storeName?: string; +}) { + let location = useLocation(); + + return ( + + ); +} diff --git a/app/components/icons.tsx b/app/components/icons.tsx new file mode 100644 index 0000000..eb3f484 --- /dev/null +++ b/app/components/icons.tsx @@ -0,0 +1,159 @@ +// https://heroicons.com/ + +export function MenuIcon({ className }: { className: string }) { + return ( + + + + ); +} + +export function CloseIcon({ className }: { className: string }) { + return ( + + + + ); +} + +export function CartIcon({ className }: { className: string }) { + return ( + + + + ); +} + +export function WishlistIcon({ className }: { className: string }) { + return ( + + + + ); +} + +export function GithubIcon({ className }: { className: string }) { + return ( + + + + ); +} + +export function ChevronDown({ className }: { className: string }) { + return ( + + + + ); +} + +export function ChevronUp({ className }: { className: string }) { + return ( + + + + ); +} + +export function MinusIcon({ className }: { className: string }) { + return ( + + + + ); +} + +export function PlusIcon({ className }: { className: string }) { + return ( + + + + ); +} diff --git a/app/components/language-dialog.tsx b/app/components/language-dialog.tsx new file mode 100644 index 0000000..df6213c --- /dev/null +++ b/app/components/language-dialog.tsx @@ -0,0 +1,120 @@ +import { Fragment, useEffect, useState } from "react"; +import { Form, useLocation } from "remix"; +import { Dialog, Transition } from "@headlessui/react"; + +import { Language, validateLanguage } from "~/models/language"; + +import { PickTranslations } from "~/translations.server"; + +export function LanguageDialog({ + lang, + translations, +}: { + lang: Language; + translations: PickTranslations< + | "Looks like your language doesn't match" + | "Would you like to switch to $1?" + | "Yes" + | "No" + >; +}) { + let location = useLocation(); + let [browserLang, setBrowserLang] = useState(null); + let closeLangModal = () => setBrowserLang(null); + useEffect(() => { + let modalShown = localStorage.getItem("lang-modal-shown"); + let shouldShowModal = location.pathname === "/"; + if (!modalShown && shouldShowModal) { + let browserLang = navigator.language.split("-", 2)[0].toLowerCase(); + if ( + !modalShown && + browserLang !== lang && + validateLanguage(browserLang) + ) { + setBrowserLang(browserLang); + localStorage.setItem("lang-modal-shown", "1"); + } + } + }, [lang, location]); + + return ( + + +
    + + + + + {/* This element is to trick the browser into centering the modal contents. */} + + +
    + + {translations?.["Looks like your language doesn't match"]} + +
    +

    + {translations?.["Would you like to switch to $1?"].replace( + "$1", + browserLang?.toUpperCase() || "" + )} +

    +
    + +
    + + + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx new file mode 100644 index 0000000..3f94502 --- /dev/null +++ b/app/components/navbar.tsx @@ -0,0 +1,207 @@ +import { Fragment, lazy, Suspense } from "react"; +import { Form, Link } from "remix"; +import type { To } from "react-router-dom"; +import { Popover, Transition } from "@headlessui/react"; + +import type { PickTranslations } from "~/translations.server"; +import type { Language } from "~/models/language"; + +import { CartIcon, CloseIcon, MenuIcon, WishlistIcon } from "./icons"; +import { OptimizedImage } from "./optimized-image"; + +export type NavbarCategory = { + name: string; + to: To; +}; + +export function Navbar({ + onOpenCart, + onOpenWishlist, + lang, + logoHref, + storeName, + categories, + translations, + cartCount, +}: { + cartCount?: number; + onOpenCart: () => void; + onOpenWishlist: () => void; + lang: Language; + logoHref: string; + storeName?: string; + categories: NavbarCategory[]; + translations?: PickTranslations< + | "Cart" + | "Close Menu" + | "Home" + | "Open Menu" + | "Search for products..." + | "Wishlist" + >; +}) { + return ( + + ); +} diff --git a/app/components/optimized-image.tsx b/app/components/optimized-image.tsx new file mode 100644 index 0000000..e48d003 --- /dev/null +++ b/app/components/optimized-image.tsx @@ -0,0 +1,56 @@ +import type { ComponentPropsWithoutRef } from "react"; + +export function OptimizedImage({ + optimizerUrl = "/resources/image", + responsive, + src, + ...rest +}: ComponentPropsWithoutRef<"img"> & { + optimizerUrl?: string; + responsive?: { + maxWidth?: number; + size: { width: number; height?: number }; + }[]; +}) { + let url = src ? optimizerUrl + "?src=" + encodeURIComponent(src) : src; + + let props: ComponentPropsWithoutRef<"img"> = { + src: url + `&width=${rest.width || ""}&height=${rest.height || ""}`, + }; + + let largestImageWidth = 0; + let largestImageSrc: string | undefined; + if (responsive && responsive.length) { + let srcSet = ""; + let sizes = ""; + for (let { maxWidth, size } of responsive) { + if (srcSet) { + srcSet += ", "; + } + let srcSetUrl = + url + `&width=${size.width}&height=${size.height || ""} ${size.width}w`; + srcSet += srcSetUrl; + + if (maxWidth) { + if (sizes) { + sizes += ", "; + } + sizes += `(max-width: ${maxWidth}px) ${size.width}px`; + } + + if (size.width > largestImageWidth) { + largestImageWidth = size.width; + largestImageSrc = srcSetUrl; + } + } + props.srcSet = srcSet; + props.sizes = sizes || "100vw"; + props.src = ""; + } + + if (largestImageSrc && (!rest.width || largestImageWidth > rest.width)) { + props.src = largestImageSrc; + } + + return ; +} diff --git a/app/components/product-details.tsx b/app/components/product-details.tsx new file mode 100644 index 0000000..aae5fc5 --- /dev/null +++ b/app/components/product-details.tsx @@ -0,0 +1,195 @@ +import { useRef } from "react"; +import type { MouseEventHandler } from "react"; +import { Form, useLocation, useSearchParams } from "remix"; +import cn from "classnames"; + +import type { FullProduct } from "~/models/ecommerce-provider.server"; +import { OptimizedImage } from "./optimized-image"; +import { PickTranslations } from "~/translations.server"; + +export function ProductDetails({ + product, + translations, +}: { + product: FullProduct; + translations: PickTranslations<"Add to cart" | "Sold out">; +}) { + let location = useLocation(); + let [searchParams] = useSearchParams(); + searchParams.sort(); + + let disabled = !product.selectedVariantId || !product.availableForSale; + + return ( +
    +
    + +
    +
    +

    {product.title}

    +

    {product.formattedPrice}

    + {product.descriptionHtml ? ( +
    + ) : product.description ? ( +

    {product.description}

    + ) : null} + {product.options && product.options.length > 0 ? ( +
    + {Array.from(searchParams.entries()).map(([key, value]) => ( + + ))} + {product.options.map((option) => ( +
    +

    {option.name}

    +
      + {option.values.map((value) => ( +
    • + +
    • + ))} +
    +
    + ))} + {!!product.selectedVariantId && !product.availableForSale ? ( +

    + {translations["Sold out"]} +

    + ) : null} +
    + ) : null} +
    + + +
    +
    +
    +
    +
    + ); +} + +function ImageSlider({ images }: { images: string[] }) { + let sliderListRef = useRef(null); + let scrollToImage: MouseEventHandler = (event) => { + let src = event.currentTarget + .querySelector("img") + ?.getAttribute("data-source"); + if (!src) return; + let img = sliderListRef.current?.querySelector( + `img[data-source=${JSON.stringify(src)}]` + ); + img?.scrollIntoView({ behavior: "smooth" }); + }; + + return ( +
    +
    +
    +
      + {images.map((image, index) => ( +
    • + +
    • + ))} +
    +
    +
      + {images.map((image, index) => ( +
    • + +
    • + ))} +
    +
    +
    + ); +} diff --git a/app/components/scrolling-product-list.tsx b/app/components/scrolling-product-list.tsx new file mode 100644 index 0000000..7e62d47 --- /dev/null +++ b/app/components/scrolling-product-list.tsx @@ -0,0 +1,108 @@ +import { useMemo } from "react"; +import type { ReactNode } from "react"; +import type { To } from "react-router-dom"; +import { Link } from "remix"; +import cn from "classnames"; + +import { OptimizedImage } from "./optimized-image"; + +export type ScrollingProductListProduct = { + id: string | number; + title: ReactNode; + image: string; + to: To; +}; + +function ScrollingProductItem({ + title, + image, + to, + disabled, +}: { + title: ReactNode; + image: string; + to: To; + disabled?: boolean; +}) { + return ( +
  • + + +
    +

    + {title} +

    +
    + +
  • + ); +} + +export function ScrollingProductList({ + variant = "primary", + products, +}: { + variant?: "primary" | "secondary"; + products: ScrollingProductListProduct[]; +}) { + let items = useMemo( + () => + products + .slice(0, 3) + .map((product) => ( + + )), + [products] + ); + + let itemsDisabled = useMemo( + () => + products + .slice(0, 3) + .map((product) => ( + + )), + [products] + ); + + return ( +
    +
    +
      {items}
    +
      + {itemsDisabled} +
    +
    +
    + ); +} diff --git a/app/components/search-container.ts b/app/components/search-container.ts new file mode 100644 index 0000000..65fb2c6 --- /dev/null +++ b/app/components/search-container.ts @@ -0,0 +1,3 @@ +export function SearchContainer() { + return null; +} diff --git a/app/components/three-product-grid.tsx b/app/components/three-product-grid.tsx new file mode 100644 index 0000000..13fd506 --- /dev/null +++ b/app/components/three-product-grid.tsx @@ -0,0 +1,184 @@ +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { Link } from "remix"; +import type { To } from "react-router-dom"; +import cn from "classnames"; +import { useId } from "@reach/auto-id"; + +import { WishlistIcon } from "./icons"; +import { OptimizedImage } from "./optimized-image"; + +export type ThreeProductGridProduct = { + id: string | number; + title: ReactNode; + formattedPrice: ReactNode; + favorited: boolean; + image: string; + to: To; +}; + +function ThreeProductGridItem({ + backgroundColor, + wishlistColors, + product, + index, +}: { + backgroundColor: string; + wishlistColors: string[]; + product: ThreeProductGridProduct; + index: number; +}) { + let id = `three-product-grid-item-${useId()}`; + + return ( +
  • +
    + + + +
    +
    + +

    + {product.title} +

    +
    +

    + {product.formattedPrice} +

    + +
    + +
    +
    +
    +
    +
  • + ); +} + +export function ThreeProductGrid({ + products, + variant = "primary", +}: { + products: ThreeProductGridProduct[]; + variant?: "primary" | "secondary"; +}) { + let [backgroundColors, wishlistColors] = useMemo( + () => + [ + ["bg-pink-brand", "bg-yellow-brand", "bg-blue-brand"], + [ + [ + "group-focus:bg-pink-brand", + "group-hover:bg-pink-brand", + "focus:bg-pink-brand", + "hover:bg-pink-brand", + "focus:text-zinc-900", + "hover:text-zinc-900", + ], + [ + "group-focus:bg-yellow-brand", + "group-hover:bg-yellow-brand", + "focus:bg-yellow-brand", + "hover:bg-yellow-brand", + "focus:text-zinc-900", + "hover:text-zinc-900", + ], + [ + "group-focus:bg-blue-brand", + "group-hover:bg-blue-brand", + "focus:bg-blue-brand", + "hover:bg-blue-brand", + "focus:text-zinc-900", + "hover:text-zinc-900", + ], + ], + ].map((colors) => { + if (variant === "primary") return colors; + + return [colors[1], colors[0], colors[2]]; + }) as [string[], string[][]], + [variant] + ); + + return ( +
    +
      + {products.map((product, index) => ( + + ))} +
    +
    + ); +} diff --git a/app/containers/boundaries/generic-catch-boundary.tsx b/app/containers/boundaries/generic-catch-boundary.tsx new file mode 100644 index 0000000..4fb4bab --- /dev/null +++ b/app/containers/boundaries/generic-catch-boundary.tsx @@ -0,0 +1,17 @@ +import { useCatch } from "remix"; + +export function GenericCatchBoundary() { + let caught = useCatch(); + let message = caught.statusText; + if (typeof caught.data === "string") { + message = caught.data; + } + + return ( +
    +
    +

    {message}

    +
    +
    + ); +} diff --git a/app/containers/boundaries/generic-error-boundary.tsx b/app/containers/boundaries/generic-error-boundary.tsx new file mode 100644 index 0000000..3858699 --- /dev/null +++ b/app/containers/boundaries/generic-error-boundary.tsx @@ -0,0 +1,11 @@ +export function GenericErrorBoundary({ error }: { error: Error }) { + console.error(error); + + return ( +
    +
    +

    An unknown error occured.

    +
    +
    + ); +} diff --git a/app/containers/cart/cart.component.tsx b/app/containers/cart/cart.component.tsx new file mode 100644 index 0000000..1b326fb --- /dev/null +++ b/app/containers/cart/cart.component.tsx @@ -0,0 +1,50 @@ +import { useLoaderData } from "remix"; + +import { CartListItem } from "~/components/cart-listitem"; +import { CheckoutForm } from "~/components/checkout-form"; +import { CartIcon } from "~/components/icons"; + +import type { LoaderData } from "./cart.server"; + +export default function Cart() { + let { cart, translations } = useLoaderData(); + + return ( +
    +

    {translations.Cart}

    + {!cart?.items ? ( +
    + + + +

    + {translations["Your cart is empty"]} +

    +
    + ) : ( + <> +
      + {cart.items.map((item) => ( + + ))} +
    + + + + )} +
    + ); +} diff --git a/app/containers/cart/cart.server.ts b/app/containers/cart/cart.server.ts new file mode 100644 index 0000000..7ea6cc1 --- /dev/null +++ b/app/containers/cart/cart.server.ts @@ -0,0 +1,100 @@ +import { json, redirect } from "remix"; +import type { ActionFunction, HeadersFunction, LoaderFunction } from "remix"; + +import commerce from "~/commerce.server"; +import type { CartInfo } from "~/models/ecommerce-provider.server"; +import { updateCartItem, removeCartItem, getSession } from "~/session.server"; +import { getTranslations, PickTranslations } from "~/translations.server"; + +export let headers: HeadersFunction = ({ actionHeaders }) => { + return actionHeaders; +}; + +export let action: ActionFunction = async ({ request, params }) => { + let [body, session] = await Promise.all([ + request.text(), + getSession(request, params), + ]); + + let formData = new URLSearchParams(body); + let action = formData.get("_action"); + console.log(Array.from(formData)); + + try { + let cart = await session.getCart(); + + switch (action) { + case "set-quantity": { + let variantId = formData.get("variantId"); + let quantityStr = formData.get("quantity"); + if (!variantId || !quantityStr) { + break; + } + let quantity = Number.parseInt(quantityStr, 10); + cart = updateCartItem(cart, variantId, quantity); + break; + } + case "delete": { + let variantId = formData.get("variantId"); + if (!variantId) { + break; + } + cart = removeCartItem(cart, variantId); + break; + } + } + + await session.setCart(cart); + return json(null, { + headers: { + "Set-Cookie": await session.commitSession(), + }, + }); + } catch (error) { + console.error(error); + } + + return null; +}; + +export type LoaderData = { + cart?: CartInfo; + translations: PickTranslations< + | "Cart" + | "Add item" + | "Remove from cart" + | "Subtract item" + | "Quantity: $1" + | "Your cart is empty" + | "Subtotal" + | "Taxes" + | "Shipping" + | "Total" + | "Proceed to checkout" + >; +}; + +export let loader: LoaderFunction = async ({ request, params }) => { + let session = await getSession(request, params); + let lang = session.getLanguage(); + let cart = await session + .getCart() + .then((cartItems) => commerce.getCartInfo(lang, cartItems)); + + return json({ + cart, + translations: getTranslations(lang, [ + "Cart", + "Add item", + "Remove from cart", + "Subtract item", + "Quantity: $1", + "Your cart is empty", + "Subtotal", + "Taxes", + "Shipping", + "Total", + "Proceed to checkout", + ]), + }); +}; diff --git a/app/containers/cdp/cdp.component.tsx b/app/containers/cdp/cdp.component.tsx new file mode 100644 index 0000000..d56f07c --- /dev/null +++ b/app/containers/cdp/cdp.component.tsx @@ -0,0 +1,202 @@ +import { useRef } from "react"; +import { Form, Link, useLoaderData, useLocation, useSubmit } from "remix"; +import { useId } from "@reach/auto-id"; +import cn from "classnames"; + +import { WishlistIcon } from "~/components/icons"; +import { OptimizedImage } from "~/components/optimized-image"; + +import type { CDPProduct, LoaderData } from "./cdp.server"; + +function ThreeProductGridItem({ product }: { product: CDPProduct }) { + let id = `three-product-grid-item-${useId()}`; + + return ( +
  • +
    + + + +
    +
    + +

    + {product.title} +

    +
    +

    + {product.formattedPrice} +

    + +
    + +
    +
    +
    +
    +
  • + ); +} + +export default function CDP() { + let { category, sort, categories, search, sortByOptions, products } = + useLoaderData(); + let formRef = useRef(null); + let submit = useSubmit(); + let location = useLocation(); + + let submitForm = () => { + submit(formRef.current); + }; + + return ( +
    + +
    + + + +
    + +
    +

    + Showing {products.length} results + {search ? ` for "${search}"` : ""} +

    +
      + {products.map((product, index) => ( + + ))} +
    +
    +
    + ); +} diff --git a/app/containers/cdp/cdp.server.ts b/app/containers/cdp/cdp.server.ts new file mode 100644 index 0000000..3186b47 --- /dev/null +++ b/app/containers/cdp/cdp.server.ts @@ -0,0 +1,60 @@ +import { json } from "remix"; +import type { LoaderFunction } from "remix"; +import type { To } from "react-router-dom"; + +import type { + Category, + SortByOption, +} from "~/models/ecommerce-provider.server"; +import commerce from "~/commerce.server"; +import { getSession } from "~/session.server"; + +export type CDPProduct = { + id: string | number; + title: string; + formattedPrice: string; + favorited: boolean; + image: string; + to: To; +}; + +export type LoaderData = { + category?: string; + sort?: string; + categories: Category[]; + search?: string; + sortByOptions: SortByOption[]; + products: CDPProduct[]; +}; + +export let loader: LoaderFunction = async ({ request, params }) => { + let session = await getSession(request, params); + let lang = session.getLanguage(); + let url = new URL(request.url); + + let category = url.searchParams.get("category") || undefined; + let sort = url.searchParams.get("sort") || undefined; + let search = url.searchParams.get("q") || undefined; + + let [categories, sortByOptions, products] = await Promise.all([ + commerce.getCategories(lang, 250), + commerce.getSortByOptions(lang), + commerce.getProducts(lang, category, sort, search), + ]); + + return json({ + category, + sort, + categories, + search, + sortByOptions, + products: products.map((product) => ({ + favorited: product.favorited, + formattedPrice: product.formattedPrice, + id: product.id, + image: product.image, + title: product.title, + to: `/${lang}/product/${product.slug}`, + })), + }); +}; diff --git a/app/containers/generic-page/generic-page.component.tsx b/app/containers/generic-page/generic-page.component.tsx new file mode 100644 index 0000000..1d0aeb4 --- /dev/null +++ b/app/containers/generic-page/generic-page.component.tsx @@ -0,0 +1,29 @@ +import { useLoaderData } from "remix"; + +import type { LoaderData } from "./generic-page.server"; + +export const meta = ({ data }: { data?: LoaderData }) => { + return data?.page?.title + ? { + title: data.page.title, + description: data.page.summary, + } + : { + title: "Remix Ecommerce", + description: "An example ecommerce site built with Remix.", + }; +}; + +export default function GenericPage() { + let { + page: { body }, + } = useLoaderData(); + return ( +
    +
    +
    + ); +} diff --git a/app/containers/generic-page/generic-page.server.ts b/app/containers/generic-page/generic-page.server.ts new file mode 100644 index 0000000..33b1cdc --- /dev/null +++ b/app/containers/generic-page/generic-page.server.ts @@ -0,0 +1,25 @@ +import { json } from "remix"; +import type { LoaderFunction } from "remix"; + +import commerce from "~/commerce.server"; +import { getSession } from "~/session.server"; +import type { FullPage } from "~/models/ecommerce-provider.server"; + +export type LoaderData = { + page: FullPage; +}; + +export let loader: LoaderFunction = async ({ request, params }) => { + let session = await getSession(request, params); + let lang = session.getLanguage(); + + let page = await commerce.getPage(lang, params["*"]!); + + if (!page) { + throw json("Page not found", { status: 404 }); + } + + return json({ + page, + }); +}; diff --git a/app/containers/home/home.component.tsx b/app/containers/home/home.component.tsx new file mode 100644 index 0000000..5e4ac02 --- /dev/null +++ b/app/containers/home/home.component.tsx @@ -0,0 +1,63 @@ +import { useMemo } from "react"; +import { useLoaderData } from "remix"; + +import { CtaBanner } from "~/components/cta-banner"; +import { ThreeProductGrid } from "~/components/three-product-grid"; +import { ScrollingProductList } from "~/components/scrolling-product-list"; + +import type { LoaderData } from "./home.server"; + +function chunkProducts(start: number, goal: number, products: T[]) { + let slice = products.slice(start, start + 3); + + if (products.length < goal) return slice; + for (let i = start + 3; slice.length < goal; i++) { + slice.push(products[i % products.length]); + } + + return slice; +} + +export default function IndexPage() { + let { featuredProducts, translations } = useLoaderData(); + + return ( +
    + chunkProducts(0, 3, featuredProducts), + [featuredProducts] + )} + /> + chunkProducts(3, 3, featuredProducts), + [featuredProducts] + )} + /> + + chunkProducts(6, 3, featuredProducts), + [featuredProducts] + )} + /> + chunkProducts(9, 3, featuredProducts), + [featuredProducts] + )} + /> +
    + ); +} diff --git a/app/containers/home/home.server.ts b/app/containers/home/home.server.ts new file mode 100644 index 0000000..1012146 --- /dev/null +++ b/app/containers/home/home.server.ts @@ -0,0 +1,40 @@ +import type { LoaderFunction } from "remix"; +import { json } from "remix"; + +import commerce from "~/commerce.server"; +import { getTranslations } from "~/translations.server"; +import type { PickTranslations } from "~/translations.server"; +import { getSession } from "~/session.server"; + +import type { ThreeProductGridProduct } from "~/components/three-product-grid"; + +export type LoaderData = { + featuredProducts: ThreeProductGridProduct[]; + translations: PickTranslations< + "MockCTADescription" | "MockCTAHeadline" | "MockCTALink" + >; +}; + +export let loader: LoaderFunction = async ({ request, params }) => { + let session = await getSession(request, params); + let lang = session.getLanguage(); + let featuredProducts = await commerce.getFeaturedProducts(lang); + + return json({ + featuredProducts: featuredProducts.map( + ({ favorited, formattedPrice, id, image, slug, title }) => ({ + favorited, + formattedPrice, + id, + image, + title, + to: `/${lang}/product/${slug}`, + }) + ), + translations: getTranslations(lang, [ + "MockCTADescription", + "MockCTAHeadline", + "MockCTALink", + ]), + }); +}; diff --git a/app/containers/layout/layout.component.tsx b/app/containers/layout/layout.component.tsx new file mode 100644 index 0000000..30842ee --- /dev/null +++ b/app/containers/layout/layout.component.tsx @@ -0,0 +1,181 @@ +import { Suspense, lazy, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { + Links, + LinksFunction, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useMatches, +} from "remix"; +import type { MetaFunction } from "remix"; + +import { ClientOnly } from "~/components/client-only"; +import { Footer } from "~/components/footer"; +import { Navbar, NavbarCategory } from "~/components/navbar"; + +import logoHref from "~/images/remix-glow.svg"; + +import { GenericCatchBoundary } from "../boundaries/generic-catch-boundary"; +import { GenericErrorBoundary } from "../boundaries/generic-error-boundary"; +import type { LoaderData } from "./layout.server"; + +let CartPopover = lazy(() => + import("~/components/cart-popover").then(({ CartPopover }) => ({ + default: CartPopover, + })) +); +let LanguageDialog = lazy(() => + import("~/components/language-dialog").then(({ LanguageDialog }) => ({ + default: LanguageDialog, + })) +); + +export const meta: MetaFunction = () => { + return { + title: "Remix Ecommerce", + description: "An example ecommerce site built with Remix.", + }; +}; + +export let links: LinksFunction = () => { + return [ + { + rel: "stylesheet", + href: + process.env.NODE_ENV === "development" + ? "/global.css" + : require("~/styles/global.css"), + }, + ]; +}; + +export function Document({ + children, + loaderData, +}: { + children: ReactNode; + loaderData?: LoaderData; +}) { + let { cart, categories, lang, pages, translations } = loaderData || { + lang: "en", + pages: [], + }; + + let allCategories = useMemo(() => { + let results: NavbarCategory[] = translations + ? [ + { + name: translations.All, + to: `/${lang}/search`, + }, + ] + : []; + + if (categories) { + results.push(...categories); + } + return results; + }, [categories]); + + let [cartOpen, setCartOpen] = useState(false); + let [wishlistOpen, setWishlistOpen] = useState(false); + + let cartCount = useMemo( + () => cart?.items?.reduce((sum, item) => sum + item.quantity, 0), + [cart] + ); + + return ( + + + + + + + + + setCartOpen(true)} + onOpenWishlist={() => setWishlistOpen(true)} + /> +
    {children}
    +