Ecommerce Boilerplate
Features

Cart & Quote Requests

Guest and signed-in carts, cart merging on login, and the quote-based checkout flow.

The default storefront is quote-based: shoppers browse the catalog, add items to a cart, and submit a quote request instead of paying. No payment processor is required, which makes this flow a good fit for B2B, wholesale, made-to-order, and "request a price" catalogs. Submitting a quote drops it straight into the admin quote pipeline and (optionally) fires notification emails.

Stripe checkout is available too, but entirely optional and env-gated — see Payments with Stripe. This page covers the cart and the quote path.

Which button the cart shows. Checkout is the primary action whenever it can complete: Stripe when STRIPE_SECRET_KEY is configured, and a local confirmation in the zero-config demo backend. The quote request is what remains — a deployment with no payment provider, or a cart carrying a native discount code (only the quote path applies those; Stripe has its own promotion codes). The "additional notes" field travels with a quote, so it only appears on that path.

The cart context

Cart state lives in a single React context, CartProvider, consumed through the useCart() hook (src/contexts/cartContext.tsx). It exposes:

MemberSignaturePurpose
itemsInCartProduct[]The current cart lines (each carries a quantity).
addItemToCart(product: Product) => Promise<void>Add one unit; increments the matching line if it already exists.
removeItemFromCart(lineKey: string) => Promise<void>Remove a line by its cart line key.
updateQuantityInCart(lineKey: string, quantity: number) => Promise<void>Set a line's quantity; a quantity <= 0 removes the line.
saveCart(cartItems: Product[]) => Promise<void>Persist the whole cart (used internally and to clear the cart).
Using the cart
import { useCart } from "@/contexts/cartContext";

function AddToCartButton({ product }) {
  const { addItemToCart } = useCart();
  return <button onClick={() => addItemToCart(product)}>Add to cart</button>;
}

useCart() throws "useCart must be used within a CartProvider" if called outside the provider, so mount CartProvider above any component that reads the cart.

Cart line identity

Lines are matched by a cart line key, computed by cartLineKey (src/lib/cart.ts), not by the product id alone:

src/lib/cart.ts
export function cartLineKey(item: { id: string; variantId?: string }): string {
  return item.variantId ? `${item.id}::${item.variantId}` : item.id;
}

A product with no selected variant keeps its plain product id as the key; a line with a variant is keyed productId::variantId. That means two variants of the same product live in the cart as separate lines. This is why removeItemFromCart and updateQuantityInCart take a lineKey rather than a product id. See Inventory & Variants for how variant lines are built (withSelectedVariant).

Guest carts vs. signed-in carts

The cart is dual-backed. Which store is used is decided by whoever useUser() reports as signed in — whichever auth provider answered, which is what lets the cart sync on a Firebase-free deployment. CartProvider waits for that context to resolve before choosing: treating "not known yet" as "signed out" would flash the guest cart at a signed-in shopper, and a save in that window would write to localStorage instead of their account.

For anonymous shoppers the cart is stored in the browser under the localStorage key cart as a JSON array:

localStorage['cart']
[{ "id": "sku-123", "productName": "…", "quantity": 2 }]

Reads and writes go through readGuestCart / writeGuestCart / clearGuestCart (src/lib/cart.ts), the one place that knows the key. None of the three throws: unreadable storage (Safari private mode, a disabled-storage policy) and unparseable contents both read as "no guest cart", because a shopper losing a cart they cannot see beats a blank storefront.

saveCart writes through writeGuestCart and dispatches a storage event so other open tabs update to match. CartProvider listens for that same event and re-reads the cart, keeping tabs in sync. Nothing leaves the browser until the shopper signs in or submits a quote.

The cart operations are part of the backend-agnostic commerce service layer (CartService in src/lib/commerce/types.ts). Under the default Firebase backend they talk to Firestore directly from the browser; with a SQL backend they route through the RPC layer instead. See Backends overview for how the adapter is selected.

carts.listen and carts.mergeCarts are client-only concerns — a guest cart in localStorage and the live subscription only exist in the browser.

Cart merge on login

When a guest signs in, their local cart is not thrown away — it is merged into whatever cart the account already has on the backend. adoptGuestCart in src/contexts/cartContext.tsx runs the sequence:

