Acme Store Docs
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.

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 Firebase auth state, watched via onAuthStateChanged inside CartProvider.

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 }]

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

Once a user is authenticated, the cart is synced live to the backend. CartProvider subscribes with commerce.carts.listen(user.uid, callback) — a real-time listener (Firestore onSnapshot under the default adapter) — so the cart updates across devices and tabs in real time. saveCart then writes through commerce.carts.set(uid, cartItems) instead of touching localStorage.

The provider tears down the previous listener before starting a new one on every auth change, so signing out or switching users never leaks a subscription.

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. This happens in the auth flows in src/lib/firebase/authService.ts (both email-link sign-in and Google sign-in):

Read the guest cart from localStorage.getItem("cart").
Load the account's existing backend cart with commerce.carts.get(uid).
Combine them with commerce.carts.mergeCarts(backendItems, localItems).
Persist the result via commerce.carts.set(uid, mergedCart).
Clear the guest cart with localStorage.removeItem("cart") so nothing is duplicated.

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
}

On a brand-new account (no existing customer record), the Google sign-in path skips the merge and simply seeds the account cart from the local cart with carts.set. Either way the localStorage cart is cleared afterward.

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, e.g. Q-20260701-1234 — 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 the profile page 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
);

handleSaveQuoteForNonLoggedInUser first collects contact details in a dialog, then submits with userId set to null:

const { userQuoteId } = await commerce.quotes.submit(
  null,
  quoteData,
  customerInfo,
  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