From Product to Checkout
This guide will walk you through the process of rendering a product lising page, to displaying the product details, and finally adding the product to the cart.
This guide assumes that you have already set up the project using pnpx @frend-digital/cli ecom using the correct URI structure. Which also means 90% of the code in this guide is already written for you. You should still read through this guide to understand the process.
Packages used in this guide
@frend-digital/ui@frend-digital/utils@frend-digital/centra@frend-digital/checkout
Project structure
Make sure your project follows the structure below:
Directorysrc
Directoryapp
Directory[geo]
- _RootLayout.tsx
- layout.tsx
- [[…slug]]
- page.tsx
Directoryfeatures
Directoryproduct
- ProductListingPage.tsx
- ProductPage.tsx
- cart
Directorylib
Directorycentra
- formatters.ts
- server.ts
Initializing Centra
@frend-digital/centra/server provides you with a fully typed server side sdk for interacting with the Centra API. This SDK automatically handles all caching behaviour, and formatting of response. The response returned is ALWAYS a Result object which contains ok, val and err properties to force you to handle errors.
This SDK should be initialized in the src/lib/centra/server.ts file.
import { createCentraAPI } from "@frend-digital/centra/server";import { formatProductCard, formatProductDetails } from "./formatters";import { unstable_cache as cache } from "next/cache";
type Callback = (...args: any[]) => Promise<any>;type CacheFn = <T extends Callback>( fn: T, cacheData: { keyParts: string[]; tags: string[]; revalidate?: number; preview?: boolean; }) => T;
const cacheFunction: CacheFn = (fn, { keyParts, tags, preview }) => { if (preview) { return fn; } return cache(fn, keyParts, { tags });};
export const centra = createCentraAPI({ url: process.env.NEXT_PUBLIC_CENTRA_CHECKOUT!, accessToken: process.env.CENTRA_CHECKOUT_SECRET!, cacheFn: cacheFunction, formatters: { card: formatProductCard, details: formatProductDetails, },});Formatters should be used around Centra responses. The data returned from Centra is enourmous, and we only want to cache what we need.
import { POST } from "@frend-digital/centra-types";import { paths } from "@frend-digital/centra-types/oas";import { getMediaObjects } from "@frend-digital/centra/server";
export type BaseProduct = NonNullable< POST<paths["/products/{product}"]>["product"]> & { // Add custom attributes here};
const relatedFormat = (product: BaseProduct) => { return { name: product.name!, uri: product.uri!, media: getMediaObjects(product, "full"), price: product.price as string | undefined, };};
const baseFormat = (product: BaseProduct, isRelated = false) => { const media = getMediaObjects(product, "full");
// Format related products a bit simpler const relatedProducts = product.relatedProducts ?.filter((p) => p.relation === "variant") .map(relatedFormat) .filter(Boolean);
return { displayItemId: product.product!, name: product.name!, uri: product.uri!, variantName: product.variantName!, price: product.price as string | undefined, category: product.category!, description: product.description, relatedProducts, };};
export const formatProductCard = (product: BaseProduct) => { const base = baseFormat(product);
return { ...base, // Add any other productCard data here };};
export const formatProductDetails = (product: BaseProduct) => { const base = baseFormat(product);
return { ...base, sizes: product.items, // Add any other productDetails data here };};
// Export typesexport type Product = ReturnType<typeof formatProductDetails>;export type ProductCard = ReturnType<typeof formatProductCard>;export type ProductCardType = ReturnType<typeof formatProductCard>;export type RelatedProduct = ReturnType<typeof relatedFormat>;Rendering the product listing page
The first step is to render the product listing page. This page will display a list of products, which can be filtered by category, price, and more.
-
Create the product fetcher
Create an
dal.tsfile infeatures/productand add the following codeimport "server-only"; // Force this to run on server onlyimport { centra } from "@/lib/centra/server";export const fetchProducts = async ({categoryId,}: {categoryId: string;}) => {const { err, val } = await centra.fetchProducts({categories: [categoryId],limit: 10,});if (err) return null; // Todo - handle errorreturn {products: val.products,total: val.productCount,};}; -
Creating the product card
The product card is a reusable component that will display the product name, price, and image. It’s good practise to keep this component fully on the server side, and add client side logic as deep down as possible. Do not mark the whole component
"use client".import Image from "next/image";import Link from "@/i18n/routing";import styles from "./index.module.css";export const ProductCard = ({ product }: { product: Product }) => {const firstImage = product.media.at(0)return (<Link href={`/products/${product.uri}`} className={styles.productCard}>{firstImage && (<div className={styles.imageContainer}><Image src={firstImage.href} fill sizes="25vw" /></div>)}<h2>{product.name}</h2><p>{product.description}</p></Link>);};.productCard {display: flex;flex-direction: column;gap: 1rem;padding: 1rem;border: 1px solid #ccc;}.imageContainer {aspect-ratio: 1/1;width: 100%;height: 0;padding-bottom: 100%;background-color: #ccc;} -
Create the product listing page
In your
ProductListingPage.tsxfile, you’ll receive props from the URI matcher. These props will be used to filter the products.import { notFound } from "next/navigation";import { fetchProducts } from "./actions";import { ProductCard } from "./ProductCard";export const ProductListingPage = async ({ categoryId }: { categoryId: string; }) => {const response = await fetchProducts({ categoryId });if (!response) return notFound();const { products, total } = response;return (<main><h1>Products {total}</h1><section className={styles.productGrid}>{products.map((product) => (<ProductCard key={product.displayItemId} product={product} />))}</section></main>);};.productGrid {display: grid;grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));gap: 1rem;}
Building the product details page
The product details page will display the product details.
export const ProductDetailsPage = async ({ uri }: { uri: string }) => { const { err, val } = await centra.fetchProductByUri(uri); if (err) return notFound();
const product = val.product;
return ( <main> <h1>{product.name}</h1> <p>{product.description}</p> <BuyBox sizes={product.sizes} /> </main> );};import { useBuyBox } from "@frend-digital/centra/client";import { type Product } from "@/lib/centra/formatters";
export const BuyBox = ({ sizes }: { sizes: Product["sizes"] }) => { const { getButtonProps, getButtonLabel, getSizeProps } = useBuyBox({ sizes, labels: { add_to_cart: "Add to cart", coming_soon: "Coming soon", out_of_stock: "Out of stock", preorder: "Preorder", select_size: "Select size", }, });
return ( <section> <div> <strong>Size</strong> <div> {sizes?.map((item) => ( <a className={styles.sizeButton} key={item.sizeId} {...getSizeProps(item)} > {item.name} </a> ))} </div> </div>
<button {...getButtonProps()}> {getButtonLabel()} </button> </div> );};And thats it! The user can now add their products to cart.
Building the cart
-
Add a cart hook
Create an
hooks.tsfile infeatures/cartand add the following codeimport { useSelection } from "@frend-digital/centra/client";export const useCart = () => {return useSelection({select: (data) => {return {total: data.selection.totals.grandTotalPrice,items: data.selection.items,};},});}; -
Add cart summary
Create an
CartSummary.tsxfile infeatures/cartand add the following codeimport { useCart } from "./hooks";export const CartSummary = () => {const { data: cart, isLoading } = useCart();return (<section><h2>Your cart</h2><ul>{!isLoading &&cart.items?.map((item) => (<li key={item.line}><h3>{item.product?.name}</h3><p>{item.quantity}</p></li>))}{isLoading && <> Cart is loading... </>}</ul><footer><p>Total: {cart.total}</p><Link href="/checkout">Go to Checkout</Link></footer></section>);}; -
Add the cart modal
Create an
CartModal.tsxfile infeatures/cartwhich can be opened externally"use client";import {DrawerClose,DrawerContent,DrawerHeader,DrawerRoot,DrawerViewport,} from "@/components/ui/Drawer";import { CartSummary } from "./CartSummary";import { atom, useAtom } from "jotai";import { Slot } from "@frend-digital/ui";export const cartModalAtom = atom(false);export const useCartModal = () => {return useAtom(cartModalAtom);};export const CartModal = () => {const [open, setOpen] = useCartModal();return (<DrawerRoot open={open} onOpenChange={setOpen}><DrawerViewport><DrawerContent><DrawerHeader><h2>Your cart</h2><DrawerClose>╳</DrawerClose></DrawerHeader><CartSummary /></DrawerContent></DrawerViewport></DrawerRoot>);};export const CartModalTrigger = ({children,asChild,...rest}: React.ComponentProps<"button"> & { asChild?: boolean }) => {const [, setOpen] = useCartModal();const Component = asChild ? Slot : "button";return (<Component {...rest} onClick={() => setOpen(true)}>{children}</Component>);};In the
onSuccesscallback of theuseBuyBoxhook, add the following codeconst [, setOpen] = useCartModal();useBuyBox({...previousValues,onSuccess: () => {// Open the cart modal when a product is added to cartsetOpen(true);},});