Read the guest cart with readGuestCart(); an empty one returns immediately.
Load the account's existing backend cart with commerce.carts.get(uid).
Combine them with commerce.carts.mergeCarts(accountItems, guestItems).
Persist the result via commerce.carts.set(uid, merged).
Clear the guest cart with clearGuestCart() — only after the write lands, so a failure leaves the cart where a retry can still find it.

Why this lives in the cart provider. It used to live in src/lib/firebase/authService.ts, spelled out twice, on the Firebase sign-in path alone — so signing in through Auth.js dropped the guest cart on the floor, silently. Hanging it off userId changing instead makes it true for every auth provider, because every provider changes that the same way. It is safe to run on every mount rather than only on the transition: clearing the guest cart makes a second call a no-op, which is a sturdier guarantee than trying to detect the edge, and it repairs a visitor whose merge was interrupted by a closed tab. The merge also completes before the account-cart subscription starts, so the merged lines are what first renders instead of arriving as a surprising second update.

mergeCarts is a pure function (it does not persist anything itself — you pass its result to carts.set). Lines are matched by cartLineKey, and when the same line exists in both carts their quantities are summed; lines only in the guest cart are appended:

src/lib/firebase/dbService.ts (mergeCarts)
const index = mergedCart.findIndex(
  (item) => cartLineKey(item) === cartLineKey(localItem)
);
if (index > -1) {
  mergedCart[index].quantity += localItem.quantity; // sum matching lines
} else {
  mergedCart.push(localItem); // otherwise add the new line
}

Submitting a quote request

The cart page (/cart, rendered by src/components/cart/Cart.tsx) is where a shopper turns their cart into a quote request. There is no payment step. Submission goes through one method regardless of backend:

commerce.quotes.submit
submit(
  userId: string | null,
  quoteData: { items: Quote["items"]; discount?: AppliedDiscount },
  customerInfo: Quote["customer"],
  message: string
): Promise<{ userQuoteId: string; mainQuoteId: string }>
ArgumentNotes
userIdThe signed-in customer's uid, or null for guests — guest quote requests are fully supported.
quoteData{ items, discount? }. items is the cart; an optional discount records the code applied in the cart (see Discounts).
customerInfoContact details collected at checkout (name, email, phone, etc.).
messageFree-form customer message attached to the quote.

It returns userQuoteId — the human-readable quote number, Q- plus a date stamp, a four-digit block, and a short random suffix (Q-20260701-1234K3QP) — and mainQuoteId, the backend record id.

Signed-in vs. guest submission

The cart component branches on auth state:

handleSaveQuoteForLoggedInUser loads the customer profile and, if the name or phone is missing, redirects to /account?tab=profile before submitting. It then calls submit with the real userId, so the quote is also recorded under the customer and shows up in their quote history:

const { userQuoteId } = await commerce.quotes.submit(
  userId!,
  quoteData,
  customerInfoToUse!,
  additionalInfo
);

After a successful submit the cart component calls saveCart([]) to empty the cart and clears any applied discount, then shows a confirmation toast (and, for signed-in users, redirects to /account).

What gets stored

Under the hood submit writes the quote via saveQuote (src/lib/firebase/dbService.ts). Every quote is created with status Pending and each line gets a computed subtotal (quantity * price, defaulting to 0 so price-less catalog items never produce NaN). The quote is written to the top-level quotes collection; for a signed-in user a mirror copy is also written under customers/{uid}/quotes/{quoteNumber} with a back-reference mainQuoteId, which powers the customer's quote history.

A successful submit also emits a quote.submitted lifecycle event on the commerce event bus, so integrations (analytics, webhooks, sync jobs) can react without editing core files.

Notifications

After the quote is stored, the cart component calls notifyQuoteSubmitted, which POSTs the quote to /api/quotes/notify. This drives the transactional emails — a notification to the team that a new quote request came in, and a confirmation to the customer. Emails are best-effort (the call is fire-and-forget and failures are swallowed) and are only attempted when a customer email is present.

Email delivery is optional and env-gated via Resend. If email is not configured, quotes are still stored and appear in the admin pipeline — only the emails are skipped. See Transactional emails for setup and templates.

The admin pipeline

Every submitted quote lands in the admin quotes dashboard, newest first, at status Pending. From there an admin moves it through the pipeline — Pending → Quoted → Accepted / Declined — and can promote an accepted quote into an order (Processing → Shipped → Delivered / Cancelled). See Admin: Quotes and Admin: Orders.

On this page