ProductListing
One module behind every product grid: categories, search, tags, brand pages, and the latest, best-selling and offers lists.
import { ProductListing, productListLoader, ProductListPage, ProductListLoaderData, ProductListLoaderContext, ProductListLoaderParams, ProductListSource } from '@salla.sa/twilight-theme-engine/routes/product-listing';In plain words
A category page, search results and the offers page all show a grid of products with a sort menu. The engine has one module for all of them, and a source says which products to fetch: categories (with an id), search (with a keyword), tags, brands, latest, sales or offers.
The generated routes set the source for you. The grid loads more products as the shopper scrolls. The latest-products, offers and search pages run on Built-in pages.
Signature
const ProductListing: {
readonly id: 'product.index';
readonly loader: typeof productListLoader; // no extend
readonly head: (ctx: TwilightContext, data: ProductListLoaderData) => HeadDescriptor;
readonly Component: typeof ProductListPage;
};
function productListLoader(ctx: ProductListLoaderContext): Promise<ProductListLoaderData>;
function ProductListPage(props: ProductListLoaderData): JSX.Element;
interface ProductListLoaderContext {
params: ProductListLoaderParams;
locale?: string; // not used
search?: { page?: string | number; sort?: string; q?: string };
}
interface ProductListLoaderParams {
id?: string;
source?: ProductListSource;
title?: string; // latest, sales, offers only
slug?: string; // latest, sales, offers only: the breadcrumb URL
keyword?: string; // search, when search.q is absent
}
type ProductListSource = 'categories' | 'latest' | 'sales' | 'offers' | 'search' | 'tags' | 'brands';
interface ProductListLoaderData {
page: ProductListPageMeta; // { title, slug, id?, breadcrumbs? }
source: ProductListSourceConfig; // { type, value?, entity? }
query: ProductListQuery; // { sort, filters? }
products: Product[];
pagination: RoutePagination; // only next is set
filters?: Filter[]; // categories and search
}Try it live
Loading products…
import { createFileRoute } from '@tanstack/react-router';
import { ProductListing } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import type { ProductListLoaderData } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
export const Route = createFileRoute('/{-$locale}/latest-products')({
validateSearch: (search: Record<string, unknown>) => {
const sort = (search.sort as string) || undefined;
return sort ? { sort } : {};
},
loaderDeps: ({ search }) => ({ sort: search.sort }),
loader: ({ deps }): Promise<ProductListLoaderData> =>
ProductListing.loader({
params: {
source: 'latest',
},
search: { sort: deps.sort },
}),
head: withHead(ProductListing),
component: ProductListingComponent,
});
function ProductListingComponent() {
const data: ProductListLoaderData = Route.useLoaderData();
return <ProductListing.Component {...data} />;
}
Example
import { createFileRoute } from '@tanstack/react-router';
import { ProductListing } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import type { ProductListLoaderData } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
export const Route = createFileRoute('/{-$locale}/$slug/c{$id}')({
validateSearch: (search: Record<string, unknown>) => {
const page = Number(search.page) || 1;
const sort = (search.sort as string) || undefined;
return { ...(page > 1 ? { page } : {}), ...(sort ? { sort } : {}) };
},
loaderDeps: ({ search }) => ({ page: search.page, sort: search.sort }),
loader: ({ deps, params }): Promise<ProductListLoaderData> =>
ProductListing.loader({
params: { id: params.id },
search: { page: deps.page, sort: deps.sort },
locale: params.locale,
}),
head: withHead(ProductListing),
component: ProductListingComponent,
});
function ProductListingComponent() {
const data: ProductListLoaderData = Route.useLoaderData();
return <ProductListing.Component {...data} />;
}
How it behaves
Generated routes that use it:
/$slug/c{$id}(categories),/search?q=(search),/tags/$idand/$slug/tag-{$id}(tags),/brands/$id(brands),/latest-products,/most-sales-productsand/offers.The source is
params.source, orcategorieswhen onlyparams.idis given. With neither, the loader throwsProductList loader requires source type; a source it cannot serve (for examplecategorieswithout an id) throwsUnknown source type: ….search.sortdefaults toourSuggest, andsearch.pageis sent to the API as the cursor. The search keyword issearch.q, orparams.keyword; an empty keyword returns an empty list without a request.Categories and brands fetch the entity and the products together; any failure, or a missing entity, throws
notFound(), which shows the not-found page. There is no tag endpoint: a tag's name and URL are read from the tags of the first product in the list, so an empty list, or a first product without that tag, throwsnotFound()too.Requests go through
queryClient.fetchQuery, so a list fetched in the last 60 seconds (the query client'sstaleTime) is served from the cache.query.filtersistruefor categories and search.ProductListPageshows the filters sidebar and drawer only when it is true and the store enables product filters (store.settings.product.filters).ProductListPageis not lazy. It renders the slotsproduct:list.start,product:list.items.start,product:list.items.endandproduct:list.end, a banner on brand pages, and store reviews under category pages when the store enables them. Further products load in the browser frompagination.nextas the shopper scrolls; the address does not change.
Gotchas
The sort menu navigates to the same address with
?sort=. That re-sorts only when the route forwardssortto the loader throughvalidateSearchandloaderDeps, as the generated category, tag, brand and static-list routes do. The generated/searchroute forwards onlyq, so sorting search results changes the address and nothing else.The generated
/latest-products,/most-sales-productsand/offersroutes pass English titles ('Latest Products','Most Sales Products','Offers'), and a passed title wins over the translation (title || i18n.t(...)). An Arabic store shows an English heading. Leavetitleout in a route you write.Brand listings report
page.slugasbrands.index, notbrands.single, and the moduleidisproduct.indexfor every source. Tell the pages apart withsource.type.docs/03-routing-system.md passes sources such as
'product.index.latest'. Those are route ids: the loader throwsUnknown source type. The sources are'latest','sales','offers'and the others listed above.?page=is not a page number here. The loader sendssearch.pageto the API ascursor, and the API's cursors are opaque tokens:/latest-products?page=2asks forcursor=2, which the API refuses (checked on the demo store:ERR_CRYPTO_INVALID_IV, no products), so the page shows an empty list. The<link rel="next">the grid writes for crawlers points at exactly such an address. Do not link to listing pages with?page=.
Related
ProductListing's head function on its own: title, canonical and share tags for any product list, from its category or tag when there is one.
Product listing typesThe pieces of ProductListLoaderData: page metadata, the source and its entity, the sort query and the tag shape, plus an unused category props type.
ProductCardThe product tile of every grid: image, badge, name, price, rating, wishlist heart and a real Add to cart button, in five layouts.
BrandsThe A to Z brands directory: every brand logo, grouped by first letter, each linking to that brand's product list.