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
Code
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:
| Method | Result |
|---|---|
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
Code
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:
Code
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
Code
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.
Code
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.
Code
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.
Code
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:
Code
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:
Code
Quote and order use the same line-building rules, so a quote is what the order should cost.
What the quote returns
Code
Lines
Each line carries the catalog's current answer:
Code
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
Code
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:
Code
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:
Code
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:
Code
The browser can also read a quote value it received without running the schema:
Code
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:
Code
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
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
- Render
getLines()immediately, with the optimistic subtotal. - Debounce a quote after a line change.
- Replace the display with the server's lines, rewards and totals when it answers.
- Keep the optimistic view if the quote fails; checkout will quote again.
- Disable checkout while a line is blocked or delivery is unavailable.
- 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.