Wocart

Products and collections

The catalogue has four read shapes:

ReadReturnsUse it on
products.list(filters)ProductList[]Grids, rails, search results
products.page(filters){ items, hasNext, total }Paginated catalogue pages
products.search(text, filters){ items, hasNext, total }Search results
products.get(handle)Product | nullA product page
collections.get(handle)StoreCollection | nullA collection's page

ProductList is a compact card shape. Product is the full detail shape with variants, images, offers and quantity pricing.

In the snippets, store is the request's StoreClient: the client the quick start creates, or Astro.locals.store.client behind resolveStoreRequest.

List products

TypeScriptCode
const products = await store.products.list({ collection: "best-sellers", limit: 8, });

The filters are:

FilterWhat it does
categoryIdProducts in that category
collectionProducts in that collection, in the merchant's order
onSaleProducts priced below their compare-at price
limitServer-side cap; the server clamps a larger value
offsetSkip that many products
sortThe order: newest, price-ascending or price-descending (PRODUCT_SORTS); absent, the listing's own

There is no featured filter: the merchant's featured products are the featured collection, collection: "featured".

Use products.page when the page needs a next link:

TypeScriptCode
const page = await store.products.page({ limit: 24, offset }); if (page.hasNext) { // link to ?offset=24 }

page asks the server for one row beyond the page and trims it, so hasNext is exact without a count query. A failed read is an empty final page, the same way list degrades.

An unknown collection or category lists nothing. Do not treat that as an error on a catalogue page; show the empty state.

A page, its size and its order

A listing reads one page at a time, in the order the shopper picked or its own (a collection's, a search's best match, else newest). PRODUCT_SORTS is the list a sort control offers: newest, price-ascending, price-descending.

TypeScriptCode
import { PRODUCT_SORTS } from "@wocart/storefront"; const page = await store.products.page({ collection: handle, limit: 24, offset, sort: "price-ascending" }); page.items; // this page's products page.hasNext; // whether another page follows page.total; // how many the whole listing holds; 0 on a failed read

total is always a number. A failed read is { items: [], hasNext: false, total: 0 }, so "no products" and "the read failed" look the same on the page; the client's onError tells them apart.

products.search(text, filters) finds the products whose name — in the client's language or the catalog's — holds every word, or whose tags do, best match first unless a sort is given. It takes the listing filters and pages like products.page:

TypeScriptCode
const results = await store.products.search(query, { limit: 24, offset, sort }); results.total; // how many products the words find

The words are trimmed and cut to 100 characters. Blank words find nothing without asking the server, never the whole catalog. A failed read is an empty page, like products.page.

Product cards

ProductList carries what a card needs:

TypeScriptCode
type ProductList = { id: string; name: string; handle: string; description: string | null; descriptionFormat: "html" | "text"; descriptionPlain: string | null; currency: string; price: number; compareAtPrice: number | null; type: "PHYSICAL" | "DIGITAL"; hasVariants: boolean; variantOptions: { name: string; values: { value: string; hexColor: string | null }[] }[] | null; sku: string | null; inventory: number; trackInventory: boolean; lowStockThreshold: number; categoryId: string | null; tags: string[]; metaTitle: string | null; metaDescription: string | null; reviewStats: { avgRating: number; reviewCount: number } | null; coverImage: ProductImage | null; };

The list is already filtered to what a shopper may see: it carries no status or visibility field to re-check. variantOptions and tags arrive parsed, as on a product page.

A list has no variant data. If a card needs “from 2,500 DA” for a variable product, ProductList.price is the representative price the server sends; the exact price comes from the product detail.

Image

coverImage is often null: a merchant may upload a gallery without choosing a cover. Use productImageSource, which falls back to the first usable gallery image:

TypeScriptCode
import { mediaImageUrl, productImageSource } from "@wocart/storefront"; const src = productImageSource(product); const cardUrl = src ? (mediaImageUrl(src, { width: 400, height: 400, fit: "cover" }, config.media) ?? src) : null;

Render a placeholder when cardUrl is null. Do not render a broken <img> with an empty src. Resizing and config.media are covered in Money, images, WhatsApp and Meta events.

Product detail

A product page reads by handle, the public URL segment:

Code
--- import { formatMoney, getProductView } from "@wocart/storefront"; import { notFound } from "@wocart/storefront/astro"; const { client, config } = Astro.locals.store; const product = await client.products.get(Astro.params.handle!); if (product === null) return notFound(Astro); const view = getProductView(product); --- <article> <h1>{product.name}</h1> <p>{formatMoney(view.basePrice, { symbol: config.currencySymbol, locale: config.locale })}</p> {view.hasDiscount && <p>Save {view.discountPct}%</p>} {view.isOutOfStock && <p>Out of stock</p>} </article>

getProductView answers the first sellable variant and the product's stock state:

TypeScriptCode
type ProductView = { defaultVariant: ProductVariant | null; basePrice: number; comparePrice: number | null; hasDiscount: boolean; discountPct: number; isOutOfStock: boolean; isLowStock: boolean; };

For a variable product, product.inventory is a product-level summary. Check variant.inventory before letting a shopper order a specific variant.

Variants

A variable product carries:

TypeScriptCode
product.hasVariants; // true product.variantOptions; // [ // { name: "Size", values: [{ value: "M", hexColor: null }, …] }, // { name: "Color", values: [{ value: "Red", hexColor: "#c00" }, …] }, // ] product.variants; // [{ id, variations: { Size: "M", Color: "Red" }, price, compareAtPrice, sku, // inventory, lowStockThreshold, imageId, isDefault }]

variants holds only the variants a shopper can pick, in the merchant's order. Take them as they come: there is no active flag or position to filter or sort on.

The helper functions keep the picker honest:

TypeScriptCode
import { initialVariant, optionStates, pickOption, variantFor, } from "@wocart/storefront"; let selection = initialVariant(product)?.variations ?? {}; const states = optionStates(product, selection); const picked = pickOption(product, selection, "Size", "L"); selection = picked.selection; const variant = variantFor(product.variants, selection);

initialVariant opens on the merchant's default when it can be sold, otherwise the first sellable variant. pickOption always returns a variant that exists, or leaves the selection unchanged. optionStates tells a value apart as:

  • available — a sellable variant has it with the other choices;
  • sold_out — the variant exists but has no stock;
  • unavailable — no variant combines that value with the other choices.

Render unavailable values as disabled, and sold_out as visibly unavailable. The server still validates the submitted variant.

Prices and offers

Prices are amounts in the store's currency (config.currency, DZD for an Algerian store), not cents. Format them with formatMoney and the store's currencySymbol; never divide by 100.

A product can carry three kinds of promotion:

KindFieldMeaning
Compare-at pricecompareAtPriceA crossed-out reference price
Quantity pricingquantityPricing.tiers“2 for 3,500 DA” tiers
Buy X, get YoffersA gift or free delivery

Quantity tiers

TypeScriptCode
import { getQuantityTierViews } from "@wocart/storefront"; const tiers = product.quantityPricing ? getQuantityTierViews(product.quantityPricing, view.basePrice) : [];

product.quantityPricing is null when the product has no quantity tiers.

Each tier gives quantity, price, perUnit, savingsAmount, savePct and freeShipping. These are display values. The total the shopper pays is always the server's cart quote.

Buy X, get Y

TypeScriptCode
import { getOfferTierView } from "@wocart/storefront"; const offer = product.offers[0]; if (offer) { const tile = getOfferTierView(offer, view.basePrice, view.comparePrice); // tile.price, tile.savingsAmount, tile.isFreeShipping }

An offer can be free (a gift) or free_shipping. The server decides whether the quantity qualifies and appends the reward. If the shopper picks a specific tier, put its offerId on the basket line; a shared tier is then priced as the shopper chose.

Do not calculate the promotion

A page may show “Buy 2, get 1 free”. It must not add the gift itself, subtract a discount, or waive delivery. Send the chosen offerId and let cart.validate / cart.quote answer with the priced lines, rewards and total.

Categories

categories.list() returns a flat, ordered list. Build the tree from parentId; the SDK does not return nested children.

TypeScriptCode
const categories = await store.categories.list(); const roots = categories.filter((category) => category.parentId === null); const childrenOf = (id: string) => categories.filter((category) => category.parentId === id);

Category pages read by slug:

Code
--- import { notFound } from "@wocart/storefront/astro"; const category = (await store.categories.list()).find( (item) => item.slug === Astro.params.slug, ); if (!category) return notFound(Astro); const products = await store.products.list({ categoryId: category.id, limit: 24, }); ---

categories.list() is cached for the client's lifetime after a successful read. A category rename is visible on the next server render, not in an old client.

Collections

A collection is a merchant-curated list with its own page:

Code
--- import { notFound } from "@wocart/storefront/astro"; const collection = await store.collections.get(Astro.params.handle!); if (!collection) return notFound(Astro); const page = await store.products.page({ collection: collection.handle, limit: 24 }); --- <h1>{collection.title}</h1> {collection.description && <p>{collection.description}</p>} {collection.image && <img src={collection.image.url} alt="" />}

collections.get returns the handle, the title, the description, how many products a shopper sees in it (productCount) and the photo of its first listed product (image, a ProductImage or null). The products come from products.page({ collection }) in the merchant's order. All of it is translated when the client's language is published.

Descriptions and SEO

product.descriptionFormat says how to render the description:

  • "text" — render it as text, with line breaks preserved.
  • "html" — render it with set:html.

descriptionPlain is the text-only version for meta descriptions and anywhere markup would be wrong. Use it for <meta name="description"> and JSON-LD.

Category and product rows carry metaTitle and metaDescription where present; the store-level fallbacks are config.metaTitle and config.metaDescription.

Titles, structured data and canonical addresses

A product carries the title and description its merchant wrote for search engines (metaTitle, metaDescription). productMeta(product) gives what a page puts in its <title> and description: what the merchant wrote, else the name and the start of the plain description — never markup — within SEO_TEXT_LIMITS. productStructuredData gives the schema.org Product, written with jsonLd, which escapes what could end the script. canonicalUrl keeps only the page's path in its language and its listing page, never a sort, a search, a coupon or an ad's click id:

Code
--- import { canonicalUrl, jsonLd, productMeta, productStructuredData } from "@wocart/storefront"; const store = Astro.locals.store; const meta = productMeta(product); const canonical = canonicalUrl(Astro.url.origin, store, Astro.url.search); --- <title>{meta.title} | {store.config.name}</title> {meta.description && <meta name="description" content={meta.description} />} <link rel="canonical" href={canonical} /> <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url: canonical, storeName: store.config.name }))} />

What this page does not cover

Reviews live on Reviews. The basket and server quote live on Basket and quotes. Images and money helpers are on Money, images, WhatsApp and Meta events. Search is products.search, under "List products" above.