diff --git a/packages/nextjs/app/about/page.tsx b/packages/nextjs/app/about/page.tsx index 6b0b1666..1a8d81b8 100644 --- a/packages/nextjs/app/about/page.tsx +++ b/packages/nextjs/app/about/page.tsx @@ -1,5 +1,294 @@ -import AboutPage from "app/migration/about"; +import { Metadata } from "next"; + +import Link from "next/link"; +import { BreadIcon } from "../ui/shared-ui"; +import { Breadcrumbs } from "app/components/Breadcrumbs"; +import LocalImage from "app/components/LocalImage"; +import bigPictureImage from "../../../../docs/img/big-picture.png"; +import cacheDiagramImage from "../../../../docs/img/caching-diagram.png"; + +const DIAGRAM_WIDTH = 700.0; +const DIAGRAM_HEIGHT = DIAGRAM_WIDTH / 1.86; +const FASTLY_WIDTH = 700.0; +const FASTLY_HEIGHT = FASTLY_WIDTH / 2.93; + +export const metadata: Metadata = { + title: "About", + description: "About the Formidable Boulangerie project.", +}; export default async function Page() { - return ; + return ( + <> +
+ +
+
+
+

Learn more about the Formidable Ecommerce demo site

+

+ We don’t really sell bread. The goal of the project is to provide a realistic demonstration of running a + highly performant and available e-commerce site with data sourced from Sanity’s headless CMS. The app is + powered by  + + Next.js + + ,  + + Sanity CMS + + , and  + + Fastly + + . +

+
+
+ +
+
+
+

Headless CMS-driven architecture.

+

+ The e-commerce data is stored in a headless CMS (powered by Sanity). The project uses Next.js (deployed on + Vercel) to render the site, and Fastly is placed in front of Vercel to cache server-rendered webpages for + speed and availability. +

+
    +
  • +
    + +
    +
    +

    Sanity CMS

    + Sanity is used for storing information about our e-commerce products. The data from Sanity is fetched + using  + + GROQ + +  – a query language, used for fetching data. Formidable built  + + Groqd + +   – a schema-unaware, runtime and type-safe query builder for GROQ. +
    +
  • +
  • +
    + +
    +
    +

    Sanity Studio

    + Sanity Studio is a web interface for Sanity’s headless CMS. It is used for creating and editing the + data on the site. The models for Sanity are created in code and tracked in source control. Sanity + Studio is integrated into the NextJS application and deployed alongside{" "} + + as a route + + . +
    +
  • +
  • +
    + +
    +
    +

    NextJS app

    + To show the CMS data to end-users we created a Next.js web app that server-renders some common + e-commerce pages, including a landing page, a Product Listing Page (PLP) with sorting and filtering, + and a Product Details Page (PDP). The Next.js app is deployed to{" "} + + Vercel + +  via their git pipeline. In a real-world e-commerce app, we expect to experience some heavy loads + on pages whose data doesn’t change much between visits, and therefore we can deploy caching strategies + to reduce the load on our source server. +
    +
  • +
+
+
+

The caching story.

+
    +
  • +
    + +
    +
    +

    Fastly CDN and Caching

    +

    + In order to enhance the speed of the app, we are utilizing Fastly’s CDN with a high cache-lifetime + for server-rendered pages. We are using Fastly to both cache and host the subdomain used for this + showcase app. The data flow involved in caching is illustrated below: +

    +
    + +
    +
    + To cache our server-rendered pages at the Fastly layer, we use response headers to indicate + what/how we want Fastly to cache our responses from the source server. We need to a couple key + ingredients: +
      +
    • +
      + +
      +
      + Surrogate-Control response header needs to be added to pages where caching is + desired.  + + Learn more. + +
      +
    • +
    • +
      + +
      +
      + Surrogate-Key response header needs to be added to enable appropriate cache + invalidation.  + + Learn more. + +
      +
    • +
    +
    +
    +
    +
    + On the Next.js side we’ll need to include a few primary response headers to then control caching (in + our case, we’re setting these headers from middleware on server-rendered pages that + we’d like to cache). +
      +
    • +
      + +
      +
      + surrogate-control Fastly-specific header used to set the cache policies. +
      +
    • +
    • +
      + +
      +
      + surrogate-key Fastly-specific header that allows purging by key. Note: this + header is removed by Fastly before sending the response to the client. To see the value of + this header, you must include the  + + Fastly-Debug + +  header in your request. +
      +
    • +
    • +
      + +
      +
      + cache-control used to indicate to browsers and Vercel to not cache so that we can + handle caching solely at the Fastly layer. +
      +
    • +
    +
    +

    + With these response headers implemented, Fastly will start caching our responses and give us a path + to invalidate our cache when necessary. In our case, we use data items’ slugs as part + of our surrogate-key  header to indicate what items’ data are used to render a + page so that we can invalidate accordingly when any of those items’ data changes. +

    +
    +
  • +
  • +
    + +
    +
    +

    Cache Invalidation and Purging

    + When CMS data changes, a Sanity webhook is triggered and makes a request to an API endpoint in our + Next.js app. The endpoint does some validation on the request (to make sure it’s coming from a trusted + Sanity webhook), and then makes a request to Fastly’s API to invalidate/purge our cache accordingly. + The Sanity webhook payload contains information (in our case, an item’s   + + slug + +  about what data changes, and our API endpoint uses that slug to tell Fastly which + cache data to invalidate (based on the surrogate-key set in the original response + header). +
    +
  • +
+
+
+
+ + ); } diff --git a/packages/nextjs/app/categories/page.tsx b/packages/nextjs/app/categories/page.tsx index 78b9ab2a..851282cc 100644 --- a/packages/nextjs/app/categories/page.tsx +++ b/packages/nextjs/app/categories/page.tsx @@ -1,4 +1,7 @@ -import CategoriesPage from "app/migration/categories"; +import { Breadcrumbs } from "app/components/Breadcrumbs"; +import { CategoryList } from "app/components/CategoryList"; +import { Metadata } from "next"; +import { WeDontSellBreadBanner } from "../ui/shared-ui"; import { getAllCategories } from "utils/getAllCategoriesQuery"; import { isString, pluralize } from "utils/pluralize"; @@ -12,8 +15,28 @@ const getData = async () => { }; }; +export async function generateMetadata(): Promise { + const data = await getData(); + + return { + title: "Categories", + description: `Product categories, including ${data.categoryNames}.`, + }; +} + export default async function Page() { const data = await getData(); - return ; + return ( +
+ +
+

Categories

+
+ +
+ +
+
+ ); } diff --git a/packages/nextjs/app/components/AnimatedProductDetail.tsx b/packages/nextjs/app/components/AnimatedProductDetail.tsx new file mode 100644 index 00000000..58768e25 --- /dev/null +++ b/packages/nextjs/app/components/AnimatedProductDetail.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { AnimatePresence } from "framer-motion"; +import { useSearchParams } from "next/navigation"; +import React from "react"; +import { FadeInOut } from "shared-ui"; + +const AnimatedProductDetail = ({ children }: React.PropsWithChildren) => { + const query = useSearchParams(); + const variant = query?.get("variant"); + + return ( + + + {children} + + + ); +}; + +export default AnimatedProductDetail; diff --git a/packages/nextjs/components/Breadcrumbs.tsx b/packages/nextjs/app/components/Breadcrumbs.tsx similarity index 99% rename from packages/nextjs/components/Breadcrumbs.tsx rename to packages/nextjs/app/components/Breadcrumbs.tsx index d7f666dd..d8854a41 100644 --- a/packages/nextjs/components/Breadcrumbs.tsx +++ b/packages/nextjs/app/components/Breadcrumbs.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { MdOutlineHome } from "react-icons/md"; import { BreadcrumbItem, BreadcrumbsContainer, capitalizeWords } from "shared-ui"; diff --git a/packages/nextjs/components/Card.tsx b/packages/nextjs/app/components/Card.tsx similarity index 95% rename from packages/nextjs/components/Card.tsx rename to packages/nextjs/app/components/Card.tsx index 6fc27c34..5ca246c4 100644 --- a/packages/nextjs/components/Card.tsx +++ b/packages/nextjs/app/components/Card.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { SanityImageSource } from "@sanity/image-url/lib/types/types"; import Link, { LinkProps } from "next/link"; import { Image } from "./Image"; -import { Card as BaseCard } from "shared-ui"; +import { Card as BaseCard } from "../ui/shared-ui"; export interface CardProps { title: string; diff --git a/packages/nextjs/components/CartContext.tsx b/packages/nextjs/app/components/CartContext.tsx similarity index 100% rename from packages/nextjs/components/CartContext.tsx rename to packages/nextjs/app/components/CartContext.tsx diff --git a/packages/nextjs/components/CategoryList.tsx b/packages/nextjs/app/components/CategoryList.tsx similarity index 96% rename from packages/nextjs/components/CategoryList.tsx rename to packages/nextjs/app/components/CategoryList.tsx index a86c4ad6..eaf717bf 100644 --- a/packages/nextjs/components/CategoryList.tsx +++ b/packages/nextjs/app/components/CategoryList.tsx @@ -1,5 +1,5 @@ import type { GetCategoriesQuery, GetProductsAndCategoriesQuery } from "utils/groqTypes/ProductList"; -import { Card } from "components/Card"; +import { Card } from "app/components/Card"; type CategoryListProps = { items?: GetCategoriesQuery["categories"] | GetProductsAndCategoriesQuery["categories"]; diff --git a/packages/nextjs/components/FeaturedList.tsx b/packages/nextjs/app/components/FeaturedList.tsx similarity index 97% rename from packages/nextjs/components/FeaturedList.tsx rename to packages/nextjs/app/components/FeaturedList.tsx index 7ad8853b..c5d2fd24 100644 --- a/packages/nextjs/components/FeaturedList.tsx +++ b/packages/nextjs/app/components/FeaturedList.tsx @@ -1,7 +1,7 @@ import type { Categories, Products } from "utils/groqTypes/ProductList"; import * as React from "react"; import classNames from "classnames"; -import { Card, CardProps } from "components/Card"; +import { Card, CardProps } from "app/components/Card"; type Props = { items?: Products | Categories; diff --git a/packages/nextjs/components/Header/Header.tsx b/packages/nextjs/app/components/Header/Header.tsx similarity index 97% rename from packages/nextjs/components/Header/Header.tsx rename to packages/nextjs/app/components/Header/Header.tsx index 49a03b71..fc40390e 100644 --- a/packages/nextjs/components/Header/Header.tsx +++ b/packages/nextjs/app/components/Header/Header.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import Link from "next/link"; import { Button, useCart, Header as BaseHeader, useMobileNav } from "shared-ui"; -import { Search } from "components/Search"; +import { Search } from "app/components/Search"; import { usePathname } from "next/navigation"; import { NAV_ITEMS } from "shared-ui"; import { DesktopNavItem } from "shared-ui"; diff --git a/packages/nextjs/components/Image.tsx b/packages/nextjs/app/components/Image.tsx similarity index 98% rename from packages/nextjs/components/Image.tsx rename to packages/nextjs/app/components/Image.tsx index 0fcb1b42..034ab10f 100644 --- a/packages/nextjs/components/Image.tsx +++ b/packages/nextjs/app/components/Image.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { ImageProps, ImageLoaderProps } from "next/image"; import * as React from "react"; import { SanityImageSource } from "@sanity/image-url/lib/types/types"; diff --git a/packages/nextjs/components/ImageCarousel.tsx b/packages/nextjs/app/components/ImageCarousel.tsx similarity index 85% rename from packages/nextjs/components/ImageCarousel.tsx rename to packages/nextjs/app/components/ImageCarousel.tsx index 7b746fa2..b8117e5f 100644 --- a/packages/nextjs/components/ImageCarousel.tsx +++ b/packages/nextjs/app/components/ImageCarousel.tsx @@ -1,7 +1,7 @@ import { ProductImage } from "utils/groqTypes/ProductList"; import * as React from "react"; -import { Image } from "components/Image"; -import { ImageCarousel as BaseImageCarousel } from "shared-ui"; +import { Image } from "app/components/Image"; +import { ImageCarousel as BaseImageCarousel } from "../ui/shared-ui"; export type ImageCarouselProps = { productImages: ProductImage[]; diff --git a/packages/nextjs/app/components/LocalImage.tsx b/packages/nextjs/app/components/LocalImage.tsx new file mode 100644 index 00000000..b8ea602b --- /dev/null +++ b/packages/nextjs/app/components/LocalImage.tsx @@ -0,0 +1,10 @@ +"use client"; + +import Image from "next/image"; +import { localImageLoader } from "utils/localImageLoader"; + +const LocalImage = (props: React.ComponentProps) => { + return {props.alt}; +}; + +export default LocalImage; diff --git a/packages/nextjs/views/ModalFiltersMobile.tsx b/packages/nextjs/app/components/ModalFiltersMobile.tsx similarity index 93% rename from packages/nextjs/views/ModalFiltersMobile.tsx rename to packages/nextjs/app/components/ModalFiltersMobile.tsx index 77a067a8..daebd62b 100644 --- a/packages/nextjs/views/ModalFiltersMobile.tsx +++ b/packages/nextjs/app/components/ModalFiltersMobile.tsx @@ -1,6 +1,6 @@ import { Button, Modal } from "shared-ui"; import React from "react"; -import { ProductFilters } from "components/ProductFilters/ProductFilters"; +import { ProductFilters } from "app/components/ProductFilters/ProductFilters"; import { CategoryFilterItem, FlavourFilterItem, StyleFilterItem } from "utils/groqTypes/ProductList"; interface ModalFiltersMobileProps { diff --git a/packages/nextjs/app/components/ModalFiltersProvider.tsx b/packages/nextjs/app/components/ModalFiltersProvider.tsx new file mode 100644 index 00000000..d5577613 --- /dev/null +++ b/packages/nextjs/app/components/ModalFiltersProvider.tsx @@ -0,0 +1,84 @@ +"use client"; + +import React from "react"; +import { ModalFiltersMobile } from "app/components/ModalFiltersMobile"; +import { useDeviceSize } from "utils/useDeviceSize"; +import { CategoryFilterItem, FlavourFilterItem, StyleFilterItem } from "utils/groqTypes/ProductList"; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +const initialValues = { + handleOpenModal: noop, + handleCloseModal: noop, + isModalOpen: false, +}; + +const ModalFiltersContext = React.createContext(initialValues); + +export const useModalFilters = () => { + const context = React.useContext(ModalFiltersContext); + + if (!context) { + throw new Error("useModalFilters must be used within a ModalFiltersContext.Provider"); + } + + return context; +}; + +type Props = { + categoryFilters: CategoryFilterItem[]; + flavourFilters: FlavourFilterItem[]; + styleFilters: StyleFilterItem[]; +}; + +const ModalFiltersProvider = ({ + children, + categoryFilters, + flavourFilters, + styleFilters, +}: React.PropsWithChildren) => { + const { isSm } = useDeviceSize(); + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleOpenModal = React.useCallback(() => { + setIsModalOpen(true); + }, []); + + const handleCloseModal = React.useCallback(() => { + setIsModalOpen(false); + }, []); + + React.useEffect(() => { + // If modal is open and the window size changes to tablet/desktop viewport, + // then closes the modal + if (!isSm) { + setIsModalOpen(false); + } + }, [isSm]); + + const value = React.useMemo( + () => ({ + handleOpenModal, + handleCloseModal, + isModalOpen, + }), + [handleCloseModal, handleOpenModal, isModalOpen] + ); + + return ( + + {children} + {/* Modal UI for filters (mobile only) */} + + + ); +}; + +export default ModalFiltersProvider; diff --git a/packages/nextjs/components/Pagination.tsx b/packages/nextjs/app/components/Pagination.tsx similarity index 94% rename from packages/nextjs/components/Pagination.tsx rename to packages/nextjs/app/components/Pagination.tsx index b63af415..b8e1d6be 100644 --- a/packages/nextjs/components/Pagination.tsx +++ b/packages/nextjs/app/components/Pagination.tsx @@ -1,5 +1,7 @@ +"use client"; + import * as React from "react"; -import { Pagination as BasePagination } from "shared-ui"; +import { Pagination as BasePagination } from "../ui/shared-ui"; import Link from "next/link"; import classNames from "classnames"; import { usePathname, useSearchParams } from "next/navigation"; diff --git a/packages/nextjs/app/components/PaginationFade.tsx b/packages/nextjs/app/components/PaginationFade.tsx new file mode 100644 index 00000000..c8d7f9e0 --- /dev/null +++ b/packages/nextjs/app/components/PaginationFade.tsx @@ -0,0 +1,35 @@ +"use client"; + +import classNames from "classnames"; +import { FadeInOut } from "../ui/shared-ui"; +import { useSearchParams } from "next/navigation"; +import { pluralize } from "utils/pluralize"; +import { PLPVariant } from "utils/groqTypes/ProductList"; + +type Props = { + variants: PLPVariant[]; +}; + +const PaginationFade = ({ children, variants }: React.PropsWithChildren) => { + const query = useSearchParams(); + const productNames = pluralize(variants.map((prod) => prod.name)); + + return ( + 1 && "grid-rows-2" + )} + key={productNames} + > + {children} + {/* Add padder items when on page > 1 so pagination bar isn't moving around */} + {+(query?.get("page") || 1) > 1 && + Array.from({ length: 6 - variants.length }) + .fill(undefined) + .map((_, i) =>
)} + + ); +}; + +export default PaginationFade; diff --git a/packages/nextjs/components/Product.tsx b/packages/nextjs/app/components/Product.tsx similarity index 95% rename from packages/nextjs/components/Product.tsx rename to packages/nextjs/app/components/Product.tsx index d32514af..f96644d8 100644 --- a/packages/nextjs/components/Product.tsx +++ b/packages/nextjs/app/components/Product.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { Price } from "shared-ui"; +import { Price } from "../ui/shared-ui"; import { PLPVariant } from "utils/groqTypes/ProductList"; import { Image } from "./Image"; diff --git a/packages/nextjs/app/components/ProductDetailBody.tsx b/packages/nextjs/app/components/ProductDetailBody.tsx new file mode 100644 index 00000000..1eca7c0d --- /dev/null +++ b/packages/nextjs/app/components/ProductDetailBody.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; + +import { BlockContent, Price } from "../ui/shared-ui"; + +import { ImageCarousel } from "app/components/ImageCarousel"; +import { StyleOptions } from "app/components/ProductPage/StyleOptions"; +import { ProductVariantSelector } from "app/components/ProductPage/ProductVariantSelector"; +import { ProductDetail, ProductDetailVariants } from "utils/groqTypes/ProductDetail"; +import QuantityInput from "./QuantityInput"; + +export const ProductDetailBody = ({ + variant, + product, +}: { + product?: ProductDetail; + variant?: ProductDetailVariants[number]; +}) => { + return ( +
+
+
+ {variant?.images && } +
+
+

{product?.name}

+ +
+ +
+ {variant?.description ? ( + + ) : null} +
+ + + {variant?.style?.length && ( + +
+ +
+ )} + +
+ +
+
+
+ ); +}; + +export default ProductDetailBody; diff --git a/packages/nextjs/components/ProductFilters/FilterGroup.tsx b/packages/nextjs/app/components/ProductFilters/FilterGroup.tsx similarity index 80% rename from packages/nextjs/components/ProductFilters/FilterGroup.tsx rename to packages/nextjs/app/components/ProductFilters/FilterGroup.tsx index eadbb91b..d031a655 100644 --- a/packages/nextjs/components/ProductFilters/FilterGroup.tsx +++ b/packages/nextjs/app/components/ProductFilters/FilterGroup.tsx @@ -1,7 +1,9 @@ +"use client"; + import type { FilterGroup as FilterGroupType } from "utils/filters"; import * as React from "react"; import { ChangeEvent } from "react"; -import { Checkbox } from "shared-ui"; +import { Checkbox } from "../../ui/shared-ui"; import { useRouterQueryParams } from "utils/useRouterQueryParams"; type FilterGroupProps = { @@ -12,8 +14,7 @@ export const FilterGroup: React.FC = ({ group }) => { const { value: groupValue, label: groupLabel, options } = group; const { query, add, remove } = useRouterQueryParams(); - - const queryValue = query?.get(groupValue); + const queryValue = query?.getAll(groupValue); const handleChange = (e: ChangeEvent) => { const { checked, value: optionValue } = e.target; @@ -30,10 +31,7 @@ export const FilterGroup: React.FC = ({ group }) => { {groupLabel}
    {options.map(({ value: optionValue, label: optionLabel }) => { - const isChecked = - !!queryValue && // Value exists - (queryValue === optionValue || // Single value matches option - queryValue.includes(optionValue)); // Multiple values includes option + const isChecked = !!queryValue && queryValue.includes(optionValue); return (
  • diff --git a/packages/nextjs/components/ProductFilters/ProductFilters.tsx b/packages/nextjs/app/components/ProductFilters/ProductFilters.tsx similarity index 100% rename from packages/nextjs/components/ProductFilters/ProductFilters.tsx rename to packages/nextjs/app/components/ProductFilters/ProductFilters.tsx diff --git a/packages/nextjs/components/ProductPage/ProductVariantSelector.tsx b/packages/nextjs/app/components/ProductPage/ProductVariantSelector.tsx similarity index 74% rename from packages/nextjs/components/ProductPage/ProductVariantSelector.tsx rename to packages/nextjs/app/components/ProductPage/ProductVariantSelector.tsx index ac2273a4..d285c599 100644 --- a/packages/nextjs/components/ProductPage/ProductVariantSelector.tsx +++ b/packages/nextjs/app/components/ProductPage/ProductVariantSelector.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useRouter } from "next/navigation"; import * as React from "react"; import { useMemo } from "react"; import { H6, Select } from "shared-ui"; @@ -6,10 +9,11 @@ import { ProductDetailVariants } from "utils/groqTypes/ProductDetail"; interface Props { variants: ProductDetailVariants; selectedVariant?: ProductDetailVariants[number]; - onVariantChange: (slug?: string) => void; } -export const ProductVariantSelector = ({ variants, selectedVariant, onVariantChange }: Props) => { +export const ProductVariantSelector = ({ variants, selectedVariant }: Props) => { + const { replace } = useRouter(); + const options = useMemo( () => variants?.map((variant) => ({ @@ -19,6 +23,17 @@ export const ProductVariantSelector = ({ variants, selectedVariant, onVariantCha [variants] ); + const setSelectedVariant = React.useCallback( + (slug: string) => { + replace(`${window.location.pathname}?variant=${slug}`); + }, + [replace] + ); + + const onVariantChange = (slug?: string) => { + if (slug) setSelectedVariant(slug); + }; + if (!options?.length) { return null; } diff --git a/packages/nextjs/components/ProductPage/StyleOptions.tsx b/packages/nextjs/app/components/ProductPage/StyleOptions.tsx similarity index 54% rename from packages/nextjs/components/ProductPage/StyleOptions.tsx rename to packages/nextjs/app/components/ProductPage/StyleOptions.tsx index d255eb70..7a501e4d 100644 --- a/packages/nextjs/components/ProductPage/StyleOptions.tsx +++ b/packages/nextjs/app/components/ProductPage/StyleOptions.tsx @@ -1,14 +1,17 @@ +"use client"; + import * as React from "react"; import { H6, Pill } from "shared-ui"; -import { Style } from "utils/groqTypes/ProductDetail"; +import { ProductDetailVariants } from "utils/groqTypes/ProductDetail"; interface Props { - options: Style[]; - selectedStyle?: string; - onChange: (slicing: string) => void; + variant?: ProductDetailVariants[number]; } -export const StyleOptions = ({ options, selectedStyle, onChange }: Props) => { +export const StyleOptions = ({ variant }: Props) => { + const options = variant?.style; + const [selectedStyle, setSelectedStyle] = React.useState(variant?.style?.[0]?.name || ""); + return (
    Style
    @@ -17,7 +20,7 @@ export const StyleOptions = ({ options, selectedStyle, onChange }: Props) => { onChange(option?.name ?? "")} + onClick={() => setSelectedStyle(option?.name ?? "")} > {option?.name} diff --git a/packages/nextjs/components/ProductSort.tsx b/packages/nextjs/app/components/ProductSort.tsx similarity index 92% rename from packages/nextjs/components/ProductSort.tsx rename to packages/nextjs/app/components/ProductSort.tsx index 0960db9f..a595a6ba 100644 --- a/packages/nextjs/components/ProductSort.tsx +++ b/packages/nextjs/app/components/ProductSort.tsx @@ -1,6 +1,8 @@ +"use client"; + import * as React from "react"; import { useRouterQueryParams } from "utils/useRouterQueryParams"; -import { ProductSort as BaseProductSort, ProductSortProps as BaseProps } from "shared-ui"; +import { ProductSort as BaseProductSort, ProductSortProps as BaseProps } from "../ui/shared-ui"; type ProductSortProps = Pick; diff --git a/packages/nextjs/app/components/ProductsList.tsx b/packages/nextjs/app/components/ProductsList.tsx new file mode 100644 index 00000000..8fd1b577 --- /dev/null +++ b/packages/nextjs/app/components/ProductsList.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import { AnimatePresence } from "../ui/framer"; + +import { H6, WeDontSellBreadBanner } from "../ui/shared-ui"; +import { CategoryFilterItem, FlavourFilterItem, PLPVariant, StyleFilterItem } from "utils/groqTypes/ProductList"; + +import { ProductFilters } from "app/components/ProductFilters/ProductFilters"; +import { Product } from "app/components/Product"; +import { Pagination } from "app/components/Pagination"; +import { Breadcrumbs } from "app/components/Breadcrumbs"; +import { SortAndFiltersToolbarMobile } from "app/components/SortAndFiltersToolbarMobile"; +import { ProductSort } from "app/components/ProductSort"; +import PaginationFade from "./PaginationFade"; +import ModalFiltersProvider from "./ModalFiltersProvider"; + +interface ProductListProps { + variants: PLPVariant[]; + itemCount: number; + pageSize: number; + pageCount: number; + currentPage?: number; + categoryFilters: CategoryFilterItem[]; + flavourFilters: FlavourFilterItem[]; + styleFilters: StyleFilterItem[]; +} + +const ProductList = ({ + variants, + pageCount, + currentPage, + categoryFilters, + flavourFilters, + styleFilters, +}: ProductListProps) => { + return ( + +
    + +
    +

    Products

    +
    +
    + + +
    + +
    +
    + +
    + + {/** + * + * Product Sort (select) and product filters (mobile only). + * See Modal component below + * + */} + + + + {variants.length > 0 && ( + + {variants.map((variant, index) => ( + + ))} + + )} + + {variants.length === 0 && ( +
    +
    No products found
    +
    + )} +
    + {variants.length > 0 && } +
    +
    +
    +
    +
    + ); +}; + +export default ProductList; diff --git a/packages/nextjs/app/components/QuantityInput.tsx b/packages/nextjs/app/components/QuantityInput.tsx new file mode 100644 index 00000000..bda40866 --- /dev/null +++ b/packages/nextjs/app/components/QuantityInput.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; +import { QuantityInput as BaseQuantityInput, useCart } from "shared-ui"; +import { ProductDetailVariants } from "utils/groqTypes/ProductDetail"; + +type Props = { + variant?: ProductDetailVariants[number]; +}; + +const QuantityInput = ({ variant }: Props) => { + const [quantity, setQuantity] = useState("1"); + const { updateCart, cartItems } = useCart(); + + const onAddToCart = () => { + if (variant?._id) { + // If the item is already in the cart allow user to click add to cart multiple times + const existingCartItem = cartItems.find((item) => item._id === variant._id); + + updateCart({ + _id: variant._id, + name: variant.name, + price: variant.price, + quantity: existingCartItem ? existingCartItem.quantity + Number(quantity) : Number(quantity), + }); + } + }; + + return ; +}; + +export default QuantityInput; diff --git a/packages/nextjs/components/Search.tsx b/packages/nextjs/app/components/Search.tsx similarity index 100% rename from packages/nextjs/components/Search.tsx rename to packages/nextjs/app/components/Search.tsx diff --git a/packages/nextjs/views/SortAndFiltersToolbarMobile.tsx b/packages/nextjs/app/components/SortAndFiltersToolbarMobile.tsx similarity index 64% rename from packages/nextjs/views/SortAndFiltersToolbarMobile.tsx rename to packages/nextjs/app/components/SortAndFiltersToolbarMobile.tsx index b07a3a0d..db2e93e0 100644 --- a/packages/nextjs/views/SortAndFiltersToolbarMobile.tsx +++ b/packages/nextjs/app/components/SortAndFiltersToolbarMobile.tsx @@ -1,15 +1,15 @@ -import { Button } from "shared-ui"; +"use client"; + +import { Button } from "../ui/shared-ui"; import React from "react"; import { MdOutlineFilterList } from "react-icons/md"; -import { ProductSort } from "components/ProductSort"; +import { ProductSort } from "app/components/ProductSort"; import { useGetFiltersCount } from "utils/getFiltersCount"; +import { useModalFilters } from "app/components/ModalFiltersProvider"; -interface SortAndFiltersToolbarMobileProps { - onFiltersClick?: React.MouseEventHandler; -} - -export const SortAndFiltersToolbarMobile: React.FC = ({ onFiltersClick }) => { +export const SortAndFiltersToolbarMobile: React.FC = () => { const total = useGetFiltersCount(); + const { handleOpenModal } = useModalFilters(); return (
    @@ -20,7 +20,7 @@ export const SortAndFiltersToolbarMobile: React.FC} - onClick={onFiltersClick} + onClick={handleOpenModal} > Filters {total > 0 ? `(${total})` : ""} diff --git a/packages/nextjs/app/migration/components.tsx b/packages/nextjs/app/components/page.tsx similarity index 96% rename from packages/nextjs/app/migration/components.tsx rename to packages/nextjs/app/components/page.tsx index d86ad762..a2bda12d 100644 --- a/packages/nextjs/app/migration/components.tsx +++ b/packages/nextjs/app/components/page.tsx @@ -1,9 +1,7 @@ -"use client"; - import { MdArrowForward } from "react-icons/md"; -import { Button, Input, Pill, Checkbox, Select, LinkText } from "shared-ui"; +import { Button, Input, Pill, Checkbox, Select, LinkText } from "../ui/shared-ui"; -export default function ComponentsPage() { +export default async function Page() { return (

    Buttons

    diff --git a/packages/nextjs/app/layout.tsx b/packages/nextjs/app/layout.tsx index c4112b4c..ef3a36e1 100644 --- a/packages/nextjs/app/layout.tsx +++ b/packages/nextjs/app/layout.tsx @@ -1,14 +1,14 @@ import { Footer, MobileNavProvider } from "./ui/shared-ui"; import "./global.css"; -import { Header } from "components/Header/Header"; +import { Header } from "app/components/Header/Header"; import { Metadata } from "next"; -import { CartProvider } from "components/CartContext"; +import { CartProvider } from "app/components/CartContext"; import { AnimatePresence, MotionConfig } from "./ui/framer"; import localFont from "next/font/local"; export const metadata: Metadata = { title: "Home", - description: "Welcome to Next.js", + description: "Welcome to Formidable Boulangerie", }; export const viewport = { diff --git a/packages/nextjs/app/migration/about.tsx b/packages/nextjs/app/migration/about.tsx deleted file mode 100644 index a3b89afe..00000000 --- a/packages/nextjs/app/migration/about.tsx +++ /dev/null @@ -1,297 +0,0 @@ -"use client"; - -import * as React from "react"; -import { NextPage } from "next"; -import NextImage from "next/image"; - -import Link from "next/link"; -import { BreadIcon } from "shared-ui"; -import { localImageLoader } from "utils/localImageLoader"; -import { PageHead } from "components/PageHead"; -import { Breadcrumbs } from "components/Breadcrumbs"; - -const DIAGRAM_WIDTH = 700.0; -const DIAGRAM_HEIGHT = DIAGRAM_WIDTH / 1.86; -const FASTLY_WIDTH = 700.0; -const FASTLY_HEIGHT = FASTLY_WIDTH / 2.93; - -const AboutPage: NextPage = () => { - return ( - <> - -
    - -
    -
    -
    -

    Learn more about the Formidable Ecommerce demo site

    -

    - We don’t really sell bread. The goal of the project is to provide a realistic demonstration of running a - highly performant and available e-commerce site with data sourced from Sanity’s headless CMS. The app is - powered by  - - Next.js - - ,  - - Sanity CMS - - , and  - - Fastly - - . -

    -
    -
    - -
    -
    -
    -

    Headless CMS-driven architecture.

    -

    - The e-commerce data is stored in a headless CMS (powered by Sanity). The project uses Next.js (deployed on - Vercel) to render the site, and Fastly is placed in front of Vercel to cache server-rendered webpages for - speed and availability. -

    -
      -
    • -
      - -
      -
      -

      Sanity CMS

      - Sanity is used for storing information about our e-commerce products. The data from Sanity is fetched - using  - - GROQ - -  – a query language, used for fetching data. Formidable built  - - Groqd - -   – a schema-unaware, runtime and type-safe query builder for GROQ. -
      -
    • -
    • -
      - -
      -
      -

      Sanity Studio

      - Sanity Studio is a web interface for Sanity’s headless CMS. It is used for creating and editing the - data on the site. The models for Sanity are created in code and tracked in source control. Sanity - Studio is integrated into the NextJS application and deployed alongside{" "} - - as a route - - . -
      -
    • -
    • -
      - -
      -
      -

      NextJS app

      - To show the CMS data to end-users we created a Next.js web app that server-renders some common - e-commerce pages, including a landing page, a Product Listing Page (PLP) with sorting and filtering, - and a Product Details Page (PDP). The Next.js app is deployed to{" "} - - Vercel - -  via their git pipeline. In a real-world e-commerce app, we expect to experience some heavy loads - on pages whose data doesn’t change much between visits, and therefore we can deploy caching strategies - to reduce the load on our source server. -
      -
    • -
    -
    -
    -

    The caching story.

    -
      -
    • -
      - -
      -
      -

      Fastly CDN and Caching

      -

      - In order to enhance the speed of the app, we are utilizing Fastly’s CDN with a high cache-lifetime - for server-rendered pages. We are using Fastly to both cache and host the subdomain used for this - showcase app. The data flow involved in caching is illustrated below: -

      -
      - -
      -
      - To cache our server-rendered pages at the Fastly layer, we use response headers to indicate - what/how we want Fastly to cache our responses from the source server. We need to a couple key - ingredients: -
        -
      • -
        - -
        -
        - Surrogate-Control response header needs to be added to pages where caching is - desired.  - - Learn more. - -
        -
      • -
      • -
        - -
        -
        - Surrogate-Key response header needs to be added to enable appropriate cache - invalidation.  - - Learn more. - -
        -
      • -
      -
      -
      -
      -
      - On the Next.js side we’ll need to include a few primary response headers to then control caching (in - our case, we’re setting these headers from middleware on server-rendered pages that - we’d like to cache). -
        -
      • -
        - -
        -
        - surrogate-control Fastly-specific header used to set the cache policies. -
        -
      • -
      • -
        - -
        -
        - surrogate-key Fastly-specific header that allows purging by key. Note: this - header is removed by Fastly before sending the response to the client. To see the value of - this header, you must include the  - - Fastly-Debug - -  header in your request. -
        -
      • -
      • -
        - -
        -
        - cache-control used to indicate to browsers and Vercel to not cache so that we can - handle caching solely at the Fastly layer. -
        -
      • -
      -
      -

      - With these response headers implemented, Fastly will start caching our responses and give us a path - to invalidate our cache when necessary. In our case, we use data items’ slugs as part - of our surrogate-key  header to indicate what items’ data are used to render a - page so that we can invalidate accordingly when any of those items’ data changes. -

      -
      -
    • -
    • -
      - -
      -
      -

      Cache Invalidation and Purging

      - When CMS data changes, a Sanity webhook is triggered and makes a request to an API endpoint in our - Next.js app. The endpoint does some validation on the request (to make sure it’s coming from a trusted - Sanity webhook), and then makes a request to Fastly’s API to invalidate/purge our cache accordingly. - The Sanity webhook payload contains information (in our case, an item’s   - - slug - -  about what data changes, and our API endpoint uses that slug to tell Fastly which - cache data to invalidate (based on the surrogate-key set in the original response - header). -
      -
    • -
    -
    -
    -
    - - ); -}; - -export default AboutPage; diff --git a/packages/nextjs/app/migration/categories.tsx b/packages/nextjs/app/migration/categories.tsx deleted file mode 100644 index 2694bae5..00000000 --- a/packages/nextjs/app/migration/categories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { NextPage } from "next"; - -import { WeDontSellBreadBanner } from "shared-ui"; - -import { CategoryList } from "components/CategoryList"; -import { PageHead } from "components/PageHead"; -import { Breadcrumbs } from "components/Breadcrumbs"; -import { Category } from "utils/groqTypes/ProductList"; - -interface PageProps { - categories: Category[]; - categoryNames: string; -} - -const CategoriesPage: NextPage = ({ categories, categoryNames }) => { - return ( - <> - -
    - -
    -

    Categories

    -
    - -
    - -
    -
    - - ); -}; - -export default CategoriesPage; diff --git a/packages/nextjs/app/migration/home-page.tsx b/packages/nextjs/app/migration/home-page.tsx deleted file mode 100644 index 30db70d2..00000000 --- a/packages/nextjs/app/migration/home-page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import type { Categories, Products } from "utils/groqTypes/ProductList"; -import * as React from "react"; -import { NextPage } from "next"; -import { FiArrowRight } from "react-icons/fi"; -import Link from "next/link"; -import NextImage from "next/image"; - -import { Button, FeaturedQuote } from "../ui/shared-ui"; -import { localImageLoader } from "utils/localImageLoader"; - -import featuredImg from "assets/featured-story.jpg"; -import { FeaturedList } from "components/FeaturedList"; -import { Image } from "components/Image"; - -interface PageProps { - data?: { - products: Products; - categories: Categories; - }; -} - -const Home: NextPage = ({ data }) => { - return ( - <> -
    -
    -
    -

    Formidable breads for your daily life.

    - -
    - - - {data?.products[0].name - -
    -
    - - Our bestsellers -
    - - -
    - - - - Top categories -
    - -
    - -
    -
    -
    - -
    -
    -
    Stories
    -

    Formidable Baker: Felicity Tai

    - -
    -
    -
    - - ); -}; - -const TitleBanner = ({ children }: React.PropsWithChildren) => ( -
    -

    {children}

    -
    -); - -export default Home; diff --git a/packages/nextjs/app/migration/products/[slug].tsx b/packages/nextjs/app/migration/products/[slug].tsx deleted file mode 100644 index fcaffd0b..00000000 --- a/packages/nextjs/app/migration/products/[slug].tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useState } from "react"; -import { NextPage } from "next"; -import { AnimatePresence } from "framer-motion"; - -import { H6, FadeInOut, BlockContent, Price, QuantityInput, useCart } from "shared-ui"; -import { getRecommendations } from "utils/getRecommendationsQuery"; - -import { ImageCarousel } from "components/ImageCarousel"; -import { PageHead } from "components/PageHead"; -import { StyleOptions } from "components/ProductPage/StyleOptions"; -import { ProductVariantSelector } from "components/ProductPage/ProductVariantSelector"; -import { Product } from "components/Product"; -import { Breadcrumbs } from "components/Breadcrumbs"; -import { useSearchParams, useRouter } from "next/navigation"; -import { ProductDetail, ProductDetailVariants } from "utils/groqTypes/ProductDetail"; - -interface PageProps { - data?: { - product: ProductDetail; - recommendations: Awaited>; - }; -} - -const ProductPage: NextPage = ({ data }) => { - const query = useSearchParams(); - const product = data?.product; - const variant = query?.get("variant"); - - const selectedVariant = - (product?.variants || []).find((v) => v?.slug && v.slug === variant) || product?.variants?.[0]; - - return ( - - -
    - -
    -
    - - - - - - - - -
    - -
    -
    Related Products
    - {data?.recommendations?.slice(0, 3).map((prod) => { - const variant = prod.variants?.[0]; - const image = variant?.images?.[0]; - if (!variant || !image) return null; - - return ( -
    - -
    - ); - })} -
    -
    - - ); -}; - -const PageBody = ({ variant, product }: { product?: ProductDetail; variant?: ProductDetailVariants[number] }) => { - const { replace } = useRouter(); - const { updateCart, cartItems } = useCart(); - - const setSelectedVariant = React.useCallback( - (slug: string) => { - replace(`${window.location.pathname}?variant=${slug}`); - }, - [replace] - ); - - const [selectedStyle, setSelectedStyle] = useState(() => variant?.style?.[0]?.name || ""); - const [quantity, setQuantity] = useState("1"); - - const onVariantChange = (slug?: string) => { - if (slug) setSelectedVariant(slug); - }; - - const onAddToCart = () => { - if (variant?._id) { - // If the item is already in the cart allow user to click add to cart multiple times - const existingCartItem = cartItems.find((item) => item._id === variant._id); - - updateCart({ - _id: variant._id, - name: variant.name, - price: variant.price, - quantity: existingCartItem ? existingCartItem.quantity + Number(quantity) : Number(quantity), - }); - } - }; - - return ( -
    -
    -
    - {variant?.images && ( - - )} -
    -
    -

    {product?.name}

    - -
    - -
    - {variant?.description ? ( - - ) : null} -
    - - - {variant?.style?.length && ( - -
    - -
    - )} - -
    - -
    -
    -
    - ); -}; - -export default ProductPage; diff --git a/packages/nextjs/app/migration/products/index.tsx b/packages/nextjs/app/migration/products/index.tsx deleted file mode 100644 index 33819afc..00000000 --- a/packages/nextjs/app/migration/products/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { NextPage } from "next"; -import * as React from "react"; -import { AnimatePresence } from "framer-motion"; -import classNames from "classnames"; - -import { H6, WeDontSellBreadBanner, FadeInOut } from "shared-ui"; -import { pluralize } from "utils/pluralize"; -import { CategoryFilterItem, FlavourFilterItem, PLPVariant, StyleFilterItem } from "utils/groqTypes/ProductList"; -import { useDeviceSize } from "utils/useDeviceSize"; - -import { PageHead } from "components/PageHead"; -import { ProductSort } from "components/ProductSort"; -import { ProductFilters } from "components/ProductFilters/ProductFilters"; -import { Product } from "components/Product"; -import { Pagination } from "components/Pagination"; -import { Breadcrumbs } from "components/Breadcrumbs"; -import { ModalFiltersMobile } from "views/ModalFiltersMobile"; -import { SortAndFiltersToolbarMobile } from "views/SortAndFiltersToolbarMobile"; -import { useSearchParams } from "next/navigation"; - -interface ProductsPageProps { - variants: PLPVariant[]; - itemCount: number; - pageSize: number; - pageCount: number; - currentPage?: number; - categoryFilters: CategoryFilterItem[]; - flavourFilters: FlavourFilterItem[]; - styleFilters: StyleFilterItem[]; -} - -const ProductsPage: NextPage = ({ - variants, - pageCount, - currentPage, - categoryFilters, - flavourFilters, - styleFilters, -}) => { - const productNames = pluralize(variants.map((prod) => prod.name)); - const query = useSearchParams(); - const [isModalOpen, setIsModalOpen] = React.useState(false); - const { isSm } = useDeviceSize(); - - const handleOpenModal = () => { - setIsModalOpen(true); - }; - - const handleCloseModal = () => { - setIsModalOpen(false); - }; - - React.useEffect(() => { - // If modal is open and the window size changes to tablet/desktop viewport, - // then closes the modal - if (!isSm) { - setIsModalOpen(false); - } - }, [isSm]); - - return ( - <> - -
    - -
    -

    Products

    -
    -
    - - -
    - -
    -
    - -
    - - {/** - * - * Product Sort (select) and product filters (mobile only). - * See Modal component below - * - */} - - - - {variants.length > 0 && ( - 1 && "grid-rows-2" - )} - key={productNames} - > - {variants.map((variant, index) => ( - - ))} - {/* Add padder items when on page > 1 so pagination bar isn't moving around */} - {+(query?.get("page") || 1) > 1 && - Array.from({ length: 6 - variants.length }) - .fill(undefined) - .map((_, i) =>
    )} - - )} - - {variants.length === 0 && ( -
    -
    No products found
    -
    - )} - - {variants.length > 0 && } -
    -
    -
    -
    - - {/* Modal UI for filters (mobile only) */} - - - ); -}; - -export default ProductsPage; diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index 667aed92..6380dd2b 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -1,7 +1,15 @@ import { Metadata } from "next"; -import HomePage from "./migration/home-page"; import { getAllCategories } from "utils/getAllCategoriesQuery"; import { getRecommendations } from "utils/getRecommendationsQuery"; +import { FiArrowRight } from "react-icons/fi"; +import Link from "next/link"; + +import { Button, FeaturedQuote } from "./ui/shared-ui"; + +import featuredImg from "assets/featured-story.jpg"; +import { FeaturedList } from "app/components/FeaturedList"; +import { Image } from "app/components/Image"; +import LocalImage from "app/components/LocalImage"; export const metadata: Metadata = { title: "Home – Formidable Boulangerie", @@ -24,5 +32,69 @@ async function getData() { export default async function Page() { const data = await getData(); - return ; + + return ( + <> +
    +
    +
    +

    Formidable breads for your daily life.

    + +
    + + + {data?.products[0].name + +
    +
    + + Our bestsellers +
    + + +
    + + + + Top categories +
    + +
    + +
    +
    +
    + +
    +
    +
    Stories
    +

    Formidable Baker: Felicity Tai

    + +
    +
    +
    + + ); } + +const TitleBanner = ({ children }: React.PropsWithChildren) => ( +
    +

    {children}

    +
    +); diff --git a/packages/nextjs/app/products/[slug]/page.tsx b/packages/nextjs/app/products/[slug]/page.tsx index 1a0a28ce..8cee0e06 100644 --- a/packages/nextjs/app/products/[slug]/page.tsx +++ b/packages/nextjs/app/products/[slug]/page.tsx @@ -1,4 +1,10 @@ -import ProductsPage from "app/migration/products/[slug]"; +import { Product } from "app/components/Product"; +import ProductsPage from "app/components/ProductDetailBody"; +import { Breadcrumbs } from "app/components/Breadcrumbs"; +import { AnimatePresence } from "../../ui/framer"; +import { Metadata } from "next"; +import React from "react"; +import { H6, FadeInOut } from "../../ui/shared-ui"; import { getProductBySlug } from "utils/getProductBySlug"; import { getRecommendations } from "utils/getRecommendationsQuery"; import { isSlug } from "utils/isSlug"; @@ -13,8 +19,65 @@ const getData = async (slug: string) => { }; }; -export default async function Page({ params }: { params: { slug: string } }) { +type Props = { + params: { slug: string }; + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export async function generateMetadata({ params }: Props): Promise { + const data = await getData(params.slug); + + return { + title: data.product?.name || "Product details", + description: `Product details page for ${data.product?.name}.`, + }; +} + +export default async function Page({ params, searchParams }: Props) { const data = await getData(params.slug); + const product = data?.product; + const variant = searchParams.variant; + + const selectedVariant = + (product?.variants || []).find((v) => v?.slug && v.slug === variant) || product?.variants?.[0]; + + return ( + +
    + +
    +
    + + + + + + + + +
    + +
    +
    Related Products
    + {data?.recommendations?.slice(0, 3).map((prod) => { + const variant = prod.variants?.[0]; + const image = variant?.images?.[0]; + if (!variant || !image) return null; - return ; + return ( +
    + +
    + ); + })} +
    +
    + + ); } diff --git a/packages/nextjs/app/products/page.tsx b/packages/nextjs/app/products/page.tsx index fdb8ceea..214c570b 100644 --- a/packages/nextjs/app/products/page.tsx +++ b/packages/nextjs/app/products/page.tsx @@ -4,7 +4,9 @@ import { getAllFilteredVariants } from "utils/getFilteredPaginatedQuery"; import { getCategoryFilters, getFlavourFilters, getStyleFilters } from "utils/getFilters"; import { getFiltersFromQuery } from "utils/getFiltersFromQuery"; import { getPaginationFromQuery } from "utils/getPaginationFromQuery"; -import ProductsPage from "app/migration/products"; +import { pluralize } from "utils/pluralize"; +import { Metadata } from "next"; +import ProductsList from "app/components/ProductsList"; // See: https://nextjs.org/docs/app/api-reference/file-conventions/page type RouteSearchParams = { [key: string]: string | string[] | undefined }; @@ -46,7 +48,21 @@ const getData = async ({ searchParams }: { searchParams: RouteSearchParams }) => }; }; -export default async function Page({ searchParams }: { searchParams: RouteSearchParams }) { +type Props = { + searchParams: RouteSearchParams; +}; + +export async function generateMetadata({ searchParams }: Props): Promise { + const data = await getData({ searchParams }); + const productNames = pluralize(data.variants.map((prod) => prod.name)); + + return { + title: "Products", + description: `Formidable Boulangerie product listing page, featuring ${productNames}.`, + }; +} + +export default async function Page({ searchParams }: Props) { const data = await getData({ searchParams }); if (data.pageCount > 0 && data.currentPage > data.pageCount) { @@ -54,5 +70,5 @@ export default async function Page({ searchParams }: { searchParams: RouteSearch return redirect(`/products?${newParams.toString()}`); } - return ; + return ; } diff --git a/packages/nextjs/components/Layout.tsx b/packages/nextjs/components/Layout.tsx deleted file mode 100644 index e6b9430a..00000000 --- a/packages/nextjs/components/Layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; -import Head from "next/head"; -import { Footer } from "shared-ui"; -import { Header } from "./Header/Header"; -import { MobileNavProvider } from "shared-ui"; - -export const Layout = ({ children }: React.PropsWithChildren) => { - return ( - <> - - Formidable Boulangerie - -
    - -
    - -
    {children}
    -
    -
    - - ); -}; diff --git a/packages/nextjs/components/PageHead.tsx b/packages/nextjs/components/PageHead.tsx deleted file mode 100644 index 56f1d0df..00000000 --- a/packages/nextjs/components/PageHead.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from "react"; -import Head from "next/head"; - -type PageHeadProps = { title: string; description: string }; - -export const PageHead = ({ title, description: rawDescription }: PageHeadProps) => { - const description = `${rawDescription} By Formidable.`; - return ( - - {`${title} – Formidable Boulangerie`} - - - - - ); -}; diff --git a/packages/nextjs/tailwind.config.js b/packages/nextjs/tailwind.config.js index a9edb51d..1686a5f4 100644 --- a/packages/nextjs/tailwind.config.js +++ b/packages/nextjs/tailwind.config.js @@ -6,7 +6,6 @@ module.exports = { "./app/**/*.{js,ts,jsx,tsx,mdx}", "./pages/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}", - "./views/**/*.{js,jsx,ts,tsx}", "../shared-ui/components/**/*.{js,jsx,ts,tsx}", ], theme: { diff --git a/packages/nextjs/utils/getFiltersCount.ts b/packages/nextjs/utils/getFiltersCount.ts index f1b99aa7..5bd6be62 100644 --- a/packages/nextjs/utils/getFiltersCount.ts +++ b/packages/nextjs/utils/getFiltersCount.ts @@ -5,12 +5,9 @@ import { getFilterGroups } from "./filters"; export const getFiltersCount = (query: ReadonlyURLSearchParams | null) => { const filters = getFilterGroups(); - const total = filters.reduce((acc: number, { label, value }: FilterGroup) => { - const selectedFilters = query?.get(value); - const elements = selectedFilters?.length ?? 0; - - // if a single element is selected, type would be a string instead of an array. - const totalFilters = typeof selectedFilters === "string" ? (elements > 0 ? 1 : 0) : elements; + const total = filters.reduce((acc: number, { value }: FilterGroup) => { + const selectedFilters = query?.getAll(value); + const totalFilters = selectedFilters?.length ?? 0; return (acc = acc + totalFilters); }, 0); diff --git a/packages/nextjs/utils/useRouterQueryParams.ts b/packages/nextjs/utils/useRouterQueryParams.ts index de637e79..feb4f76e 100644 --- a/packages/nextjs/utils/useRouterQueryParams.ts +++ b/packages/nextjs/utils/useRouterQueryParams.ts @@ -1,3 +1,5 @@ +"use client"; + import { usePathname, useRouter, useSearchParams } from "next/navigation"; export const useRouterQueryParams = () => { diff --git a/packages/shared-ui/components/MobileNav/MobileNavContext.tsx b/packages/shared-ui/components/MobileNav/MobileNavContext.tsx index 00db57fd..b44cac1a 100644 --- a/packages/shared-ui/components/MobileNav/MobileNavContext.tsx +++ b/packages/shared-ui/components/MobileNav/MobileNavContext.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; type CartContext = { diff --git a/packages/shared-ui/tailwind.config.js b/packages/shared-ui/tailwind.config.js index 726d25cb..c8af2c73 100644 --- a/packages/shared-ui/tailwind.config.js +++ b/packages/shared-ui/tailwind.config.js @@ -2,7 +2,7 @@ const defaultTheme = require("tailwindcss/defaultTheme"); /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./pages/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}", "./views/**/*.{js,jsx,ts,tsx}"], + content: ["./pages/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"], theme: { extend: { container: {