Wocart

Basket and quotes

The basket is a shopper's intent. It lives in the browser, never in D1. The server owns the prices.

That gives the storefront one rule to remember: the browser basket is for display and interaction; cart.validate is for money.

The browser basket

TypeScriptCode
import { createBasketStore, localizeHref, storefrontPath } from "@wocart/storefront"; const basket = createBasketStore(); basket.addLine({ productId: product.id, productName: product.name, variantId: variant?.id ?? null, variantLabel: variantLabel(variant), quantity: 1, unitPrice: view.basePrice, // display only href: localizeHref(storefrontPath("product", { handle: product.handle }), locale, routing), ...(cardImageUrl ? { image: cardImageUrl } : {}), });

href is the link the drawer shows for the line: build it with storefrontPath in the page's language, never by hand.

createBasketStore uses localStorage when it is available, a memory adapter in SSR or tests, and fails soft when storage is blocked. A browser that cannot store still has an in-memory basket for the current page; isPersistent() says whether it survives a reload.

The methods are:

MethodResult
getLines()Current lines
getCount()Total quantity
getSubtotal()Optimistic display subtotal
addLine(line)Merges by product + variant
addSelections(line, selections)One offer chosen piece by piece: one line per variant, all or nothing
setQuantity(key, quantity)Capped; <= 0 removes
removeLine(key)Removes one line
clear()Empties the basket
toOrderItems()Server-ready items[]
subscribe(listener)Local and cross-tab changes
isPersistent()Whether storage survives a reload

The limits are MAX_BASKET_LINES (20) and MAX_BASKET_LINE_QUANTITY (100). A line's identity is product + variant, so the same product in two variants has two lines.

unitPrice is only what the drawer shows while it waits for the quote. Do not send it as the price, and do not use getSubtotal() as the order total.

Subscribe once

TypeScriptCode
const unsubscribe = basket.subscribe((lines) => { renderBadge(lines.reduce((total, line) => total + line.quantity, 0)); scheduleQuote(); });

The store notifies on local changes and re-reads storage when another tab changes the basket. Keep the listener cheap; the quote is debounced separately.

Choosing an offer

If the product shows quantity or Buy X Get Y tiers, store the shopper's choice on the line:

TypeScriptCode
basket.addLine({ // … offerId: selectedTier.offerId, });

A later explicit choice replaces it; a plain add keeps the existing choice. The server still applies the offer only when the quantity qualifies.

Building the line from a product

TypeScriptCode
import { getProductView, localizeHref, productImageSource, storefrontPath } from "@wocart/storefront"; const view = getProductView(product); const image = productImageSource(product); basket.addLine({ productId: product.id, productName: product.name, variantId: view.defaultVariant?.id ?? null, variantLabel: view.defaultVariant ? Object.values(view.defaultVariant.variations).join(" / ") : null, quantity, unitPrice: view.basePrice, href: localizeHref(storefrontPath("product", { handle: product.handle }), locale, routing), ...(image ? { image } : {}), ...(offerId ? { offerId } : {}), });

An offer chosen piece by piece

An offer of several pieces can let the shopper pick a variant for each — three pyjamas in two sizes. Add that choice as the order form carries it: one call, one line per variant holding its pieces, every line carrying the offer, and all of it or none — a basket without room for every new line adds nothing.

TypeScriptCode
basket.addSelections( { productId, productName, unitPrice, offerId, href, image }, [ { variantId: "6m", variantLabel: "6 mois" }, { variantId: "12m", variantLabel: "12 mois" }, { variantId: "6m", variantLabel: "6 mois" }, ], );

What an add put in

addedLines(before, after) is what one tap put in the basket: each line that grew, by what it grew. Report that as the Meta AddToCart event — never the whole basket — and read an empty answer as a basket with no room left, which the shopper should be told.

TypeScriptCode
import { addedLines, buildMetaAddToCartPayload } from "@wocart/storefront"; const before = basket.getLines(); const added = addedLines(before, basket.addLine(line)); if (added.length === 0) showBasketFull(); else tracker.track("AddToCart", buildMetaAddToCartPayload(added));

Quote the basket

The quote is a server read. In the browser, ask it through your same-origin route with createQuoter: it posts the lines with what the shopper gave so far and keeps only the latest answer, so a slow answer to an earlier tap never replaces a newer one.

TypeScriptCode
import { createQuoter } from "@wocart/storefront"; const quoter = createQuoter({ endpoint: "/api/cart/validate" }); const result = await quoter.quote({ items: basket.toOrderItems() }, { destination, couponCode, phone }); if (result.status === "fresh") show(result.quote); // "stale": a newer question was asked meanwhile — keep what is shown. // "failed": no usable answer — keep the optimistic basket.

A product's order form asks the same way, with one product as the form posts it ({ productId, productName, quantity, variantSelections, offerId }). The answer is read without Zod (readCartQuote), so no schema reaches a shopper's browser.

On the server, the same call is cart.validate:

TypeScriptCode
const result = await store.cart.validate(items, { destination: { wilayaId: 16, communeId: "c-16-001", deliveryType: "home", }, couponCode: "RAMADAN10", }); if (!result.success) { // The quote is read-only; show the reason or keep the optimistic basket. return; } result.data;

cart.validate is read-only: nothing is written and no stock is reserved. A failure is safe to show while keeping the basket.

For a product order form that has not entered the basket yet, use cart.quote instead. It accepts either a basket or one product as the form posts it:

TypeScriptCode
const result = await store.cart.quote({ productId: product.id, productName: product.name, quantity: 3, variantSelections: [{ variantId: variant.id, variantLabel }], offerId, });

Quote and order use the same line-building rules, so a quote is what the order should cost.

What the quote returns

TypeScriptCode
type CartValidation = { lines: CartValidationLine[]; // CartValidation["lines"][number] subtotal: number; rewards: CartReward[]; freeDelivery: { fromOffer: boolean; threshold: number | null; qualified: boolean; remaining: number; by: "offer" | "coupon" | "threshold" | null; }; delivery?: { available: true; fee: number; total: number } | { available: false; fee: null; total: null }; coupon: CouponOutcome | null; // null when no code was sent };

Lines

Each line carries the catalog's current answer:

TypeScriptCode
type CartValidationLine = { productId: string; variantId: string | null; variantLabel: string | null; productName: string; quantity: number; unitPrice: number; lineTotal: number; // offers and coupon share applied offerId: string | null; offerName: string | null; discount: number; maxQuantity: number | null; blocker: "missing" | "unlisted" | "out_of_stock" | null; };

A blocked line is excluded from the subtotal, offers and free-delivery maths. Show the reason and disable checkout while any line is blocked. Do not remove the line for the shopper unless your UI explicitly offers that action.

maxQuantity is null when the product is not stock-tracked.

Rewards

rewards lists gifts the basket earned. They are not basket lines; render them as a separate “free gift” row.

Free delivery

TypeScriptCode
const free = quote.freeDelivery; if (free.qualified) { // Delivery is free; free.by says why: "offer", "coupon" or "threshold". } else if (free.threshold !== null) { // free.remaining is how much more the subtotal needs. }

The threshold is a store setting; the quote is still the authority, because an offer can make delivery free before the threshold.

Delivery

delivery is present only when the quote was given a destination:

TypeScriptCode
if (quote.delivery?.available) { showDelivery(quote.delivery.fee, quote.delivery.total); } else if (quote.delivery) { showNotDeliverable(); }

When it is absent, do not invent a fee. The default rates in Delivery and geography are a placeholder; the quote is what the order charges.

Coupons

The quote carries the outcome of the code:

TypeScriptCode
type CouponOutcome = | { code: string; applied: true; discount: number; freeShipping: boolean } | { code: string; applied: false; reason: string; shortBy?: number; startsAt?: string };

Show the applied discount and free delivery, or the reason. reason is open; a reason your UI does not recognise should fall back to a general sentence.

A shareable link can carry the code:

TypeScriptCode
import { couponFromUrl } from "@wocart/storefront"; const code = couponFromUrl(new URL(location.href));

The browser can also read a quote value it received without running the schema:

TypeScriptCode
import { readCouponOutcome } from "@wocart/storefront"; const coupon = readCouponOutcome(value);

Use it when a page is cached apart from its scripts and may hold another version's shape. Do not use a browser schema.

A hint or a refusal

Some refusals are about the code — unknown, outside its dates, used up, not for this shopper — and some are about the basket, which does not meet the code yet: a minimum not reached, a product under another offer, a phone still to type. couponRefusalTone(reason) tells them apart, so a theme words a hint gently and a refusal plainly:

TypeScriptCode
import { COUPON_REFUSAL_REASONS, couponRefusalTone } from "@wocart/storefront"; const tone = couponRefusalTone(outcome.reason); // "hint" or "refused"

Type your words against COUPON_REFUSAL_REASONS (a Record over it), so a reason the platform adds fails your type check instead of reaching a shopper as the general sentence.

Cart visibility

config.cartEnabled is the switch:

Code
{config.cartEnabled && <CartDrawer />}

A store with the cart off must ship no cart markup, no cart island and no cart request. The product's direct order form still works; the cart is an additive surface.

A safe drawer loop

  1. Render getLines() immediately, with the optimistic subtotal.
  2. Debounce a quote after a line change.
  3. Replace the display with the server's lines, rewards and totals when it answers.
  4. Keep the optimistic view if the quote fails; checkout will quote again.
  5. Disable checkout while a line is blocked or delivery is unavailable.
  6. Send basket.toOrderItems() with the order, never a constructed price.

A browser total is not an order total

Anyone can edit localStorage and change unitPrice. The drawer may show that number optimistically, but the server re-prices every line and resolves delivery again when the order is created. Never display a client-calculated total as the amount to be charged.