JSPM

  • Created
  • Published
  • Downloads 400
  • Score
    100M100P100Q100720F
  • License MIT

Official SDK for building e-commerce storefronts with Brainerce Platform. Perfect for vibe-coded sites, AI-built stores (Cursor, Lovable, v0), and custom storefronts.

Package Exports

  • brainerce

Readme

brainerce

Official SDK for building e-commerce storefronts with Brainerce Platform.

This SDK provides a complete solution for vibe-coded sites, AI-built stores (Cursor, Lovable, v0), and custom storefronts to connect to Brainerce's unified commerce API.

AI Agents / Vibe Coders: Use the MCP server for AI-powered store building: npx @brainerce/mcp-server. It provides docs, code templates, and live store capabilities to any AI tool (Cursor, Lovable, Claude Code).

Installation

npm install brainerce
# or
pnpm add brainerce
# or
yarn add brainerce

Quick Reference - Helper Functions

The SDK exports these utility functions for common UI tasks:

Function Purpose Example
formatPrice(amount, { currency?, locale? }) Format prices for display formatPrice("99.99", { currency: 'USD' })$99.99
getPriceDisplay(amount, currency?, locale?) Alias for formatPrice Same as above
getDescriptionContent(product) Get product description (HTML or text) getDescriptionContent(product)
isHtmlDescription(product) Check if description is HTML isHtmlDescription(product)true/false
getStockStatus(inventory) Get human-readable stock status getStockStatus(inventory)"In Stock"
getProductPrice(product) Get effective price (handles sales) getProductPrice(product)29.99
getProductPriceInfo(product) Get price + sale info + discount % { price, isOnSale, discountPercent }
getVariantPrice(variant, basePrice) Get variant price with fallback getVariantPrice(variant, '29.99')34.99
getCartTotals(cart, shippingPrice?) Calculate cart subtotal/discount/total { subtotal, discount, shipping, total }
getCartItemName(item) Get name from nested cart item getCartItemName(item)"Blue T-Shirt"
getCartItemImage(item) Get image URL from cart item getCartItemImage(item)"https://..."
getVariantOptions(variant) Get variant attributes as array [{ name: "Color", value: "Red" }]
isCouponApplicableToProduct(coupon, product) Check if coupon applies isCouponApplicableToProduct(coupon, product)
isAllowedPaymentUrl(url, options?) Validate a payment URL host isAllowedPaymentUrl(intent.clientSecret)true
safePaymentRedirect(url, options?) Validate then window.location.href safePaymentRedirect(intent.clientSecret)
import {
  formatPrice,
  getDescriptionContent,
  getStockStatus,
  getProductPrice,
  getProductPriceInfo,
  getCartTotals,
  getCartItemName,
  getCartItemImage,
} from 'brainerce';

// Format price for display
const priceText = formatPrice(product.basePrice, { currency: 'USD' }); // "$99.99"

// Get product description (handles HTML vs plain text)
const description = getDescriptionContent(product);

// Get stock status text
const stockText = getStockStatus(product.inventory); // "In Stock", "Low Stock", "Out of Stock"

// Get effective price (handles sale prices automatically)
const price = getProductPrice(product); // Returns number: 29.99

// Get full price info including sale status
const priceInfo = getProductPriceInfo(product);
// { price: 19.99, originalPrice: 29.99, isOnSale: true, discountPercent: 33 }

// Calculate cart totals
const totals = getCartTotals(cart, shippingRate?.price);
// { subtotal: 59.98, discount: 10, shipping: 5.99, total: 55.97 }

// Access cart item details (handles nested structure)
const itemName = getCartItemName(cartItem); // "Blue T-Shirt - Large"
const itemImage = getCartItemImage(cartItem); // "https://..."

⚠️ DO NOT CREATE YOUR OWN UTILITY FILES! All helper functions above are exported from brainerce. Never create utils/format.ts, lib/helpers.ts, or similar files - use the SDK exports directly.


⚠️ CRITICAL: Payment Integration Required!

Your store will NOT work without payment integration. The store owner has already configured payment providers (Stripe/PayPal) - you just need to implement the payment page.

// On your checkout/payment page, ALWAYS call this first:
const { hasPayments, providers } = await client.getPaymentProviders();

if (!hasPayments) {
  // Show error - payment is not configured
  return <div>Payment not configured for this store</div>;
}

// Show payment forms for available providers
const stripeProvider = providers.find(p => p.provider === 'stripe');
const paypalProvider = providers.find(p => p.provider === 'paypal');

See the Payment Integration section for complete implementation examples.


Quick Start

import { BrainerceClient } from 'brainerce';

// Initialize with your Connection ID
const client = new BrainerceClient({
  connectionId: 'vc_YOUR_CONNECTION_ID',
});

// Fetch products
const { data: products } = await client.getProducts();

Product customization fields (buyer input)

Products can expose customizationFields — merchant-defined inputs the buyer fills on the product page (engraving text, photo upload, select / multi-select options, date pickers, etc.). Render the form from the array, upload any images via uploadCustomizationFile(), then pass values as metadata on add-to-cart. The server validates and snapshots everything onto the order line. Definitions flagged appliesToAllProducts: true are folded into every product's customizationFields automatically — no client-side merging required.

if (product.customizationFields?.length) {
  // Render a form control per field using field.type (TEXT, SELECT,
  // MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see INTEGRATION.md §2.8
}

// For IMAGE / GALLERY fields: upload first
const { url: photoUrl } = await client.uploadCustomizationFile(file);

await client.addToCart(cart.id, {
  productId: product.id,
  quantity: 1,
  metadata: {
    engraving_text: 'Happy Birthday!',
    frame_color: 'Gold', // SELECT — must be in enumValues
    upload_photo: photoUrl, // IMAGE — URL from uploadCustomizationFile
    addons: ['Gift wrap'], // MULTI_SELECT — always an array
  },
});

Full rendering guide + per-type validation rules: INTEGRATION.md §2.8 and INTEGRATION-RULES.md.

Modifier groups (restaurant / build-your-own products)

Products can expose modifierGroups — merchant-defined option blocks like "Toppings" (max 8, first 3 free) or "Sauce" (pick exactly one). Render radios for selectionType: 'SINGLE' and checkboxes for 'MULTIPLE', honor defaultModifierIds and isDefault on first render, disable modifiers with available: false, and pass selections on add-to-cart. The server is the source of truth for free-allocation and final pricing.

// 5-line add-to-cart with modifiers
await client.addToCart(cart.id, {
  productId: 'prod_pizza',
  quantity: 1,
  selections: [
    { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
    { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon'] },
  ],
});

Money on the wire is always strings (priceDelta: "5.00"). Validation failures arrive as a structured 400 envelope on BrainerceError.details with code: 'MODIFIER_VALIDATION_FAILED' and errors[] — see INTEGRATION-RULES.md "Modifier validation errors" for the full code list.

Full rendering guide: INTEGRATION.md §2.9. Restaurant features (allergens, scheduled availability, nested combos to depth 3, downsell modifiers): INTEGRATION-OPTIONAL.md "Restaurant / build-your-own products".


Common Mistakes to Avoid

AI Agents / Vibe-Coders: Read this section carefully! These are common misunderstandings.

1. Guest Checkout - Use startGuestCheckout() for Guests

For guest users, use startGuestCheckout() which creates a checkout from the session cart:

// ✅ CORRECT - Use startGuestCheckout() for guest users
const result = await client.startGuestCheckout();
if (result.tracked) {
  const checkout = await client.getCheckout(result.checkoutId);
  // Continue with payment flow...
}

// ✅ ALTERNATIVE - Use submitGuestOrder() for simple checkout without payment UI
const order = await client.submitGuestOrder();

Rule of thumb:

  • Guest user + Session cart → startGuestCheckout() or submitGuestOrder()
  • Logged-in user + Server cart → createCheckout({ cartId })

2. ⛔ NEVER Create Local Interfaces - Use SDK Types!

This causes type errors and runtime bugs!

// ❌ WRONG - Don't create your own interfaces!
interface CartItem {
  id: string;
  name: string; // WRONG - it's item.product.name!
  price: number; // WRONG - prices are strings!
}

// ❌ WRONG - Don't use 'as unknown as' casting!
const item = result as unknown as MyLocalType;

// ✅ CORRECT - Import ALL types from SDK
import type {
  Product,
  ProductVariant,
  Cart,
  CartItem,
  Checkout,
  CheckoutLineItem,
  Order,
  OrderItem,
  CustomerProfile,
  CustomerAddress,
  ShippingRate,
  PaymentProvider,
  PaymentIntent,
  PaymentStatus,
  SearchSuggestions,
  ProductSuggestion,
  CategorySuggestion,
  OAuthAuthorizeResponse,
  CustomerOAuthProvider,
} from 'brainerce';

⚠️ SDK Type Facts - Trust These!

What Correct Wrong
Prices string (use parseFloat()) number
Cart item name item.product.name item.name
Order item name item.name item.product.name
Cart item image item.product.images[0] item.image
Order item image item.image item.product.images
Address state/province region state or province
OAuth redirect URL authorizationUrl url
OAuth providers response { providers: [...] } [...] directly

If you think a type is "wrong", YOU are wrong. Read the SDK types!

3. formatPrice Expects Options Object

// ❌ WRONG
formatPrice(amount, 'USD');

// ✅ CORRECT
formatPrice(amount, { currency: 'USD' });

4. Cart/Checkout vs Order - Different Item Structures!

IMPORTANT: Cart and Checkout items have NESTED product data. Order items are FLAT.

// CartItem and CheckoutLineItem - NESTED product
cart.items.forEach((item) => {
  console.log(item.product.name); // ✅ Correct for Cart/Checkout
  console.log(item.product.sku);
  console.log(item.product.images);
});

// OrderItem - FLAT structure
order.items.forEach((item) => {
  console.log(item.name); // ✅ Correct for Orders
  console.log(item.sku);
  console.log(item.image); // singular, not images
});
Type Access Name Access Image
CartItem item.product.name item.product.images
CheckoutLineItem item.product.name item.product.images
OrderItem item.name item.image

5. Payment Status is 'succeeded', not 'completed'

// ❌ WRONG
if (status.status === 'completed')

// ✅ CORRECT
if (status.status === 'succeeded')

6. ProductSuggestion vs Product - Different Types

getSearchSuggestions() returns ProductSuggestion[], NOT Product[]. This is intentional - suggestions are lightweight for autocomplete.

// ProductSuggestion has:
{
  (id, name, slug, image, basePrice, salePrice, type);
}

// Product has many more fields

7. All Prices Are Strings - Use parseFloat()

// ❌ WRONG - assuming number
const total = item.price * quantity;

// ✅ CORRECT - parse first
const total = parseFloat(item.price) * quantity;

// Or use SDK helper
import { formatPrice } from 'brainerce';
const display = formatPrice(item.price, { currency: 'USD' });

8. Variant Attributes Are Record<string, string> (Locale-Aware)

// Accessing variant attributes:
const color = variant.attributes?.['Color']; // string
const size = variant.attributes?.['Size']; // string

// Use getVariantOptions() for display — returns translated names when locale is active:
const options = getVariantOptions(variant);
// With Accept-Language: en → [{ name: "Color", value: "Red" }, { name: "Size", value: "M" }]
// With Accept-Language: he → [{ name: "צבע", value: "אדום" }, { name: "מידה", value: "M" }]

i18n note: When client.setLocale() is active, the backend translates both attribute keys (e.g. "צבע" → "Color") and option values (e.g. "אדום" → "Red") inside variant.attributes. The getVariantOptions() and getProductSwatches() helpers automatically return translated names.

9. Address Uses region, NOT state

// ❌ WRONG
const address = {
  state: 'NY', // This field doesn't exist!
};

// ✅ CORRECT
const address: SetShippingAddressDto = {
  firstName: 'John',
  lastName: 'Doe',
  line1: '123 Main St',
  city: 'New York',
  region: 'NY', // Use 'region' for state/province
  postalCode: '10001',
  country: 'US',
};

10. OAuth - Use authorizationUrl, NOT url

// ❌ WRONG
const response = await client.getOAuthAuthorizeUrl('GOOGLE', { redirectUrl });
window.location.href = response.url; // 'url' doesn't exist!

// ✅ CORRECT
const response = await client.getOAuthAuthorizeUrl('GOOGLE', { redirectUrl });
window.location.href = response.authorizationUrl; // Correct property name

11. OAuth Provider Type is Exported

// ❌ WRONG - creating your own type
type Provider = 'google' | 'facebook'; // lowercase won't work!

// ✅ CORRECT - import from SDK
import { CustomerOAuthProvider } from 'brainerce';
// CustomerOAuthProvider = 'GOOGLE' | 'FACEBOOK' | 'GITHUB'  (UPPERCASE)

const provider: CustomerOAuthProvider = 'GOOGLE';
await client.getOAuthAuthorizeUrl(provider, { redirectUrl });

12. getAvailableOAuthProviders Returns Object, Not Array

// ❌ WRONG - expecting array directly
const providers = await client.getAvailableOAuthProviders();
providers.forEach(p => ...);  // Error! providers is not an array

// ✅ CORRECT - access the providers property
const response = await client.getAvailableOAuthProviders();
response.providers.forEach(p => ...);  // response.providers is the array

13. SDK Uses null, Not undefined

Optional fields in SDK types use null, not undefined:

// SDK types use:
slug: string | null;
salePrice: string | null;

// So when checking:
if (product.slug !== null) {
  // ✅ Check for null
  // ...
}

14. Cart Has No total Field - Use getCartTotals() Helper

// ❌ WRONG - these fields don't exist on Cart
const total = cart.total; // ← 'total' doesn't exist!
const discount = cart.discount; // ← 'discount' doesn't exist! It's 'discountAmount'

// ✅ CORRECT - use the helper function (RECOMMENDED)
import { getCartTotals } from 'brainerce';
const totals = getCartTotals(cart, shippingPrice);
// Returns: { subtotal: 59.98, discount: 10, shipping: 5.99, total: 55.97 }

// ✅ CORRECT - or calculate manually
const subtotal = parseFloat(cart.subtotal);
const discount = parseFloat(cart.discountAmount); // ← Note: 'discountAmount', NOT 'discount'
const total = subtotal - discount;

Important Notes:

  • Cart field is discountAmount, NOT discount
  • Cart has NO total field - use getCartTotals() or calculate
  • Checkout DOES have a total field, but Cart does not
  • getCartTotals() works with all carts — guests now use server-side session carts with full pricing fields.

15. SearchSuggestions - Products Have price, Not basePrice

// In SearchSuggestions, ProductSuggestion has:
// - price: effective price (sale price if on sale, otherwise base price)
// - basePrice: original price
// - salePrice: sale price if on sale

// ✅ Use 'price' for display (it's already the correct price)
suggestions.products.map(p => (
  <div>{p.name} - {formatPrice(p.price, { currency })}</div>
));

16. Forgetting to Clear Cart After Payment

This causes "ghost items" in the cart after successful payment!

// ❌ WRONG - Cart items remain after payment!
// In your success page:
export default function SuccessPage() {
  return <div>Thank you for your order!</div>;
  // User goes back to shop → still sees purchased items in cart!
}

// ✅ CORRECT - Call completeGuestCheckout() on success page
export default function SuccessPage() {
  const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');

  useEffect(() => {
    if (checkoutId) {
      // Send order to server AND clear cart
      client.completeGuestCheckout(checkoutId);
    }
  }, []);

  return <div>Thank you for your order!</div>;
}

Why is this needed?

  • completeGuestCheckout() sends the order to the server AND clears the session cart
  • Without it, the order is never created on the server (payment goes through but no order!)
  • For partial checkout (AliExpress-style), only the purchased items are removed
  • After successful checkout, also call client.onCheckoutComplete() to clear the session cart reference

Checkout: Guest vs Logged-In Customer

Both guests and logged-in customers now use the same smart* cart methods. The SDK handles server-side session carts for guests automatically.

Customer Type Cart Method Checkout
Guest smartAddToCart() (session cart) startGuestCheckout()createCheckout()
Logged In smartAddToCart() (server cart) createCheckout()completeCheckout()

Cart Usage (Same for Both)

// Add to cart — works for both guests and logged-in users
await client.smartAddToCart({ productId: 'prod_123', quantity: 2 });

// Get cart
const cart = await client.smartGetCart();

// Get cart with recommendations, upgrades, and bundles in a single request
const cartWithExtras = await client.smartGetCart({
  include: ['recommendations', 'upgrades', 'bundles'],
});
// cartWithExtras.recommendations, cartWithExtras.upgrades, cartWithExtras.bundles

// Update quantity
await client.smartUpdateCartItem('prod_123', 5);

// Remove item
await client.smartRemoveFromCart('prod_123');

// Get totals (works for all carts)
import { getCartTotals } from 'brainerce';
const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }

On Login — Merge Guest Cart

// After setting customer token
client.setCustomerToken(token);
await client.syncCartOnLogin(); // Merges session cart into customer cart

Guest Checkout

// Guest checkout creates a checkout from the session cart
const result = await client.startGuestCheckout();

if (result.tracked) {
  const checkout = await client.getCheckout(result.checkoutId);
  await client.setShippingAddress(result.checkoutId, shippingAddress);
  // ... continue with shipping rates, payment, etc.
}

Logged-In Customer Checkout (orders linked to account)

// 1. Make sure customer token is set (after login)
client.setCustomerToken(authResponse.token);

// 2. Add items to cart (smart methods handle server cart automatically)
await client.smartAddToCart({
  productId: products[0].id,
  quantity: 1,
});

// 3. Get cart and create checkout
const cart = await client.smartGetCart();
const checkout = await client.createCheckout({ cartId: cart.id });

// 4. Set customer info (REQUIRED - email is needed for order!)
await client.setCheckoutCustomer(checkout.id, {
  email: 'customer@example.com',
  firstName: 'John',
  lastName: 'Doe',
});

// 5. Set shipping address
await client.setShippingAddress(checkout.id, {
  firstName: 'John',
  lastName: 'Doe',
  line1: '123 Main St',
  city: 'New York',
  postalCode: '10001',
  country: 'US',
});

// 6. Get shipping rates and select one
const rates = await client.getShippingRates(checkout.id);
await client.selectShippingMethod(checkout.id, rates[0].id);

// 7. Complete checkout - order is linked to customer!
const { orderId } = await client.completeCheckout(checkout.id);
console.log('Order created:', orderId);

// Customer can now see this order in client.getMyOrders()

WARNING: Do NOT use submitGuestOrder() for logged-in customers! Their orders won't be linked to their account and won't appear in their order history.


Cart (Unified for All Users)

The SDK uses server-side carts for all users. Guests get automatic session carts; logged-in customers get server carts linked to their account.

  • ✅ Cart persists across page refreshes (via session token in localStorage)
  • ✅ Server-side pricing, discounts, and totals
  • ✅ Automatic migration from guest → customer cart on login
  • ✅ Same API for both guests and logged-in users
// Add to cart (guest or logged-in — same code!)
await client.smartAddToCart({ productId: 'prod_123', quantity: 2 });

// Get cart
const cart = await client.smartGetCart();
console.log('Items:', cart.items.length);
console.log('Total:', getCartTotals(cart).total);

// Update quantity
await client.smartUpdateCartItem('prod_123', 5);

// Remove item
await client.smartRemoveFromCart('prod_123');

After Login — Sync Cart

client.setCustomerToken(token);
const mergedCart = await client.syncCartOnLogin();
// Guest session cart items are merged into the customer's server cart

After Checkout — Clear Cart

client.onCheckoutComplete();
// Clears session cart reference so next visit starts fresh

After Logout — Preserve Guest Cart

client.clearCustomerToken();
client.onLogout();
// Session cart is preserved — guest can continue browsing

Complete Store Setup

Step 1: Create the Brainerce Client

Create a file lib/brainerce.ts:

import { BrainerceClient } from 'brainerce';

export const client = new BrainerceClient({
  connectionId: 'vc_YOUR_CONNECTION_ID', // Your Connection ID from Brainerce
});

// ----- Cart Helpers -----

export async function getCart() {
  return client.smartGetCart();
}

export function getCartItemCount(): number {
  return client.getSmartCartItemCount();
}

// ----- Customer Token Helpers -----

export function setCustomerToken(token: string | null): void {
  if (token) {
    localStorage.setItem('customerToken', token);
    client.setCustomerToken(token);
  } else {
    localStorage.removeItem('customerToken');
    client.clearCustomerToken();
  }
}

export function restoreCustomerToken(): string | null {
  const token = localStorage.getItem('customerToken');
  if (token) client.setCustomerToken(token);
  return token;
}

export function isLoggedIn(): boolean {
  return !!localStorage.getItem('customerToken');
}

Important: Cart & Checkout Data Structures

Nested Product/Variant Structure

Cart and Checkout items use a nested structure for product and variant data. This is a common pattern that prevents data duplication and ensures consistency.

Common Mistake:

// WRONG - product name is NOT at top level
const name = item.name; // undefined!
const sku = item.sku; // undefined!

Correct Access Pattern:

// CORRECT - access via nested objects
const name = item.product.name;
const sku = item.product.sku;
const variantName = item.variant?.name;
const variantSku = item.variant?.sku;

Field Mapping Reference

What You Want CartItem CheckoutLineItem
Product Name item.product.name item.product.name
Product SKU item.product.sku item.product.sku
Product ID item.productId item.productId
Product Images item.product.images item.product.images
Variant Name item.variant?.name item.variant?.name
Variant SKU item.variant?.sku item.variant?.sku
Variant ID item.variantId item.variantId
Unit Price item.unitPrice (string) item.unitPrice (string)
Quantity item.quantity item.quantity

Price Fields Are Strings

All monetary values in Cart and Checkout are returned as strings (e.g., "29.99") to preserve decimal precision across different systems. Use parseFloat() or the formatPrice() helper:

// Monetary fields that are strings:
// - CartItem: unitPrice, discountAmount
// - Cart: subtotal, discountAmount
// - CheckoutLineItem: unitPrice, discountAmount
// - Checkout: subtotal, discountAmount, shippingAmount, taxAmount, total
// - ShippingRate: price

import { formatPrice } from 'brainerce';

// Option 1: Using formatPrice helper (recommended)
const cart = await client.getCart(cartId);
const total = formatPrice(cart.subtotal); // "$59.98"
const totalNum = formatPrice(cart.subtotal, { asNumber: true }); // 59.98

// Option 2: Manual parseFloat
const subtotal = parseFloat(cart.subtotal);
const discount = parseFloat(cart.discountAmount);
const total = subtotal - discount;

// Line item total
cart.items.forEach((item) => {
  const lineTotal = parseFloat(item.unitPrice) * item.quantity;
  console.log(`${item.product.name}: $${lineTotal.toFixed(2)}`);
});

Complete Cart Item Display Example

import type { CartItem } from 'brainerce';
import { formatPrice } from 'brainerce';

function CartItemRow({ item }: { item: CartItem }) {
  // Access nested product data
  const productName = item.product.name;
  const productSku = item.product.sku;
  const productImage = item.product.images?.[0]?.url;

  // Access nested variant data (if exists)
  const variantName = item.variant?.name;
  const displayName = variantName ? `${productName} - ${variantName}` : productName;

  // Format price using helper
  const unitPrice = formatPrice(item.unitPrice);
  const lineTotal = formatPrice(item.unitPrice, { asNumber: true }) * item.quantity;

  return (
    <div className="flex items-center gap-4">
      <img src={productImage} alt={displayName} className="w-16 h-16 object-cover" />
      <div className="flex-1">
        <h3 className="font-medium">{displayName}</h3>
        <p className="text-sm text-gray-500">SKU: {item.variant?.sku || productSku}</p>
      </div>
      <span className="text-gray-600">Qty: {item.quantity}</span>
      <span className="font-medium">${lineTotal.toFixed(2)}</span>
    </div>
  );
}

API Reference

Categories, Brands & Tags (Filtering)

Get Categories (Tree Structure)

Categories are returned as a nested tree. Each category can have children, which can also have children (unlimited depth). Use this to build category filter UI with dropdowns for subcategories.

const { categories } = await client.getCategories();

// categories is a tree:
// [
//   { id: "cat_1", name: "Jewelry", parentId: null, children: [
//     { id: "cat_2", name: "Necklaces", parentId: "cat_1", children: [] },
//     { id: "cat_3", name: "Rings", parentId: "cat_1", children: [
//       { id: "cat_4", name: "Gold Rings", parentId: "cat_3", children: [] }
//     ]}
//   ]},
//   { id: "cat_5", name: "Clothing", parentId: null, children: [] }
// ]

Type:

interface CategoryNode {
  id: string;
  name: string;
  parentId?: string | null;
  children: CategoryNode[]; // Recursive — can nest to any depth
}

Filter Products by Category

Pass a category ID to getProducts(). The backend automatically includes all subcategories. So filtering by a parent category returns products from that category AND all its descendants.

// Filter by parent "Jewelry" — returns products from Jewelry, Necklaces, Rings, Gold Rings
const { data } = await client.getProducts({ categories: 'cat_1' });

// Filter by specific subcategory "Rings" — returns products from Rings + Gold Rings
const { data } = await client.getProducts({ categories: 'cat_3' });

// Multiple categories
const { data } = await client.getProducts({ categories: ['cat_1', 'cat_5'] });

Building Category Filter UI (Nested Dropdowns)

Complete example for a products page with hierarchical category chips:

'use client';
import { useEffect, useState, useRef } from 'react';
import { client } from '@/lib/brainerce';

interface CategoryNode {
  id: string;
  name: string;
  parentId?: string | null;
  children: CategoryNode[];
}

// Check if a category or any descendant matches the selected ID
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
  if (node.id === selectedId) return true;
  return node.children.some((child) => isActiveInTree(child, selectedId));
}

// Category chip — simple button for leaf categories, dropdown for parents
function CategoryChip({ category, selectedId, onSelect }: {
  category: CategoryNode;
  selectedId: string;
  onSelect: (id: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const hasChildren = category.children.length > 0;
  const isActive = isActiveInTree(category, selectedId);

  // Close on outside click
  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, [open]);

  // Simple chip for categories without children
  if (!hasChildren) {
    return (
      <button
        onClick={() => onSelect(category.id)}
        className={selectedId === category.id ? 'chip active' : 'chip'}
      >
        {category.name}
      </button>
    );
  }

  // Chip with dropdown for categories with children
  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => setOpen(!open)} className={isActive ? 'chip active' : 'chip'}>
        {category.name}</button>
      {open && (
        <div className="dropdown">
          <button onClick={() => { onSelect(category.id); setOpen(false); }}>
            All {category.name}
          </button>
          <SubcategoryList nodes={category.children} depth={0} selectedId={selectedId}
            onSelect={(id) => { onSelect(id); setOpen(false); }} />
        </div>
      )}
    </div>
  );
}

// Recursive list — renders children at any depth with indentation
function SubcategoryList({ nodes, depth, selectedId, onSelect }: {
  nodes: CategoryNode[]; depth: number; selectedId: string; onSelect: (id: string) => void;
}) {
  return (
    <>
      {nodes.map((node) => (
        <div key={node.id}>
          <button
            onClick={() => onSelect(node.id)}
            style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
            className={selectedId === node.id ? 'active' : ''}
          >
            {node.name}
          </button>
          {node.children.length > 0 && (
            <SubcategoryList nodes={node.children} depth={depth + 1}
              selectedId={selectedId} onSelect={onSelect} />
          )}
        </div>
      ))}
    </>
  );
}

// Usage in products page:
function ProductFilters() {
  const [categories, setCategories] = useState<CategoryNode[]>([]);
  const [selectedCategory, setSelectedCategory] = useState('');

  useEffect(() => {
    client.getCategories().then(({ categories }) => setCategories(categories));
  }, []);

  useEffect(() => {
    // Fetch products filtered by selected category
    client.getProducts({
      categories: selectedCategory || undefined,
    }).then(/* update products state */);
  }, [selectedCategory]);

  return (
    <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
      <button onClick={() => setSelectedCategory('')}
        className={!selectedCategory ? 'chip active' : 'chip'}>
        All
      </button>
      {categories.map((cat) => (
        <CategoryChip key={cat.id} category={cat}
          selectedId={selectedCategory} onSelect={setSelectedCategory} />
      ))}
    </div>
  );
}

Key points for AI builders:

  • getCategories() returns a tree — don't flatten it! Use children to build nested UI.
  • Selecting a parent category automatically includes all descendants (backend handles this).
  • Use position: relative on the chip wrapper and position: absolute on the dropdown for proper overlay positioning.
  • Use paddingInlineStart (not paddingLeft) for RTL support.
  • Close dropdown on outside click to prevent multiple dropdowns staying open.

Get Brands & Tags

const { brands } = await client.getBrands();
// [{ id: "brand_1", name: "Nike" }, ...]

const { tags } = await client.getTags();
// [{ id: "tag_1", name: "Sale" }, ...]

// Use in product filtering:
const { data } = await client.getProducts({
  brands: ['brand_1'],
  tags: ['tag_1'],
  categories: ['cat_1'], // Can combine all filters
});

Products

Get Products (with pagination)

import { client } from '@/lib/brainerce';
import type { Product, PaginatedResponse } from 'brainerce';

const response: PaginatedResponse<Product> = await client.getProducts({
  page: 1,
  limit: 12,
  search: 'shirt', // Optional: search by name
  categories: 'cat_id', // Optional: filter by category (includes subcategories)
  brands: ['brand_id'], // Optional: filter by brands
  tags: ['tag_id'], // Optional: filter by tags
  minPrice: 10, // Optional: minimum price
  maxPrice: 100, // Optional: maximum price
  metafields: { color: ['red', 'blue'] }, // Optional: filter by custom-field values
  sortBy: 'createdAt', // Optional: 'name' | 'createdAt' | 'updatedAt' | 'basePrice'
  sortOrder: 'desc', // Optional: 'asc' | 'desc'
});

console.log(response.data); // Product[]
console.log(response.meta.total); // Total number of products
console.log(response.meta.totalPages); // Total pages

Get Single Product

const product: Product = await client.getProduct('product_id');

console.log(product.name);
console.log(product.basePrice);
console.log(product.salePrice); // null if no sale
console.log(product.images); // ProductImage[]
console.log(product.variants); // ProductVariant[] (for VARIABLE products)
console.log(product.inventory); // { total, reserved, available }

Search Suggestions (Autocomplete)

Get search suggestions for building autocomplete/search-as-you-type UI:

import type { SearchSuggestions } from 'brainerce';

// Basic autocomplete
const suggestions: SearchSuggestions = await client.getSearchSuggestions('shirt');

console.log(suggestions.products);
// [{ id, name, image, basePrice, salePrice, type }]

console.log(suggestions.categories);
// [{ id, name, productCount }]

// With custom limit (default: 5, max: 10)
const suggestions = await client.getSearchSuggestions('dress', 3);

Search covers: name, sku, description, categories, tags, and brands.

Example: Search Input with Suggestions

function SearchInput() {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState<SearchSuggestions | null>(null);

  // Debounce search requests
  useEffect(() => {
    if (query.length < 2) {
      setSuggestions(null);
      return;
    }

    const timer = setTimeout(async () => {
      const results = await client.getSearchSuggestions(query, 5);
      setSuggestions(results);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      {suggestions && (
        <div className="suggestions">
          {suggestions.products.map((product) => (
            <a key={product.id} href={`/products/${product.slug}`}>
              <img src={product.image || '/placeholder.png'} alt={product.name} />
              <span>{product.name}</span>
              <span>${product.basePrice}</span>
            </a>
          ))}
          {suggestions.categories.map((category) => (
            <a key={category.id} href={`/category/${category.id}`}>
              {category.name} ({category.productCount} products)
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

Product Type Definition

interface Product {
  id: string;
  name: string;
  description?: string | null;
  descriptionFormat?: 'text' | 'html' | 'markdown'; // Format of description content
  sku: string;
  basePrice: number;
  salePrice?: number | null;
  status: 'active' | 'draft';
  type: 'SIMPLE' | 'VARIABLE';
  images?: ProductImage[];
  inventory?: InventoryInfo | null;
  variants?: ProductVariant[];
  categories?: string[];
  tags?: string[];
  createdAt: string;
  updatedAt: string;
}

interface ProductImage {
  url: string;
  position?: number;
  isMain?: boolean;
}

interface ProductVariant {
  id: string;
  sku?: string | null;
  name?: string | null;
  price?: number | null;
  salePrice?: number | null;
  attributes?: Record<string, string>;
  inventory?: InventoryInfo | null;
}

interface InventoryInfo {
  total: number;
  reserved: number;
  available: number;
  trackingMode?: 'TRACKED' | 'UNLIMITED' | 'DISABLED';
  inStock: boolean; // Pre-calculated - use this for display!
  canPurchase: boolean; // Pre-calculated - use this for add-to-cart
}

Product Metafields (Custom Fields)

Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".

Important: Each metafield has a type field. When rendering, you must check field.type and render accordingly — don't just display field.value as text for all types.

Type Rendering
IMAGE <img> thumbnail
GALLERY Row of <img> thumbnails (value is JSON array of URLs)
URL <a> clickable link
COLOR Color swatch circle + hex value
BOOLEAN "Yes" / "No"
DATE Formatted date (toLocaleDateString())
DATETIME Formatted datetime (toLocaleString())
TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON Plain text
import {
  getProductMetafield,
  getProductMetafieldValue,
  getProductMetafieldsByType,
} from 'brainerce';
import type { ProductMetafield } from 'brainerce';

const product = await client.getProductBySlug('blue-shirt');

// Render metafield value based on type
function renderMetafieldValue(field: ProductMetafield): string | JSX.Element {
  switch (field.type) {
    case 'IMAGE':
      return field.value ? <img src={field.value} alt={field.definitionName} className="h-16 w-16 rounded object-cover" /> : '-';
    case 'GALLERY': {
      let urls: string[] = [];
      try { urls = JSON.parse(field.value); } catch { urls = field.value ? [field.value] : []; }
      return <div className="flex gap-2">{urls.map((url, i) => <img key={i} src={url} className="h-16 w-16 rounded object-cover" />)}</div>;
    }
    case 'URL':
      return field.value ? <a href={field.value} target="_blank" rel="noopener noreferrer">{field.value}</a> : '-';
    case 'COLOR':
      return <span><span style={{ backgroundColor: field.value }} className="inline-block h-4 w-4 rounded-full" /> {field.value}</span>;
    case 'BOOLEAN':
      return field.value === 'true' ? 'Yes' : 'No';
    case 'DATE':
      return field.value ? new Date(field.value).toLocaleDateString() : '-';
    case 'DATETIME':
      return field.value ? new Date(field.value).toLocaleString() : '-';
    default:
      return field.value || '-';
  }
}

// Display in a spec table
product.metafields?.forEach((field) => {
  console.log(`${field.definitionName}: ${renderMetafieldValue(field)}`);
});

// Get a specific metafield by key
const material = getProductMetafieldValue(product, 'material'); // auto-parsed (string | number | boolean | null)
const careField = getProductMetafield(product, 'care_instructions'); // full ProductMetafield object

// Filter metafields by type
const textFields = getProductMetafieldsByType(product, 'TEXT');

// Fetch metafield definitions (schema) to build dynamic UI
const { definitions } = await client.getPublicMetafieldDefinitions();
definitions.forEach((def) => {
  console.log(`${def.name} (${def.key}): ${def.type}, required: ${def.required}`);
});

Note: metafields may be empty if the store hasn't defined custom fields. Always use optional chaining (product.metafields?.forEach).

Product Customization Fields (Customer Input)

Some products allow customers to provide custom input (e.g., "What text to write on the cake?", "Upload your logo"). These are returned in the customizationFields array on the product response.

import { getProductCustomizationFields } from 'brainerce';
import type { ProductCustomizationField } from 'brainerce';

const product = await client.getProductBySlug('custom-cake');

// Get customization fields for this product
const fields = getProductCustomizationFields(product);

fields.forEach((field) => {
  // field.key: unique identifier (e.g., "cake_text")
  // field.name: display label (e.g., "Text on Cake")
  // field.type: TEXT, TEXTAREA, NUMBER, BOOLEAN, DATE, COLOR, IMAGE, GALLERY, etc.
  // field.required: whether customer must fill this in
  // field.minLength, field.maxLength: validation for text fields
  // field.minValue, field.maxValue: validation for number fields
  // field.enumValues: allowed options for dropdown fields
  // field.defaultValue: default value to pre-fill
});

// Customer fills in values, then add to cart with metadata
await client.addToCart(cartId, {
  productId: product.id,
  quantity: 1,
  metadata: {
    cake_text: 'Happy Birthday!', // TEXT field
    font_color: '#FF0000', // COLOR field
  },
});

For IMAGE/GALLERY fields, upload the file first:

// Upload customer file (storefront/vibe-coded mode)
const { url } = await client.uploadCustomizationFile(fileInput.files[0]);

// Then include the URL in cart metadata
await client.addToCart(cartId, {
  productId: product.id,
  quantity: 1,
  metadata: {
    logo: url, // IMAGE field value is the uploaded URL
  },
});

Supported field types:

Type Stored in metadata as Customer UI
TEXT string Text input
TEXTAREA string Textarea
NUMBER number Number input
BOOLEAN boolean Checkbox
DATE/DATETIME string (ISO) Date picker
COLOR string (hex) Color picker
URL string URL input
IMAGE string (URL) File upload
GALLERY string[] (URLs) Multi-file upload
DIMENSION/WEIGHT { value, unit } Value + unit inputs

Admin methods (require API key):

// Set which customer-input definitions apply to a product
await client.setProductCustomizationFields(productId, [definitionId1, definitionId2]);

// Get current assignments
const fields = await client.getProductCustomizationFields(productId);

Note: customizationFields is only present when the product has customer input fields assigned. After checkout, customization values are preserved in the order as item.customizations.

Displaying Price Range for Variable Products

For products with type: 'VARIABLE' and multiple variants with different prices, display a price range instead of a single price:

// Helper function to get price range from variants
function getPriceRange(product: Product): { min: number; max: number } | null {
  if (product.type !== 'VARIABLE' || !product.variants?.length) {
    return null;
  }

  const prices = product.variants
    .map(v => v.price ?? product.basePrice)
    .filter((p): p is number => p !== null);

  if (prices.length === 0) return null;

  const min = Math.min(...prices);
  const max = Math.max(...prices);

  // Return null if all variants have the same price
  return min !== max ? { min, max } : null;
}

// Usage in component
function ProductPrice({ product }: { product: Product }) {
  const priceRange = getPriceRange(product);

  if (priceRange) {
    // Variable product with different variant prices - show range
    return <span>${priceRange.min} - ${priceRange.max}</span>;
  }

  // Simple product or all variants same price - show single price
  return product.salePrice ? (
    <>
      <span className="text-red-600">${product.salePrice}</span>
      <span className="line-through text-gray-400 ml-2">${product.basePrice}</span>
    </>
  ) : (
    <span>${product.basePrice}</span>
  );
}

When to show price range:

  • Product type is 'VARIABLE'
  • Has 2+ variants with different prices
  • Example: T-shirt sizes S/M/L at $29, XL/XXL at $34 → Display "$29 - $34"

When to show single price:

  • Product type is 'SIMPLE'
  • Variable product where all variants have the same price

Rendering Product Descriptions

CRITICAL: Product descriptions from Shopify/WooCommerce contain HTML tags. If you render them as plain text, users will see raw <p>, <ul>, <li> tags instead of formatted content!

Use the SDK helper functions to handle this automatically:

import { isHtmlDescription, getDescriptionContent } from 'brainerce';

// Option 1: Using isHtmlDescription helper (recommended)
function ProductDescription({ product }: { product: Product }) {
  if (!product.description) return null;

  if (isHtmlDescription(product)) {
    // HTML from Shopify/WooCommerce - MUST use dangerouslySetInnerHTML
    return <div dangerouslySetInnerHTML={{ __html: product.description }} />;
  }

  // Plain text - render normally
  return <p>{product.description}</p>;
}

// Option 2: Using getDescriptionContent helper
function ProductDescription({ product }: { product: Product }) {
  const content = getDescriptionContent(product);
  if (!content) return null;

  if ('html' in content) {
    return <div dangerouslySetInnerHTML={{ __html: content.html }} />;
  }

  return <p>{content.text}</p>;
}
Source Platform descriptionFormat Rendering
Shopify 'html' Use dangerouslySetInnerHTML
WooCommerce 'html' Use dangerouslySetInnerHTML
TikTok 'text' Render as plain text
Manual entry 'text' Render as plain text

Common Mistake - DO NOT do this:

// WRONG - HTML will show as raw tags like <p>Hello</p>
<p>{product.description}</p>

Cart Operations (All Users)

The smart* methods work for both guests and logged-in users. Guests use server-side session carts; logged-in users use server carts linked to their account.

Add to Cart

await client.smartAddToCart({
  productId: 'prod_123',
  variantId: 'var_456', // Optional: for products with variants
  quantity: 2,
});

Get Cart

const cart = await client.smartGetCart();

console.log(cart.items); // Array of CartItem
console.log(cart.itemCount); // Total item count
console.log(cart.couponCode); // Applied coupon (if any)

Update Item Quantity

// Set quantity to 5
await client.smartUpdateCartItem('prod_123', 5);

// For variant products
await client.smartUpdateCartItem('prod_123', 3, 'var_456');

// Set to 0 to remove
await client.smartUpdateCartItem('prod_123', 0);

Remove Item

await client.smartRemoveFromCart('prod_123');
await client.smartRemoveFromCart('prod_123', 'var_456'); // With variant

Get Cart Item Count (No API Call)

// Returns cached count from session reference — instant, no API call
const count = client.getSmartCartItemCount();
console.log(`${count} items in cart`);
// For accurate count, use: (await client.smartGetCart()).itemCount

Apply Coupon

const cart = await client.smartGetCart();
const updated = await client.applyCoupon(cart.id, 'SAVE20');
console.log(updated.discountAmount); // "10.00"
console.log(updated.couponCode); // "SAVE20"

// Remove coupon
await client.removeCoupon(cart.id);

Cart Totals

import { getCartTotals } from 'brainerce';

const cart = await client.smartGetCart();
const totals = getCartTotals(cart);
// { subtotal: 59.98, discount: 10, shipping: 5.99, total: 55.97 }

Guest Checkout (Submit Order)

Note: startGuestCheckout() is the preferred method for guest checkout — it creates a full checkout session from the session cart. submitGuestOrder() still works as a simpler alternative for basic orders.

// Make sure cart has items, customer email, and shipping address
const order = await client.submitGuestOrder();

console.log(order.orderId); // 'order_abc123...'
console.log(order.orderNumber); // 'ORD-12345'
console.log(order.status); // 'pending'
console.log(order.total); // 59.98
console.log(order.message); // 'Order created successfully'

// Cart is automatically cleared after successful order

🔄 Automatic Tracking: If "Track Guest Checkouts" is enabled in your connection settings (Brainerce Admin), submitGuestOrder() will automatically create a tracked checkout session before placing the order. This allows you to see abandoned carts and checkout sessions in your admin dashboard - no code changes needed!

Keep Cart After Order

// If you want to keep the cart data (e.g., for order review page)
const order = await client.submitGuestOrder({ clearCartOnSuccess: false });

Create Order with Custom Data

If you manage cart state yourself instead of using the smart cart methods:

const order = await client.createGuestOrder({
  items: [
    { productId: 'prod_123', quantity: 2 },
    { productId: 'prod_456', variantId: 'var_789', quantity: 1 },
  ],
  customer: {
    email: 'customer@example.com',
    firstName: 'John',
    lastName: 'Doe',
  },
  shippingAddress: {
    firstName: 'John',
    lastName: 'Doe',
    line1: '123 Main St',
    city: 'New York',
    postalCode: '10001',
    country: 'US',
  },
  couponCode: 'SAVE20', // Optional
  notes: 'Please gift wrap', // Optional
});

Guest Order Response Type

interface GuestOrderResponse {
  orderId: string;
  orderNumber: string;
  status: string;
  total: number;
  message: string;
}

Tracked Guest Checkout (Automatic)

Note: As of SDK v0.7.1, submitGuestOrder() automatically handles tracking. You don't need to use these methods unless you want explicit control over the checkout flow.

When "Track Guest Checkouts" is enabled in your connection settings, checkout sessions are automatically created on the server, allowing:

  • Visibility of checkout sessions in admin dashboard
  • Abandoned cart tracking
  • Future: abandoned cart recovery emails

How to Enable

  1. Go to Brainerce Admin → Integrations → Vibe-Coded Sites
  2. Click on your connection → Settings
  3. Enable "Track Guest Checkouts"
  4. Save - that's it! No code changes needed.

Advanced: Manual Tracking Control

If you need explicit control over the tracking flow (e.g., to track checkout steps before the user places an order):

// 1. Start tracked checkout (sends cart items to server)
const checkout = await client.startGuestCheckout();

if (checkout.tracked) {
  // 2. Update with shipping address
  await client.updateGuestCheckoutAddress(checkout.checkoutId, {
    shippingAddress: {
      firstName: 'John',
      lastName: 'Doe',
      line1: '123 Main St',
      city: 'New York',
      postalCode: '10001',
      country: 'US',
    },
  });

  // 3. Complete the checkout
  const order = await client.completeGuestCheckout(checkout.checkoutId);
  console.log('Order created:', order.orderId);
} else {
  // Fallback to regular guest checkout
  const order = await client.submitGuestOrder();
}

Response Types

type GuestCheckoutStartResponse =
  | {
      tracked: true;
      checkoutId: string;
      cartId: string;
      message: string;
    }
  | {
      tracked: false;
      message: string;
    };

Server Cart (Low-Level API)

These low-level methods are available for advanced use cases. For most storefronts, use the smart* methods above instead.

Create Cart

const cart = await client.createCart();

Get Cart

const cartId = getCartId();
if (cartId) {
  const cart = await client.getCart(cartId);
  console.log(cart.items); // CartItem[]
  console.log(cart.itemCount); // Total items
  console.log(cart.subtotal); // Subtotal amount
}

Add to Cart

const cart = await client.addToCart(cartId, {
  productId: 'product_id',
  variantId: 'variant_id', // Optional: for VARIABLE products
  quantity: 2,
  notes: 'Gift wrap please', // Optional
});

Update Cart Item

const cart = await client.updateCartItem(cartId, itemId, {
  quantity: 3,
});

Remove Cart Item

const cart = await client.removeCartItem(cartId, itemId);

Apply Coupon

const cart = await client.applyCoupon(cartId, 'SAVE20');
console.log(cart.discountAmount); // Discount applied
console.log(cart.couponCode); // 'SAVE20'

Remove Coupon

const cart = await client.removeCoupon(cartId);

Cart Type Definition

interface Cart {
  id: string;
  sessionToken?: string | null;
  customerId?: string | null;
  status: 'ACTIVE' | 'MERGED' | 'CONVERTED' | 'ABANDONED';
  currency: string;
  subtotal: string;
  discountAmount: string;
  couponCode?: string | null;
  items: CartItem[];
  itemCount: number;
  createdAt: string;
  updatedAt: string;
}

interface CartItem {
  id: string;
  productId: string;
  variantId?: string | null;
  quantity: number;
  unitPrice: string;
  discountAmount: string;
  notes?: string | null;
  product: {
    id: string;
    name: string;
    sku: string;
    images?: unknown[];
  };
  variant?: {
    id: string;
    name?: string | null;
    sku?: string | null;
  } | null;
}

Checkout

Create Checkout from Cart

const checkout = await client.createCheckout({
  cartId: cartId,
});

Partial Checkout (AliExpress-style)

Allow customers to select which items to checkout from their cart. Only selected items are purchased - remaining items stay in the cart for later.

// 1. In your cart page, track selected items
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());

// 2. Create checkout with only selected items
const checkout = await client.createCheckout({
  cartId: cart.id,
  selectedItemIds: Array.from(selectedItems), // Only these items go to checkout
});

// 3. Before checkout, check stock ONLY for selected items
const stockCheck = await client.checkCartStock(cart, Array.from(selectedItems));
if (!stockCheck.allAvailable) {
  // Handle out-of-stock items
}

// After successful payment:
// - Selected items are REMOVED from cart
// - Unselected items REMAIN in cart (cart stays ACTIVE)
// - Customer can continue shopping and checkout remaining items later

Set Customer Information

const checkout = await client.setCheckoutCustomer(checkoutId, {
  email: 'customer@example.com',
  firstName: 'John',
  lastName: 'Doe',
  phone: '+1234567890', // Optional
});

Set Shipping Address

const { checkout, rates } = await client.setShippingAddress(checkoutId, {
  firstName: 'John',
  lastName: 'Doe',
  line1: '123 Main St',
  line2: 'Apt 4B', // Optional
  city: 'New York',
  region: 'NY', // State/Province
  postalCode: '10001',
  country: 'US',
  phone: '+1234567890', // Optional
});

// rates contains available shipping options
console.log(rates); // ShippingRate[]

Select Shipping Method

const checkout = await client.selectShippingMethod(checkoutId, rates[0].id);

Set Billing Address

// Same as shipping
const checkout = await client.setBillingAddress(checkoutId, {
  ...shippingAddress,
  sameAsShipping: true, // Optional shortcut
});

Complete Checkout

const { orderId } = await client.completeCheckout(checkoutId);
clearCartId(); // Clear cart from localStorage
console.log('Order created:', orderId);

Checkout Custom Fields (Surcharges)

Some stores define custom fields at checkout (e.g., floor number, gift wrapping, installation) that can add surcharges to the order total.

import type { CheckoutCustomFieldDefinition } from 'brainerce';

// 1. Get applicable custom fields for this checkout
const fields = await client.getCheckoutCustomFields(checkoutId);

// 2. Render fields dynamically based on field.type:
//    TEXT → text input, NUMBER → number input, BOOLEAN → checkbox,
//    SELECT → dropdown (field.options), DATE → date picker
//    Show pricing info from field.pricing (e.g., "+25₪ when checked")

// 3. Set customer values → recalculates surcharges and total
const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
  gift_wrapping: 'premium', // SELECT → surcharge based on selected option
  floor_number: 5, // NUMBER → conditional surcharge (e.g., floor > 2 = +30₪)
  installation: true, // BOOLEAN → surcharge when checked
});

// updatedCheckout.surchargeAmount = "55"  (sum of all surcharges)
// updatedCheckout.appliedSurcharges = [{ key, name, value, amount }]
// updatedCheckout.total is recalculated with surcharges included

Pricing types:

  • none → no surcharge
  • fixed → flat amount when field has a value
  • boolean_checked → amount when checkbox is checked
  • per_option → each SELECT option has its own price
  • conditional → surcharge when NUMBER value meets condition (gt, gte, lt, lte, eq)

Checkout Type Definition

interface Checkout {
  id: string;
  status: CheckoutStatus;
  email?: string | null;
  shippingAddress?: CheckoutAddress | null;
  billingAddress?: CheckoutAddress | null;
  shippingMethod?: ShippingRate | null;
  currency: string;
  subtotal: string;
  discountAmount: string;
  shippingAmount: string;
  taxAmount: string;
  total: string;
  couponCode?: string | null;
  items: CheckoutLineItem[];
  itemCount: number;
  availableShippingRates?: ShippingRate[];
}

type CheckoutStatus = 'PENDING' | 'SHIPPING_SET' | 'PAYMENT_PENDING' | 'COMPLETED' | 'FAILED';

interface ShippingRate {
  id: string;
  name: string;
  description?: string | null;
  price: string;
  currency: string;
  estimatedDays?: number | null;
}

Shipping Rates: Complete Flow

The shipping flow involves setting an address and then selecting from available rates:

// Step 1: Set shipping address - this returns available rates
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
  firstName: 'John',
  lastName: 'Doe',
  line1: '123 Main St',
  city: 'New York',
  region: 'NY',
  postalCode: '10001',
  country: 'US',
});

// Step 2: Handle empty rates (edge case)
if (rates.length === 0) {
  // No shipping options available for this address
  // This can happen when:
  // - Store doesn't ship to this address/country
  // - All shipping methods have restrictions that exclude this address
  // - Shipping rates haven't been configured in the store

  return (
    <div className="bg-yellow-50 p-4 rounded">
      <p className="font-medium">No shipping options available</p>
      <p className="text-sm text-gray-600">
        We currently cannot ship to this address. Please try a different address or contact us for
        assistance.
      </p>
    </div>
  );
}

// Step 3: Display available rates to customer
<div className="space-y-2">
  <h3 className="font-medium">Select Shipping Method</h3>
  {rates.map((rate) => (
    <label key={rate.id} className="flex items-center gap-3 p-3 border rounded cursor-pointer">
      <input
        type="radio"
        name="shipping"
        value={rate.id}
        checked={selectedRateId === rate.id}
        onChange={() => setSelectedRateId(rate.id)}
      />
      <div className="flex-1">
        <span className="font-medium">{rate.name}</span>
        {rate.description && <p className="text-sm text-gray-500">{rate.description}</p>}
        {rate.estimatedDays && (
          <p className="text-sm text-gray-500">Estimated delivery: {rate.estimatedDays} business days</p>
        )}
      </div>
      <span className="font-medium">${parseFloat(rate.price).toFixed(2)}</span>
    </label>
  ))}
</div>;

// Step 4: Select the shipping method
await client.selectShippingMethod(checkoutId, selectedRateId);

Handling Empty Shipping Rates:

When no shipping rates are available, you have several options:

// Option 1: Show helpful message
if (rates.length === 0) {
  return <NoShippingAvailable address={shippingAddress} />;
}

// Option 2: Allow customer to contact store
if (rates.length === 0) {
  return (
    <div>
      <p>Shipping not available to your location.</p>
      <a href="/contact">Request a shipping quote</a>
    </div>
  );
}

// Option 3: Validate before proceeding
function canProceedToPayment(checkout: Checkout, rates: ShippingRate[]): boolean {
  if (rates.length === 0) return false;
  if (!checkout.shippingRateId) return false;
  if (!checkout.email) return false;
  return true;
}

Payment Integration (Vibe-Coded Sites)

For vibe-coded sites, the SDK provides payment integration with Stripe and PayPal. The store owner configures their payment provider(s) in the admin, and your site uses these methods to process payments.

⚠️ Important: Getting a Valid Checkout ID

Before creating a payment intent, you need a checkout ID. How you get it depends on the customer type:

// For GUEST users (session cart):
const result = await client.startGuestCheckout();
const checkoutId = result.checkoutId;

// For LOGGED-IN users (server cart):
const cart = await client.smartGetCart();
const checkout = await client.createCheckout({ cartId: cart.id });
const checkoutId = checkout.id;

// Then continue with shipping and payment...

Use this method to get ALL enabled payment providers and build dynamic UI:

const { hasPayments, providers, defaultProvider } = await client.getPaymentProviders();

// Returns:
// {
//   hasPayments: true,
//   providers: [
//     {
//       id: 'provider_xxx',
//       provider: 'stripe',
//       name: 'Stripe',
//       publicKey: 'pk_live_xxx...',
//       supportedMethods: ['card', 'ideal'],
//       testMode: false,
//       isDefault: true
//     },
//     {
//       id: 'provider_yyy',
//       provider: 'paypal',
//       name: 'PayPal',
//       publicKey: 'client_id_xxx...',
//       supportedMethods: ['paypal'],
//       testMode: false,
//       isDefault: false
//     }
//   ],
//   defaultProvider: { ... }  // The default provider (first one)
// }

// Build dynamic UI based on available providers
if (!hasPayments) {
  return <div>Payment not configured for this store</div>;
}

const stripeProvider = providers.find(p => p.provider === 'stripe');
const paypalProvider = providers.find(p => p.provider === 'paypal');

// Show Stripe payment form if available
if (stripeProvider) {
  const stripe = await loadStripe(stripeProvider.publicKey);
  // ... show Stripe Elements
}

// Show PayPal buttons if available
if (paypalProvider) {
  // ... show PayPal buttons with paypalProvider.publicKey as client-id
}

Get Payment Configuration (Single Provider)

If you only need the default provider, use this simpler method:

const config = await client.getPaymentConfig();

// Returns:
// {
//   provider: 'stripe' | 'paypal',
//   publicKey: 'pk_live_xxx...',  // Stripe publishable key or PayPal client ID
//   supportedMethods: ['card', 'ideal', 'bancontact'],
//   testMode: false
// }

Create Payment Intent

After the customer fills in shipping details, create a payment intent:

const intent = await client.createPaymentIntent(checkout.id);

// Returns:
// {
//   id: 'pi_xxx...',
//   clientSecret: 'pi_xxx_secret_xxx',  // Used by Stripe.js/PayPal SDK
//   amount: 9999,  // In cents
//   currency: 'USD',
//   status: 'requires_payment_method'
// }

Confirm Payment with Stripe.js

Use the client secret with Stripe.js to collect payment:

// Initialize Stripe.js with the public key from getPaymentConfig()
const stripe = await loadStripe(config.publicKey);

// Create Elements and Payment Element
const elements = stripe.elements({ clientSecret: intent.clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');

// When customer submits payment
const { error } = await stripe.confirmPayment({
  elements,
  confirmParams: {
    return_url: `${window.location.origin}/checkout/success?checkout_id=${checkout.id}`,
  },
});

if (error) {
  console.error('Payment failed:', error.message);
}

Important: Orders are created asynchronously via webhook after Stripe confirms payment. This typically takes 1-5 seconds, but can vary. Follow these best practices:

Option 1: Optimistic Success Page (Recommended - Used by Amazon, Shopify, AliExpress)

Show success immediately without waiting for orderId. This is the industry standard:

// In your payment form, after stripe.confirmPayment() succeeds:
const { error } = await stripe.confirmPayment({
  elements,
  confirmParams: {
    return_url: `${window.location.origin}/checkout/success?checkout_id=${checkout.id}`,
  },
});

// On /checkout/success page - show confirmation IMMEDIATELY:
export default function CheckoutSuccessPage() {
  const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');

  return (
    <div className="text-center py-12">
      <h1 className="text-2xl font-bold text-green-600">Payment Received!</h1>
      <p className="mt-4">Your order is being processed.</p>
      <p className="mt-2 text-gray-600">
        Confirmation #{checkoutId?.slice(-8).toUpperCase()}
      </p>
      <p className="mt-4">A confirmation email will be sent shortly.</p>
      <a href="/orders" className="mt-6 inline-block text-blue-600">
        View Your Orders →
      </a>
    </div>
  );
}

Option 2: Wait for Order (For SPAs that need orderId)

Use waitForOrder() to poll in the background with exponential backoff:

// After payment succeeds, wait for order creation (max 30 seconds)
const result = await client.waitForOrder(checkout.id, {
  maxWaitMs: 30000, // 30 seconds max
  onPollAttempt: (attempt, status) => {
    console.log(`Checking order status... (attempt ${attempt})`);
  },
  onOrderReady: (status) => {
    // Called immediately when order is created
    console.log('Order ready:', status.orderNumber);
  },
});

if (result.success) {
  // Order was created within timeout
  window.location.href = `/orders/${result.status.orderId}`;
} else {
  // Order not created yet - show optimistic success anyway
  // The email will be sent when order is ready
  showSuccessMessage('Payment received! Order confirmation coming soon.');
}

Option 3: Simple Status Check (Single poll)

For simple use cases where you just want to check once:

const status = await client.getPaymentStatus(checkout.id);

// Returns:
// {
//   checkoutId: 'checkout_xxx',
//   status: 'succeeded' | 'pending' | 'failed' | 'canceled',
//   orderId: 'order_xxx',      // Only if order was created
//   orderNumber: 'ORD-123',    // Only if order was created
//   error: 'Payment declined'  // Only if payment failed
// }

if (status.status === 'succeeded' && status.orderId) {
  window.location.href = `/order-confirmation/${status.orderId}`;
} else if (status.status === 'succeeded') {
  // Payment succeeded but order not created yet
  showMessage('Payment received, processing your order...');
} else if (status.status === 'failed') {
  showError(status.error || 'Payment failed');
}

Why Optimistic Success? Stripe webhooks typically arrive within 1-5 seconds, but network issues can cause delays. Major e-commerce platforms (Amazon, Shopify) show success immediately and send order details via email. This provides better UX than making customers wait on a loading screen.

Complete Checkout with Payment Example

Note: This example assumes you already have a checkout_id. See below for how to create one.

How to get a checkout_id:

// For GUEST users (session cart):
const result = await client.startGuestCheckout();
const checkoutId = result.checkoutId; // Use this!

// For LOGGED-IN users (server cart):
const cart = await client.smartGetCart();
const checkout = await client.createCheckout({ cartId: cart.id });
const checkoutId = checkout.id; // Use this!
'use client';
import { useState, useEffect } from 'react';
import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { client } from '@/lib/brainerce';
import type { Checkout, PaymentConfig, PaymentIntent } from 'brainerce';

export default function CheckoutPaymentPage() {
  const [checkout, setCheckout] = useState<Checkout | null>(null);
  const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
  const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
  const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    async function initPayment() {
      try {
        // Get checkout_id from URL (set by previous step)
        const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
        if (!checkoutId) throw new Error('No checkout ID');

        // Get payment configuration
        const config = await client.getPaymentConfig();
        setPaymentConfig(config);

        // Initialize Stripe
        if (config.provider === 'stripe') {
          setStripePromise(loadStripe(config.publicKey));
        }

        // Create payment intent
        const intent = await client.createPaymentIntent(checkoutId);
        setPaymentIntent(intent);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to initialize payment');
      } finally {
        setLoading(false);
      }
    }
    initPayment();
  }, []);

  if (loading) return <div>Loading payment...</div>;
  if (error) return <div className="text-red-600">{error}</div>;
  if (!paymentConfig || !paymentIntent || !stripePromise) return null;

  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Payment</h1>

      <Elements stripe={stripePromise} options={{ clientSecret: paymentIntent.clientSecret }}>
        <PaymentForm checkoutId={paymentIntent.id} />
      </Elements>
    </div>
  );
}

function PaymentForm({ checkoutId }: { checkoutId: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [processing, setProcessing] = useState(false);
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setProcessing(true);
    setError('');

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/checkout/success?checkout_id=${checkoutId}`,
      },
    });

    if (error) {
      setError(error.message || 'Payment failed');
      setProcessing(false);
    }
    // If successful, Stripe redirects to return_url
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />

      {error && <div className="text-red-600 mt-4">{error}</div>}

      <button
        type="submit"
        disabled={!stripe || processing}
        className="w-full mt-6 bg-green-600 text-white py-3 rounded disabled:opacity-50"
      >
        {processing ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

Complete Order After Payment: completeGuestCheckout()

CRITICAL: After payment succeeds, you MUST call completeGuestCheckout() to create the order on the server and clear the cart.

WARNING: Do NOT use handlePaymentSuccess() - it only clears cart state locally and does NOT send the order to the server. Your customer will pay but no order will be created!

// On your /checkout/success page:
export default function CheckoutSuccessPage() {
  const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');

  useEffect(() => {
    if (checkoutId) {
      // IMPORTANT: This sends the order to the server AND clears the cart
      // completeGuestCheckout() returns GuestOrderResponse (has .orderNumber directly)
      // This is different from waitForOrder() which returns WaitForOrderResult
      // (access orderNumber via .status.orderNumber instead)
      client.completeGuestCheckout(checkoutId).then(result => {
        console.log('Order created:', result.orderNumber);
      }).catch(() => {
        // Order may already exist (e.g., page refresh) - safe to ignore
      });
    }
  }, []);

  return (
    <div className="text-center py-12">
      <h1 className="text-2xl font-bold text-green-600">Payment Received!</h1>
      {/* ... rest of success page */}
    </div>
  );
}

How it works:

User Type Cart Type Behavior
Guest (partial checkout) Session cart Creates order + removes only purchased items
Guest (full checkout) Session cart Creates order + clears entire cart
Logged-in Server cart Creates order + clears cart via SDK state

Why is this needed?

  • completeGuestCheckout() sends POST /checkout/:id/complete which creates the order on the server
  • Without it, payment goes through Stripe but no order is created in the system
  • The server also links the order to the customer (by email) so it appears in their order history
  • For partial checkout (AliExpress-style), only the purchased items are removed

Sandbox / Test Mode

When sandboxPaymentsEnabled is turned on for a vibe-coded connection, getPaymentProviders() includes a sandbox provider that allows placing test orders without real payment.

const { providers } = await client.getPaymentProviders();
const sandboxProvider = providers.find((p) => p.provider === 'sandbox');

if (sandboxProvider) {
  // Sandbox is available — testMode will be true
  console.log(sandboxProvider.testMode); // true
  console.log(sandboxProvider.name); // "Sandbox (Test Orders)"
}

When you create a payment intent with the sandbox provider, it returns renderType: 'sandbox':

const intent = await client.createPaymentIntent(checkoutId);

if (intent.clientSdk?.renderType === 'sandbox') {
  // Show a "Complete Test Order" button instead of real payment UI
  // No real charges — call completeGuestCheckout() to finalize
  await client.completeGuestCheckout(checkoutId);
  // Redirect to order confirmation
  window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
}

Important notes:

  • Sandbox orders are marked isTestOrder: true in the database
  • Sandbox orders are automatically set to financialStatus: 'paid'
  • Test orders do not affect store analytics
  • The embedded PaymentStep template handles sandbox rendering automatically

Customer Authentication

Register Customer

const auth = await client.registerCustomer({
  email: 'customer@example.com',
  password: 'securepassword123',
  firstName: 'John',
  lastName: 'Doe',
});

// Check if email verification is required
if (auth.requiresVerification) {
  localStorage.setItem('verificationToken', auth.token);
  window.location.href = '/verify-email';
} else {
  setCustomerToken(auth.token);
  // Redirect back to store, not /account
  window.location.href = '/';
}

Login Customer

const auth = await client.loginCustomer('customer@example.com', 'password123');
setCustomerToken(auth.token);

// Best practice: redirect back to previous page or home
const returnUrl = localStorage.getItem('returnUrl') || '/';
localStorage.removeItem('returnUrl');
window.location.href = returnUrl;

Best Practice: Before showing login page, save the current URL with localStorage.setItem('returnUrl', window.location.pathname). After login, redirect back to that URL. This is how Amazon, Shopify, and most e-commerce sites work.

Forgot Password

await client.forgotPassword('customer@example.com');
// Always returns success message (prevents email enumeration)
// If account exists, sends email with reset link

Reset Password

// On /reset-password page, extract token from URL
const token = new URLSearchParams(window.location.search).get('token');
const result = await client.resetPassword(token!, 'newSecurePassword123');
// result.message = "Password has been reset successfully"

Logout Customer

setCustomerToken(null);
window.location.href = '/'; // Return to store home

Get Customer Profile

restoreCustomerToken(); // Restore from localStorage
const profile = await client.getMyProfile();

console.log(profile.firstName);
console.log(profile.email);
console.log(profile.addresses);

Get Customer Orders

const { data: orders, meta } = await client.getMyOrders({
  page: 1,
  limit: 10,
});

Auth Response Type

interface CustomerAuthResponse {
  customer: {
    id: string;
    email: string;
    firstName?: string;
    lastName?: string;
    emailVerified: boolean;
  };
  token: string;
  expiresAt: string;
  requiresVerification?: boolean; // true if email verification is required
}

Email Verification

If the store has email verification enabled, customers must verify their email after registration before they can fully use their account.

Registration with Email Verification

When requiresVerification is true in the registration response, the customer needs to verify their email:

const auth = await client.registerCustomer({
  email: 'customer@example.com',
  password: 'securepassword123',
  firstName: 'John',
});

if (auth.requiresVerification) {
  // Save token for verification step
  localStorage.setItem('verificationToken', auth.token);
  // Redirect to verification page
  window.location.href = '/verify-email';
} else {
  // No verification needed - redirect back to store
  setCustomerToken(auth.token);
  window.location.href = '/';
}

Verify Email with Code

After the customer receives the 6-digit code via email:

// Get the token saved from registration
const token = localStorage.getItem('verificationToken');

// Verify email - pass the token directly (no need to call setCustomerToken first!)
const result = await client.verifyEmail(code, token);

if (result.verified) {
  // Email verified! Now set the token for normal use
  setCustomerToken(token);
  localStorage.removeItem('verificationToken');
  // Redirect back to store (or returnUrl if saved)
  const returnUrl = localStorage.getItem('returnUrl') || '/';
  localStorage.removeItem('returnUrl');
  window.location.href = returnUrl;
}

Resend Verification Email

If the customer didn't receive the email or the code expired:

const token = localStorage.getItem('verificationToken');
await client.resendVerificationEmail(token);
// Show success message - new code sent

Note: Resend is rate-limited to 3 requests per hour.

Complete Email Verification Page Example

'use client';
import { useState } from 'react';
import { client, setCustomerToken } from '@/lib/brainerce';
import { toast } from 'sonner';

export default function VerifyEmailPage() {
  const [code, setCode] = useState('');
  const [loading, setLoading] = useState(false);

  const handleVerify = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      const token = localStorage.getItem('verificationToken');
      if (!token) {
        toast.error('No verification token found. Please register again.');
        return;
      }

      const result = await client.verifyEmail(code, token);

      if (result.verified) {
        toast.success('Email verified!');
        setCustomerToken(token);
        localStorage.removeItem('verificationToken');
        // Redirect back to store
        const returnUrl = localStorage.getItem('returnUrl') || '/';
        localStorage.removeItem('returnUrl');
        window.location.href = returnUrl;
      }
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Verification failed');
    } finally {
      setLoading(false);
    }
  };

  const handleResend = async () => {
    try {
      const token = localStorage.getItem('verificationToken');
      if (!token) {
        toast.error('No verification token found');
        return;
      }
      await client.resendVerificationEmail(token);
      toast.success('Verification code sent!');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Failed to resend');
    }
  };

  return (
    <div className="max-w-md mx-auto mt-12">
      <h1 className="text-2xl font-bold mb-4">Verify Your Email</h1>
      <p className="text-gray-600 mb-6">
        We sent a 6-digit code to your email. Enter it below to verify your account.
      </p>

      <form onSubmit={handleVerify} className="space-y-4">
        <input
          type="text"
          placeholder="Enter 6-digit code"
          value={code}
          onChange={(e) => setCode(e.target.value)}
          maxLength={6}
          className="w-full border p-3 rounded text-center text-2xl tracking-widest"
          required
        />
        <button
          type="submit"
          disabled={loading || code.length !== 6}
          className="w-full bg-black text-white py-3 rounded disabled:opacity-50"
        >
          {loading ? 'Verifying...' : 'Verify Email'}
        </button>
      </form>

      <button onClick={handleResend} className="mt-4 text-blue-600 text-sm">
        Didn't receive the code? Resend
      </button>
    </div>
  );
}

Social Login (OAuth)

Allow customers to sign in with Google, Facebook, or GitHub. The store owner configures which providers are available in their Brainerce admin panel.

Check Available Providers

// Returns only the providers the store owner has enabled
const { providers } = await client.getAvailableOAuthProviders();
// providers = ['GOOGLE', 'FACEBOOK'] - varies by store configuration

OAuth Login Flow

Step 1: User clicks "Sign in with Google"

// Get authorization URL
const { authorizationUrl } = await client.getOAuthAuthorizeUrl('GOOGLE', {
  redirectUrl: window.location.origin + '/auth/callback', // Where to redirect after OAuth
});

// Redirect to Google
window.location.href = authorizationUrl;

Step 2: Create callback page (/auth/callback)

The backend handles the OAuth code exchange automatically and redirects to your callback page with URL params. You do not need to call handleOAuthCallback() — just read the token from the URL.

// app/auth/callback/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { setCustomerToken } from '@/lib/brainerce';

export default function AuthCallback() {
  const searchParams = useSearchParams();
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const oauthSuccess = searchParams.get('oauth_success');
    const token = searchParams.get('token');
    const oauthError = searchParams.get('oauth_error');

    // Check for OAuth errors (user cancelled, provider error, etc.)
    if (oauthError) {
      setError(oauthError);
      return;
    }

    if (oauthSuccess === 'true' && token) {
      // Save the customer token
      setCustomerToken(token);

      // Also available in URL params: customer_id, customer_email, is_new

      // Redirect to return URL or home
      const returnUrl = localStorage.getItem('returnUrl') || '/';
      localStorage.removeItem('returnUrl');
      window.location.href = returnUrl;
    } else {
      setError('Missing authentication parameters');
    }
  }, [searchParams]);

  if (error) {
    return (
      <div className="max-w-md mx-auto mt-12 text-center">
        <h1 className="text-xl font-bold text-red-600">Login Failed</h1>
        <p className="mt-2 text-gray-600">{error}</p>
        <a href="/login" className="mt-4 inline-block text-blue-600">
          Try again
        </a>
      </div>
    );
  }

  return (
    <div className="max-w-md mx-auto mt-12 text-center">
      <div className="animate-spin h-8 w-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
      <p className="mt-4 text-gray-600">Completing login...</p>
    </div>
  );
}

Login Page with Social Buttons

'use client';
import { useState, useEffect } from 'react';
import { client, setCustomerToken } from '@/lib/brainerce';

export default function LoginPage() {
  const [providers, setProviders] = useState<string[]>([]);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  // Load available OAuth providers
  useEffect(() => {
    client.getAvailableOAuthProviders()
      .then(({ providers }) => setProviders(providers))
      .catch(() => {}); // OAuth not configured - that's ok
  }, []);

  // Social login handler
  const handleSocialLogin = async (provider: string) => {
    try {
      // Session cart is preserved automatically via localStorage session token
      const { authorizationUrl, state } = await client.getOAuthAuthorizeUrl(
        provider as 'GOOGLE' | 'FACEBOOK' | 'GITHUB',
        { redirectUrl: window.location.origin + '/auth/callback' }
      );

      sessionStorage.setItem('oauthState', state);
      window.location.href = authorizationUrl;
    } catch (err) {
      setError('Failed to start login');
    }
  };

  // Regular email/password login
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');
    try {
      const auth = await client.loginCustomer(email, password);
      setCustomerToken(auth.token);
      const returnUrl = localStorage.getItem('returnUrl') || '/';
      localStorage.removeItem('returnUrl');
      window.location.href = returnUrl;
    } catch (err) {
      setError('Invalid email or password');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto mt-12">
      <h1 className="text-2xl font-bold mb-6">Login</h1>

      {/* Social Login Buttons */}
      {providers.length > 0 && (
        <div className="space-y-3 mb-6">
          {providers.includes('GOOGLE') && (
            <button
              onClick={() => handleSocialLogin('GOOGLE')}
              className="w-full flex items-center justify-center gap-2 border py-3 rounded hover:bg-gray-50"
            >
              <GoogleIcon />
              Continue with Google
            </button>
          )}
          {providers.includes('FACEBOOK') && (
            <button
              onClick={() => handleSocialLogin('FACEBOOK')}
              className="w-full flex items-center justify-center gap-2 bg-[#1877F2] text-white py-3 rounded"
            >
              <FacebookIcon />
              Continue with Facebook
            </button>
          )}
          {providers.includes('GITHUB') && (
            <button
              onClick={() => handleSocialLogin('GITHUB')}
              className="w-full flex items-center justify-center gap-2 bg-[#24292F] text-white py-3 rounded"
            >
              <GithubIcon />
              Continue with GitHub
            </button>
          )}
          <div className="relative my-4">
            <div className="absolute inset-0 flex items-center">
              <div className="w-full border-t" />
            </div>
            <div className="relative flex justify-center text-sm">
              <span className="px-2 bg-white text-gray-500">or</span>
            </div>
          </div>
        </div>
      )}

      {/* Email/Password Form */}
      {error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
      <form onSubmit={handleSubmit} className="space-y-4">
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="w-full border p-2 rounded"
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          className="w-full border p-2 rounded"
        />
        <button
          type="submit"
          disabled={loading}
          className="w-full bg-black text-white py-3 rounded"
        >
          {loading ? 'Logging in...' : 'Login'}
        </button>
      </form>

      <p className="mt-4 text-center">
        Don't have an account? <a href="/register" className="text-blue-600">Register</a>
      </p>
    </div>
  );
}

// Simple SVG icons (or use lucide-react)
const GoogleIcon = () => (
  <svg className="w-5 h-5" viewBox="0 0 24 24">
    <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
    <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
    <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
    <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
  </svg>
);

const FacebookIcon = () => (
  <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
    <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
  </svg>
);

const GithubIcon = () => (
  <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
    <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
  </svg>
);

Account Linking (Add Social Account to Existing User)

Logged-in customers can link additional social accounts to their profile:

// Get currently linked accounts
const connections = await client.getOAuthConnections();
// [{ provider: 'GOOGLE', email: 'user@gmail.com', linkedAt: '...' }]

// Link a new provider (redirects to OAuth flow)
const { authorizationUrl } = await client.linkOAuthProvider('GITHUB');
window.location.href = authorizationUrl;

// Unlink a provider
await client.unlinkOAuthProvider('GOOGLE');

Cart Linking After OAuth Login

When a customer logs in via OAuth, their guest cart should be linked to their account:

// client.linkCart() associates a guest cart with the logged-in customer
await client.linkCart(cartId);

This is automatically handled in the callback example above.

OAuth Method Reference

Method Description
getAvailableOAuthProviders() Get list of enabled providers for this store
getOAuthAuthorizeUrl(provider, options?) Get URL to redirect user to OAuth provider
handleOAuthCallback(provider, code, state) Exchange OAuth code for customer token
linkOAuthProvider(provider) Link social account to current customer
unlinkOAuthProvider(provider) Remove linked social account
getOAuthConnections() Get list of linked social accounts
linkCart(cartId) Link guest cart to logged-in customer

Customer Addresses

Get Addresses

const addresses = await client.getMyAddresses();

Add Address

const address = await client.addMyAddress({
  firstName: 'John',
  lastName: 'Doe',
  line1: '123 Main St',
  city: 'New York',
  region: 'NY',
  postalCode: '10001',
  country: 'US',
  isDefault: true,
});

Update Address

const updated = await client.updateMyAddress(addressId, {
  line1: '456 New Street',
});

Delete Address

await client.deleteMyAddress(addressId);

Store Info

const store = await client.getStoreInfo();

console.log(store.name); // Store name
console.log(store.currency); // 'USD', 'ILS', etc.
console.log(store.language); // 'en', 'he', etc.

Admin API Reference

The Admin API requires an API key (apiKey) and provides full access to store configuration and management features.

// Initialize in Admin mode
const client = new BrainerceClient({
  apiKey: process.env.BRAINERCE_API_KEY, // 'brainerce_*' prefix
});

Taxonomy Management

// Categories
const categories = await client.listCategories({ page: 1, limit: 20 });
const category = await client.getCategory('cat_id');
const newCategory = await client.createCategory({ name: 'Electronics', slug: 'electronics' });
await client.updateCategory('cat_id', { name: 'Updated Name' });
await client.deleteCategory('cat_id');

// Brands
const brands = await client.listBrands();
const brand = await client.createBrand({ name: 'Nike', slug: 'nike' });

// Tags
const tags = await client.listTags();
const tag = await client.createTag({ name: 'Sale', slug: 'sale' });

// Attributes (for variant options like Size, Color, Material)
const attributes = await client.listAttributes();

// Create a color swatch attribute
const colorAttr = await client.createAttribute({
  name: 'Color',
  displayType: 'COLOR_SWATCH', // Options: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' | 'MIXED_SWATCH'
  source: 'GLOBAL',
});

// Add color swatch options
await client.createAttributeOption(colorAttr.id, {
  name: 'Red',
  value: 'red',
  swatchColor: '#FF0000',
  source: 'GLOBAL',
});

// Gradient swatch (two colors)
await client.createAttributeOption(colorAttr.id, {
  name: 'Sunset',
  value: 'sunset',
  swatchColor: '#FF6B35',
  swatchColor2: '#FFD700',
  source: 'GLOBAL',
});

// Image swatch attribute
const materialAttr = await client.createAttribute({
  name: 'Material',
  displayType: 'IMAGE_SWATCH',
  source: 'GLOBAL',
});
await client.createAttributeOption(materialAttr.id, {
  name: 'Leather',
  value: 'leather',
  swatchImageUrl: 'https://example.com/leather-texture.jpg',
  source: 'GLOBAL',
});

// Mixed swatch attribute — each option independently uses color or image (image takes priority)
const finishAttr = await client.createAttribute({
  name: 'Finish',
  displayType: 'MIXED_SWATCH',
  source: 'GLOBAL',
});
await client.createAttributeOption(finishAttr.id, {
  name: 'Gold',
  value: 'gold',
  swatchColor: '#FFD700',
  source: 'GLOBAL',
});
await client.createAttributeOption(finishAttr.id, {
  name: 'Marble',
  value: 'marble',
  swatchImageUrl: 'https://example.com/marble.jpg',
  source: 'GLOBAL',
});

// Default attribute (text buttons/dropdown)
const sizeAttr = await client.createAttribute({
  name: 'Size',
  source: 'GLOBAL',
});
await client.createAttributeOption(sizeAttr.id, {
  name: 'Large',
  value: 'L',
  source: 'GLOBAL',
});

// Get options, update, delete
const options = await client.getAttributeOptions(colorAttr.id);
await client.updateAttribute(colorAttr.id, { displayType: 'IMAGE_SWATCH' });
await client.updateAttributeOption(colorAttr.id, options[0].id, { swatchColor: '#CC0000' });
await client.deleteAttributeOption(colorAttr.id, options[0].id);
await client.deleteAttribute(colorAttr.id);

// Storefront: render swatches from product data
import { getProductSwatches } from 'brainerce';

const product = await client.getProductBySlug('my-product');
const swatches = getProductSwatches(product);
// Returns: [{ attributeName: 'Color', displayType: 'COLOR_SWATCH', options: [{ name: 'Red', swatchColor: '#FF0000', ... }] }]

Shipping Configuration

// Shipping Zones
const zones = await client.listShippingZones();
const zone = await client.createShippingZone({
  name: 'US Domestic',
  countries: ['US'],
});

// Shipping Rates
const rates = await client.getZoneShippingRates('zone_id');
await client.createZoneShippingRate('zone_id', {
  name: 'Standard Shipping',
  type: 'flat',
  price: 5.99,
  estimatedDays: '3-5',
});

Tax Configuration

const rates = await client.getTaxRates();
await client.createTaxRate({
  name: 'CA Sales Tax',
  rate: 7.25,
  country: 'US',
  region: 'CA',
});

Metafield Definitions

// Define custom fields
const definitions = await client.getMetafieldDefinitions();
await client.createMetafieldDefinition({
  name: 'Care Instructions',
  key: 'care_instructions',
  type: 'multi_line_text',
});

// Set values on products
await client.setProductMetafield('prod_id', 'def_id', {
  value: 'Machine wash cold',
});

// Configure how this metafield syncs to external platforms
// (Shopify namespace+key, WooCommerce meta_key, TikTok attributeName, Meta custom_label_*)
await client.setMetafieldPlatforms('def_id', {
  publishedOn: ['SHOPIFY', 'WOOCOMMERCE'],
  platformMetadata: {
    SHOPIFY: { namespace: 'custom', key: 'care_instructions' },
    WOOCOMMERCE: { key: '_care_instructions' },
  },
});

Per-Channel Publishing (Categories / Tags / Brands / Custom Fields)

Each of these entities can be gated per vibe-coded site — i.e., merchants choose which storefronts see which categories, tags, brands, and custom fields. Mirrors the pattern already used by Products and Coupons.

Visibility semantics — explicit opt-in: an entity is visible to a vibe-coded site only if it has been explicitly published to that connection. Entities with no publish rows are invisible to every vibe-coded site, including in product responses (the related categories/brands/tags arrays and the metafields array on each product are filtered the same way). Merchants publish through the dashboard's per-row Platforms cell or via the admin SDK below.

// Publish a category to a specific vibe-coded site
await client.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
// Tag, brand, metafield definition follow the same shape:
await client.publishTagToVibeCodedSite('tag_id', 'conn_id');
await client.publishBrandToVibeCodedSite('brand_id', 'conn_id');
await client.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');

// Unpublish — entity is no longer visible to that site (but stays visible
// to every other site if no other publishes exist).
await client.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
await client.unpublishTagFromVibeCodedSite('tag_id', 'conn_id');
await client.unpublishBrandFromVibeCodedSite('brand_id', 'conn_id');
await client.unpublishMetafieldDefinitionFromVibeCodedSite('def_id', 'conn_id');

// Read which sites an entity is published to via the admin list/get response —
// `vibeCodedPublishes` is included on every list/get for these 4 entity types:
const cat = await client.getCategory('cat_id');
cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]

Cross-account isolation: publishing only succeeds when the entity and the target vibe-coded connection both belong to the same account. Cross-account calls fail with 404 Not Found (the connection ID is treated as if it doesn't exist for that account).

Store Team Management

Each store has its own team with roles (OWNER, MANAGER, STAFF, VIEWER) and granular permissions.

// List team members + pending invitations for a store
const { members, invitations } = await client.getStoreTeam('store_id');

// Invite a new member
const invitation = await client.inviteStoreMember('store_id', {
  email: 'newmember@example.com',
  role: 'MANAGER', // 'MANAGER' | 'STAFF' | 'VIEWER'
});

// Update member role or set custom permissions
await client.updateStoreMember('store_id', 'member_id', { role: 'STAFF' });
await client.updateStoreMember('store_id', 'member_id', {
  permissions: ['VIEW_PRODUCTS', 'VIEW_ORDERS', 'FULFILL_ORDERS'], // overrides role defaults
});

// Remove a member
await client.removeStoreMember('store_id', 'member_id');

// Manage invitations
await client.resendStoreInvitation('store_id', 'inv_id');
await client.revokeStoreInvitation('store_id', 'inv_id');

// Get all stores accessible to the current user (owned + shared)
const stores = await client.getMyStores();
// [{ id, name, role: 'OWNER', context: 'owner' }, { id, name, role: 'MANAGER', context: 'member' }]

// Get user's permissions for a specific store
const { role, permissions } = await client.getMyStorePermissions('store_id');

Note: The previous account-level team methods (getTeamMembers, inviteTeamMember, etc.) are deprecated. Use the store-level methods above instead.

Email Settings & Templates

// Get/update settings
const settings = await client.getEmailSettings();
await client.updateEmailSettings({
  emailsEnabled: true,
  defaultFromName: 'My Store',
  eventSettings: {
    ORDER_CONFIRMATION: { enabled: true, sendToCustomer: true },
  },
});

// Manage templates
const templates = await client.getEmailTemplates();
await client.createEmailTemplate({
  name: 'Order Confirmation',
  eventType: 'ORDER_CONFIRMATION',
  subject: 'Your order #{{orderNumber}}',
  htmlContent: '<h1>Thank you!</h1>...',
});

// Preview template
const preview = await client.previewEmailTemplate('template_id', {
  variables: { orderNumber: '1001' },
});

Sync Conflict Resolution

// Product sync conflicts
const conflicts = await client.getSyncConflicts();
await client.resolveSyncConflict('conflict_id', 'MERGE'); // or 'CREATE_NEW'

// Metafield conflicts
const metafieldConflicts = await client.getMetafieldConflicts();
await client.resolveMetafieldConflict('conflict_id', {
  resolution: 'USE_INCOMING', // 'KEEP_EXISTING' | 'USE_INCOMING' | 'MERGE'
});
await client.ignoreMetafieldConflict('conflict_id');

OAuth Provider Configuration

// Configure social login for customers
const providers = await client.getOAuthProviders();
await client.configureOAuthProvider({
  provider: 'GOOGLE',
  clientId: 'your-google-client-id',
  clientSecret: 'your-google-client-secret',
  isEnabled: true,
});
await client.updateOAuthProvider('GOOGLE', { isEnabled: false });
await client.deleteOAuthProvider('GOOGLE');

Modifier Groups Management

12 admin methods covering group / modifier / attachment CRUD. Money fields are decimal strings on the wire (priceDelta: "5.00"). Server-side validation failures arrive as a MODIFIER_VALIDATION_FAILED envelope on BrainerceError.details.

// Group CRUD
const groups = await client.listModifierGroups(storeId, {
  page: 1,
  limit: 20,
  search: 'pizza',
  status: 'active',
});
const group = await client.createModifierGroup(storeId, {
  name: 'Toppings',
  internalName: 'Pizza toppings', // admin-only — stripped from storefront
  selectionType: 'MULTIPLE',
  minSelections: 0,
  maxSelections: 8,
  freeQuantity: 3,
  freeAllocationPolicy: 'EXPENSIVE_FREE',
});
await client.updateModifierGroup(storeId, group.id, { freeQuantity: 4 });
await client.deleteModifierGroup(storeId, group.id);
// Soft-delete: updateModifierGroup(..., { status: 'archived' })

// Modifier CRUD (inside a group)
const olives = await client.createModifier(storeId, group.id, {
  name: 'Olives',
  priceDelta: '5.00', // string — negative ('-2.00') is valid for downsell
  isDefault: false,
  available: true,
});
await client.updateModifier(storeId, group.id, olives.id, { priceDelta: '6.00' });

// Sold-out flip (separate op — STAFF-level once granular permissions ship)
await client.toggleModifierAvailability(storeId, group.id, olives.id, false);
await client.deleteModifier(storeId, group.id, olives.id);

// Attach to a product
const attachment = await client.attachModifierGroup(storeId, productId, {
  modifierGroupId: group.id,
  position: 0,
});

// Per-variant override (e.g., Large pizza gets 4 free toppings instead of 3)
const override = await client.attachModifierGroup(storeId, productId, {
  modifierGroupId: group.id,
  variantId: 'var_large',
  freeQuantityOverride: 4,
});

// Disable a group entirely for a single variant (PRD §7.2.2 convention)
await client.attachModifierGroup(storeId, productId, {
  modifierGroupId: group.id,
  variantId: 'var_kids',
  maxOverride: 0, // 0 = group hidden for this variant
});

await client.updateAttachment(storeId, productId, attachment.id, { position: 1 });
await client.detachModifierGroup(storeId, productId, attachment.id);

null on an override means "inherit from the group default"; any non-null value (including 0 or false) wins. modifierGroupId and variantId are immutable on updateAttachment — to swap a group, detach and re-attach.


Complete Page Examples

Home Page

'use client';
import { useEffect, useState } from 'react';
import { client } from '@/lib/brainerce';
import type { Product } from 'brainerce';

export default function HomePage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function loadProducts() {
      try {
        const { data } = await client.getProducts({ limit: 8 });
        setProducts(data);
      } catch (err) {
        setError('Failed to load products');
      } finally {
        setLoading(false);
      }
    }
    loadProducts();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>{error}</div>;

  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
      {products.map((product) => (
        <a key={product.id} href={`/products/${product.slug}`} className="group">
          <img
            src={product.images?.[0]?.url || '/placeholder.jpg'}
            alt={product.name}
            className="w-full aspect-square object-cover"
          />
          <h3 className="mt-2 font-medium">{product.name}</h3>
          <p className="text-lg">
            {product.salePrice ? (
              <>
                <span className="text-red-600">${product.salePrice}</span>
                <span className="line-through text-gray-400 ml-2">${product.basePrice}</span>
              </>
            ) : (
              <span>${product.basePrice}</span>
            )}
          </p>
        </a>
      ))}
    </div>
  );
}

Products List with Pagination

'use client';
import { useEffect, useState } from 'react';
import { client } from '@/lib/brainerce';
import type { Product, PaginatedResponse } from 'brainerce';

export default function ProductsPage() {
  const [data, setData] = useState<PaginatedResponse<Product> | null>(null);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function load() {
      setLoading(true);
      try {
        const result = await client.getProducts({ page, limit: 12 });
        setData(result);
      } finally {
        setLoading(false);
      }
    }
    load();
  }, [page]);

  if (loading) return <div>Loading...</div>;
  if (!data) return <div>No products found</div>;

  return (
    <div>
      <div className="grid grid-cols-3 gap-6">
        {data.data.map((product) => (
          <a key={product.id} href={`/products/${product.slug}`}>
            <img src={product.images?.[0]?.url} alt={product.name} />
            <h3>{product.name}</h3>
            <p>${product.salePrice || product.basePrice}</p>
          </a>
        ))}
      </div>

      {/* Pagination */}
      <div className="flex gap-2 mt-8">
        <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
          Previous
        </button>
        <span>Page {data.meta.page} of {data.meta.totalPages}</span>
        <button onClick={() => setPage(p => p + 1)} disabled={page >= data.meta.totalPages}>
          Next
        </button>
      </div>
    </div>
  );
}

Product Detail with Add to Cart

'use client';
import { useEffect, useState } from 'react';
import { client } from '@/lib/brainerce';
import { isHtmlDescription, getVariantPrice, getVariantOptions, formatPrice } from 'brainerce';
import type { Product, ProductVariant, StoreInfo } from 'brainerce';

// Route: /products/[slug]/page.tsx - uses URL-friendly slug instead of ID
export default function ProductPage({ params }: { params: { slug: string } }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
  const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
  const [quantity, setQuantity] = useState(1);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function load() {
      try {
        const [p, info] = await Promise.all([
          client.getProductBySlug(params.slug),
          client.getStoreInfo(),
        ]);
        setProduct(p);
        setStoreInfo(info);
        // Auto-select first variant for VARIABLE products
        if (p.type === 'VARIABLE' && p.variants && p.variants.length > 0) {
          setSelectedVariant(p.variants[0]);
        }
      } finally {
        setLoading(false);
      }
    }
    load();
  }, [params.slug]);

  // Get the image to display (variant image takes priority)
  const getDisplayImage = () => {
    // Use variant-specific image if available
    if (selectedVariant?.image) {
      const img = selectedVariant.image;
      return typeof img === 'string' ? img : img.url;
    }
    // Fall back to product's main image
    return product?.images?.[0]?.url || '/placeholder.jpg';
  };

  // Get the price to display (variant price takes priority)
  const getDisplayPrice = () => {
    if (!product) return '0';
    if (selectedVariant) {
      return getVariantPrice(selectedVariant, product.basePrice).toString();
    }
    return product.salePrice || product.basePrice;
  };

  const handleAddToCart = async () => {
    if (!product) return;

    // Add to cart (works for both guests and logged-in users)
    await client.smartAddToCart({
      productId: product.id,
      variantId: selectedVariant?.id,
      quantity,
    });

    alert('Added to cart!');
  };

  if (loading) return <div>Loading...</div>;
  if (!product) return <div>Product not found</div>;

  return (
    <div className="grid grid-cols-2 gap-8">
      {/* Images - updates when variant changes */}
      <div>
        <img
          src={getDisplayImage()}
          alt={product.name}
          className="w-full"
        />
      </div>

      {/* Details */}
      <div>
        <h1 className="text-3xl font-bold">{product.name}</h1>
        {/* Price updates based on selected variant */}
        <p className="text-2xl mt-4">
          {formatPrice(getDisplayPrice(), { currency: storeInfo?.currency || 'USD' })}
        </p>

        {/* IMPORTANT: Use isHtmlDescription() to render HTML descriptions correctly */}
        {product.description && (
          isHtmlDescription(product) ? (
            <div className="mt-4 text-gray-600" dangerouslySetInnerHTML={{ __html: product.description }} />
          ) : (
            <p className="mt-4 text-gray-600">{product.description}</p>
          )
        )}

        {/* Variant Selection - shows attribute-based buttons */}
        {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
          <div className="mt-6">
            {/* Extract unique attribute names from all variants */}
            {(() => {
              const allOptions = product.variants.map(v => getVariantOptions(v));
              const attributeNames = [...new Set(allOptions.flatMap(opts => opts.map(o => o.name)))];

              return attributeNames.map(attrName => {
                const uniqueValues = [...new Set(
                  allOptions.flatMap(opts => opts.filter(o => o.name === attrName).map(o => o.value))
                )];
                const currentValue = getVariantOptions(selectedVariant).find(o => o.name === attrName)?.value;

                return (
                  <div key={attrName} className="mb-4">
                    <label className="block font-medium mb-2">
                      {attrName}: {currentValue}
                    </label>
                    <div className="flex gap-2">
                      {uniqueValues.map(value => (
                        <button
                          key={value}
                          onClick={() => {
                            // Find variant matching the selected attribute value
                            const match = product.variants?.find(v => {
                              const opts = getVariantOptions(v);
                              return opts.some(o => o.name === attrName && o.value === value);
                            });
                            if (match) setSelectedVariant(match);
                          }}
                          className={`px-4 py-2 border rounded ${
                            currentValue === value
                              ? 'bg-black text-white border-black'
                              : 'bg-white text-black border-gray-300 hover:border-black'
                          }`}
                        >
                          {value}
                        </button>
                      ))}
                    </div>
                  </div>
                );
              });
            })()}
          </div>
        )}

        {/* Quantity */}
        <div className="mt-4">
          <label className="block font-medium mb-2">Quantity</label>
          <input
            type="number"
            min="1"
            value={quantity}
            onChange={(e) => setQuantity(Number(e.target.value))}
            className="border rounded p-2 w-20"
          />
        </div>

        {/* Add to Cart Button */}
        <button
          onClick={handleAddToCart}
          disabled={adding}
          className="mt-6 w-full bg-black text-white py-3 rounded disabled:opacity-50"
        >
          {adding ? 'Adding...' : 'Add to Cart'}
        </button>

        {/* Stock Status - use inStock which handles UNLIMITED variants */}
        {product.inventory && (
          <p className="mt-4 text-sm">
            {product.inventory.inStock
              ? (product.inventory.trackingMode === 'UNLIMITED'
                  ? 'In Stock'
                  : `${product.inventory.available} in stock`)
              : 'Out of stock'}
          </p>
        )}
      </div>
    </div>
  );
}

Cart Page

'use client';
import { useState, useEffect } from 'react';
import { client } from '@/lib/brainerce';
import { getCartTotals, formatPrice } from 'brainerce';
import type { Cart } from 'brainerce';

export default function CartPage() {
  const [cart, setCart] = useState<Cart | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    client.smartGetCart().then(setCart).finally(() => setLoading(false));
  }, []);

  const updateQuantity = async (productId: string, quantity: number, variantId?: string) => {
    const updated = await client.smartUpdateCartItem(productId, quantity, variantId);
    setCart(updated);
  };

  const removeItem = async (productId: string, variantId?: string) => {
    const updated = await client.smartRemoveFromCart(productId, variantId);
    setCart(updated);
  };

  if (loading) return <div>Loading cart...</div>;
  if (!cart || cart.items.length === 0) {
    return (
      <div className="text-center py-12">
        <h1 className="text-2xl font-bold">Your cart is empty</h1>
        <a href="/products" className="text-blue-600 mt-4 inline-block">Continue Shopping</a>
      </div>
    );
  }

  const totals = getCartTotals(cart);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>

      {cart.items.map((item) => (
        <div key={item.id} className="flex items-center gap-4 py-4 border-b">
          <img
            src={item.product.images?.[0] || '/placeholder.jpg'}
            alt={item.product.name}
            className="w-20 h-20 object-cover"
          />
          <div className="flex-1">
            <h3 className="font-medium">{item.product.name}</h3>
            {item.variant && <p className="text-sm text-gray-500">{item.variant.name}</p>}
            <p className="font-bold">${item.unitPrice}</p>
          </div>
          <div className="flex items-center gap-2">
            <button
              onClick={() => updateQuantity(item.productId, item.quantity - 1, item.variantId ?? undefined)}
              className="w-8 h-8 border rounded"
            >-</button>
            <span className="w-8 text-center">{item.quantity}</span>
            <button
              onClick={() => updateQuantity(item.productId, item.quantity + 1, item.variantId ?? undefined)}
              className="w-8 h-8 border rounded"
            >+</button>
          </div>
          <button
            onClick={() => removeItem(item.productId, item.variantId ?? undefined)}
            className="text-red-600"
          >Remove</button>
        </div>
      ))}

      <div className="mt-6 text-right">
        <p className="text-xl">Subtotal: <strong>${totals.subtotal.toFixed(2)}</strong></p>
        {totals.discount > 0 && (
          <p className="text-green-600">Discount: -${totals.discount.toFixed(2)}</p>
        )}
        <a
          href="/checkout"
          className="mt-4 inline-block bg-black text-white px-8 py-3 rounded"
        >
          Proceed to Checkout
        </a>
      </div>
    </div>
  );
}

Checkout Page

RECOMMENDED: Use this unified pattern — the smart* methods handle both guest and logged-in users.

'use client';
import { useState, useEffect } from 'react';
import { client, isLoggedIn, restoreCustomerToken } from '@/lib/brainerce';
import type { Checkout, ShippingRate } from 'brainerce';

export default function CheckoutPage() {
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);
  const [checkout, setCheckout] = useState<Checkout | null>(null);
  const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
  const [selectedRate, setSelectedRate] = useState<string | null>(null);
  const customerLoggedIn = isLoggedIn();

  // Form state
  const [email, setEmail] = useState('');
  const [shippingAddress, setShippingAddress] = useState({
    firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
  });

  useEffect(() => {
    restoreCustomerToken();

    async function initCheckout() {
      try {
        // startGuestCheckout() creates checkout from session cart (for guests)
        // For logged-in users, create checkout from their server cart
        if (customerLoggedIn) {
          const cart = await client.smartGetCart();
          const co = await client.createCheckout({ cartId: cart.id });
          setCheckout(co);
        } else {
          const result = await client.startGuestCheckout();
          if (result.tracked) {
            const co = await client.getCheckout(result.checkoutId);
            setCheckout(co);
          }
        }
      } catch (err) {
        console.error('Failed to initialize checkout:', err);
      }
      setLoading(false);
    }

    initCheckout();
  }, []);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!checkout) return;
    setSubmitting(true);

    try {
      // 1. Set customer info
      await client.setCheckoutCustomer(checkout.id, {
        email,
        firstName: shippingAddress.firstName,
        lastName: shippingAddress.lastName,
      });

      // 2. Set shipping address
      await client.setShippingAddress(checkout.id, shippingAddress);

      // 3. Get and select shipping rate
      const rates = await client.getShippingRates(checkout.id);
      if (rates.length > 0) {
        await client.selectShippingMethod(checkout.id, selectedRate || rates[0].id);
      }

      // 4. Complete checkout
      const { orderId } = await client.completeCheckout(checkout.id);

      // 5. Clean up
      client.onCheckoutComplete();

      window.location.href = `/order-success?orderId=${orderId}`;
    } catch (error) {
      console.error('Checkout failed:', error);
      alert('Checkout failed. Please try again.');
    } finally {
      setSubmitting(false);
    }
  };

  if (loading) return <div>Loading checkout...</div>;

  return (
    <form onSubmit={handleSubmit}>
      {!customerLoggedIn && (
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
      )}
      <input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
      <input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
      <input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
      <input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
      <input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />

      {shippingRates.length > 0 && (
        <select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
          {shippingRates.map((rate) => (
            <option key={rate.id} value={rate.id}>{rate.name} - ${rate.price}</option>
          ))}
        </select>
      )}

      <button type="submit" disabled={submitting}>
        {submitting ? 'Processing...' : 'Place Order'}
      </button>
    </form>
  );
}

Key Points:

  • Both guests and logged-in users go through createCheckout()completeCheckout()
  • Guest session cart is created automatically by smart* methods
  • Call client.onCheckoutComplete() after successful payment to clear the session cart
  • Call client.syncCartOnLogin() when a user logs in to merge their guest cart

Multi-Step Checkout (Server Cart - For Logged-In Customers Only)

IMPORTANT: This checkout pattern is ONLY for logged-in customers. For a checkout page that handles both guests and logged-in customers, see the "Checkout Page" example above.

For logged-in users with server-side cart - orders will be linked to their account:

'use client';
import { useEffect, useState } from 'react';
import { client } from '@/lib/brainerce';
import type { Checkout, ShippingRate } from 'brainerce';

type Step = 'customer' | 'shipping' | 'payment' | 'complete';

export default function CheckoutPage() {
  const [checkout, setCheckout] = useState<Checkout | null>(null);
  const [step, setStep] = useState<Step>('customer');
  const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);

  // Form state
  const [email, setEmail] = useState('');
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [address, setAddress] = useState('');
  const [city, setCity] = useState('');
  const [postalCode, setPostalCode] = useState('');
  const [country, setCountry] = useState('US');

  useEffect(() => {
    async function initCheckout() {
      try {
        const cart = await client.smartGetCart();
        if (!cart || cart.items.length === 0) {
          window.location.href = '/cart';
          return;
        }
        const c = await client.createCheckout({ cartId: cart.id });
        setCheckout(c);
      } finally {
        setLoading(false);
      }
    }
    initCheckout();
  }, []);

  const handleCustomerSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!checkout) return;
    setSubmitting(true);
    try {
      await client.setCheckoutCustomer(checkout.id, { email, firstName, lastName });
      setStep('shipping');
    } finally {
      setSubmitting(false);
    }
  };

  const handleShippingSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!checkout) return;
    setSubmitting(true);
    try {
      const { rates } = await client.setShippingAddress(checkout.id, {
        firstName, lastName,
        line1: address,
        city, postalCode, country,
      });
      setShippingRates(rates);
      if (rates.length > 0) {
        await client.selectShippingMethod(checkout.id, rates[0].id);
      }
      setStep('payment');
    } finally {
      setSubmitting(false);
    }
  };

  const handleCompleteOrder = async () => {
    if (!checkout) return;
    setSubmitting(true);
    try {
      const { orderId } = await client.completeCheckout(checkout.id);
      setStep('complete');
    } catch (err) {
      alert('Failed to complete order');
    } finally {
      setSubmitting(false);
    }
  };

  if (loading) return <div>Loading checkout...</div>;
  if (!checkout) return <div>Failed to create checkout</div>;

  if (step === 'complete') {
    return (
      <div className="text-center py-12">
        <h1 className="text-3xl font-bold text-green-600">Order Complete!</h1>
        <p className="mt-4">Thank you for your purchase.</p>
        <a href="/" className="mt-6 inline-block text-blue-600">Continue Shopping</a>
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">Checkout</h1>

      {step === 'customer' && (
        <form onSubmit={handleCustomerSubmit} className="space-y-4">
          <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
          <div className="grid grid-cols-2 gap-4">
            <input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
            <input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
          </div>
          <button type="submit" disabled={submitting} className="w-full bg-black text-white py-3 rounded">
            {submitting ? 'Saving...' : 'Continue to Shipping'}
          </button>
        </form>
      )}

      {step === 'shipping' && (
        <form onSubmit={handleShippingSubmit} className="space-y-4">
          <input placeholder="Address" value={address} onChange={e => setAddress(e.target.value)} required className="w-full border p-2 rounded" />
          <div className="grid grid-cols-2 gap-4">
            <input placeholder="City" value={city} onChange={e => setCity(e.target.value)} required className="border p-2 rounded" />
            <input placeholder="Postal Code" value={postalCode} onChange={e => setPostalCode(e.target.value)} required className="border p-2 rounded" />
          </div>
          <select value={country} onChange={e => setCountry(e.target.value)} className="w-full border p-2 rounded">
            <option value="US">United States</option>
            <option value="IL">Israel</option>
            <option value="GB">United Kingdom</option>
          </select>
          <button type="submit" disabled={submitting} className="w-full bg-black text-white py-3 rounded">
            {submitting ? 'Calculating Shipping...' : 'Continue to Payment'}
          </button>
        </form>
      )}

      {step === 'payment' && (
        <div className="space-y-6">
          <div className="border p-4 rounded">
            <h3 className="font-bold mb-2">Order Summary</h3>
            <p>Subtotal: ${checkout.subtotal}</p>
            <p>Shipping: ${checkout.shippingAmount}</p>
            <p className="text-xl font-bold mt-2">Total: ${checkout.total}</p>
          </div>
          <button onClick={handleCompleteOrder} disabled={submitting} className="w-full bg-green-600 text-white py-3 rounded text-lg">
            {submitting ? 'Processing...' : 'Complete Order'}
          </button>
        </div>
      )}
    </div>
  );
}

Login Page

'use client';
import { useState, useEffect } from 'react';
import { client, setCustomerToken } from '@/lib/brainerce';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  // Save the page user came from (for redirect after login)
  useEffect(() => {
    const referrer = document.referrer;
    if (referrer && !referrer.includes('/login') && !referrer.includes('/register')) {
      const url = new URL(referrer);
      localStorage.setItem('returnUrl', url.pathname);
    }
  }, []);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');
    try {
      const auth = await client.loginCustomer(email, password);
      setCustomerToken(auth.token);

      // Redirect back to previous page or home (like Amazon/Shopify do)
      const returnUrl = localStorage.getItem('returnUrl') || '/';
      localStorage.removeItem('returnUrl');
      window.location.href = returnUrl;
    } catch (err) {
      setError('Invalid email or password');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto mt-12">
      <h1 className="text-2xl font-bold mb-6">Login</h1>
      {error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
      <form onSubmit={handleSubmit} className="space-y-4">
        <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
        <input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border p-2 rounded" />
        <button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
          {loading ? 'Logging in...' : 'Login'}
        </button>
      </form>
      <p className="mt-4 text-center">
        Don't have an account? <a href="/register" className="text-blue-600">Register</a>
      </p>
    </div>
  );
}

Register Page

'use client';
import { useState, useEffect } from 'react';
import { client, setCustomerToken } from '@/lib/brainerce';

export default function RegisterPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  // Save the page user came from (for redirect after registration)
  useEffect(() => {
    const referrer = document.referrer;
    if (referrer && !referrer.includes('/login') && !referrer.includes('/register')) {
      const url = new URL(referrer);
      localStorage.setItem('returnUrl', url.pathname);
    }
  }, []);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');
    try {
      const auth = await client.registerCustomer({ email, password, firstName, lastName });

      // Check if email verification is required
      if (auth.requiresVerification) {
        // Save token for verification step
        localStorage.setItem('verificationToken', auth.token);
        window.location.href = '/verify-email';
      } else {
        // No verification needed - redirect back to store (like Amazon/Shopify)
        setCustomerToken(auth.token);
        const returnUrl = localStorage.getItem('returnUrl') || '/';
        localStorage.removeItem('returnUrl');
        window.location.href = returnUrl;
      }
    } catch (err) {
      setError('Registration failed. Email may already be in use.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto mt-12">
      <h1 className="text-2xl font-bold mb-6">Create Account</h1>
      {error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
      <form onSubmit={handleSubmit} className="space-y-4">
        <div className="grid grid-cols-2 gap-4">
          <input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
          <input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
        </div>
        <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
        <input type="password" placeholder="Password (min 8 characters)" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} className="w-full border p-2 rounded" />
        <button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
          {loading ? 'Creating Account...' : 'Create Account'}
        </button>
      </form>
      <p className="mt-4 text-center">
        Already have an account? <a href="/login" className="text-blue-600">Login</a>
      </p>
    </div>
  );
}

Account Page

'use client';
import { useEffect, useState } from 'react';
import { client, restoreCustomerToken, setCustomerToken, isLoggedIn } from '@/lib/brainerce';
import type { CustomerProfile, Order } from 'brainerce';

export default function AccountPage() {
  const [profile, setProfile] = useState<CustomerProfile | null>(null);
  const [orders, setOrders] = useState<Order[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    restoreCustomerToken();
    if (!isLoggedIn()) {
      window.location.href = '/login';
      return;
    }

    async function load() {
      try {
        const [p, o] = await Promise.all([
          client.getMyProfile(),
          client.getMyOrders({ limit: 10 }),
        ]);
        setProfile(p);
        setOrders(o.data);
      } finally {
        setLoading(false);
      }
    }
    load();
  }, []);

  const handleLogout = () => {
    setCustomerToken(null);
    window.location.href = '/';
  };

  if (loading) return <div>Loading...</div>;
  if (!profile) return <div>Please log in</div>;

  return (
    <div>
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-2xl font-bold">My Account</h1>
        <button onClick={handleLogout} className="text-red-600">Logout</button>
      </div>

      <div className="grid md:grid-cols-2 gap-8">
        <div className="border rounded p-6">
          <h2 className="text-xl font-bold mb-4">Profile</h2>
          <p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
          <p><strong>Email:</strong> {profile.email}</p>
        </div>

        <div className="border rounded p-6">
          <h2 className="text-xl font-bold mb-4">Recent Orders</h2>
          {orders.length === 0 ? (
            <p className="text-gray-500">No orders yet</p>
          ) : (
            <div className="space-y-4">
              {orders.map((order) => (
                <div key={order.id} className="border-b pb-4">
                  <span className="font-medium">#{order.id.slice(-8)}</span>
                  <span className="ml-2 text-sm">{order.status}</span>
                  <p className="font-bold">${order.totalAmount}</p>
                </div>
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Header Component with Cart Count

'use client';
import { useState, useEffect } from 'react';
import { client, isLoggedIn } from '@/lib/brainerce';

export function Header() {
  const [cartCount, setCartCount] = useState(0);
  const [loggedIn, setLoggedIn] = useState(false);

  useEffect(() => {
    setLoggedIn(isLoggedIn());
    // Get cart count from session reference (no API call!)
    setCartCount(client.getSmartCartItemCount());
  }, []);

  return (
    <header className="flex justify-between items-center p-4 border-b">
      <a href="/" className="text-xl font-bold">Store Name</a>
      <nav className="flex gap-6 items-center">
        <a href="/products">Shop</a>
        <a href="/cart" className="relative">
          Cart
          {cartCount > 0 && (
            <span className="absolute -top-2 -right-2 bg-red-600 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
              {cartCount}
            </span>
          )}
        </a>
        {loggedIn ? (
          <a href="/account">Account</a>
        ) : (
          <a href="/login">Login</a>
        )}
      </nav>
    </header>
  );
}

Error Handling

import { BrainerceClient, BrainerceError } from 'brainerce';

try {
  const product = await client.getProduct('invalid_id');
} catch (error) {
  if (error instanceof BrainerceError) {
    console.error(`API Error: ${error.message}`);
    console.error(`Status Code: ${error.statusCode}`);
    console.error(`Details:`, error.details);
  }
}

Contact Inquiries & Forms

Submit contact-form messages from your storefront. Inquiries show up for the merchant at Customers → Inquiries in the dashboard; the merchant's reply is emailed back to the customer.

Simple (legacy — always supported):

await brainerce.createInquiry({
  name: 'Jane Doe',
  email: 'jane@example.com',
  subject: 'Do you ship to Canada?',
  message: 'Hi, I had a question about delivery...',
  phone: '+1-555-0100', // optional
});
// → { id, status: 'NEW', createdAt }

Flexible forms (SDK ≥ 1.21): merchants can configure multiple forms with custom fields and per-locale translations from the dashboard. Fetch the schema at render time; submit a keyed payload:

const form = await brainerce.contactForms.get('main', 'he');
// form.fields is the merchant-configured field list, localized.

await brainerce.createInquiry({
  formKey: 'main',
  fields: { email: 'jane@example.com', message: 'Hi!' },
  locale: 'he',
});

// List all configured forms when a site has more than one:
const forms = await brainerce.contactForms.list();
// → [{ key, name, isDefault }, ...]

Rate limit: 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it) — bots that auto-fill every input will be rejected.


Webhooks

Receive real-time updates when products, orders, or inventory change.

Setup Webhook Endpoint

// api/webhooks/brainerce/route.ts (Next.js App Router)
import { verifyWebhook, createWebhookHandler } from 'brainerce';

const handler = createWebhookHandler({
  'product.updated': async (event) => {
    console.log('Product updated:', event.entityId);
    // Invalidate cache, update UI, etc.
  },
  'inventory.updated': async (event) => {
    console.log('Stock changed:', event.data);
  },
  'order.created': async (event) => {
    console.log('New order from:', event.platform);
  },
});

export async function POST(req: Request) {
  const signature = req.headers.get('x-brainerce-signature');
  const body = await req.json();

  // Verify signature
  if (!verifyWebhook(body, signature, process.env.BRAINERCE_WEBHOOK_SECRET!)) {
    return new Response('Invalid signature', { status: 401 });
  }

  // Process event
  await handler(body);

  return new Response('OK');
}

Webhook Events

Event Description
product.created New product created
product.updated Product details changed
product.deleted Product removed
inventory.updated Stock levels changed
order.created New order received
order.updated Order status changed
cart.abandoned Cart abandoned (no activity)
checkout.completed Checkout completed successfully

TypeScript Support

All types are exported for full TypeScript support:

import type {
  // Products
  Product,
  ProductImage,
  ProductVariant,
  InventoryInfo,
  ProductQueryParams,
  PaginatedResponse,

  // Cart (All Users)
  Cart,
  CartItem,
  AddToCartDto,
  CreateGuestOrderDto,
  GuestOrderResponse,

  // Checkout
  Checkout,
  CheckoutStatus,
  ShippingRate,
  SetShippingAddressDto,

  // Customer
  Customer,
  CustomerProfile,
  CustomerAddress,
  CustomerAuthResponse,

  // Orders
  Order,
  OrderStatus,
  OrderItem,

  // Webhooks
  WebhookEvent,
  WebhookEventType,

  // Errors
  BrainerceError,
} from 'brainerce';

Environment Variables

# Required for vibe-coded sites
NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=vc_your_connection_id

# Optional: Override API URL (default: https://api.brainerce.com)
NEXT_PUBLIC_BRAINERCE_API_URL=https://api.brainerce.com

# For webhooks (server-side only)
BRAINERCE_WEBHOOK_SECRET=your_webhook_secret

Required Pages Checklist

When building a store, implement these pages:

  • Home (/) - Product grid
  • Products (/products) - Product list with pagination
  • Product Detail (/products/[slug]) - Single product with Add to Cart (use getProductBySlug(slug))
  • Cart (/cart) - Cart items, update quantity, remove
  • Checkout (/checkout) - Multi-step checkout flow
  • ⚠️ Payment (/checkout/payment) - REQUIRED! Use getPaymentProviders() to show Stripe/PayPal forms
  • Login (/login) - Customer login + social login buttons (Google/Facebook/GitHub if available)
  • Register (/register) - Customer registration + social signup buttons
  • Auth Callback (/auth/callback) - Handle OAuth redirects from Google/Facebook/GitHub
  • Verify Email (/verify-email) - Email verification with 6-digit code (if store requires it)
  • Account (/account) - Profile, addresses, and full order history (per-item customizations, shipping & tracking, payment status, status timeline)

⚠️ Payment Page is REQUIRED

Without a payment page, customers cannot complete orders! See Payment Integration for implementation.


Error Handling & Toast Notifications

For a polished user experience, use toast notifications to show success/error messages. We recommend Sonner - a lightweight toast library.

Setup Toast Notifications

npm install sonner

Add the Toaster component to your app layout:

// app/layout.tsx or App.tsx
import { Toaster } from 'sonner';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Toaster
          position="top-right"
          richColors
          closeButton
          toastOptions={{
            duration: 4000,
          }}
        />
      </body>
    </html>
  );
}

Handling SDK Errors

The SDK throws BrainerceError with helpful messages. Wrap SDK calls in try/catch:

import { toast } from 'sonner';
import { BrainerceError } from 'brainerce';

// Add to cart with toast feedback
const handleAddToCart = async (productId: string, quantity: number) => {
  try {
    await client.smartAddToCart({ productId, quantity });
    toast.success('Added to cart!');
  } catch (error) {
    if (error instanceof BrainerceError) {
      toast.error(error.message);
    } else {
      toast.error('Something went wrong');
    }
  }
};

// Checkout with toast feedback
const handleCheckout = async () => {
  try {
    const order = await client.submitGuestOrder();
    toast.success(`Order placed! Order #${order.orderNumber}`);
    // Navigate to success page
  } catch (error) {
    if (error instanceof BrainerceError) {
      // Show specific error message from SDK
      toast.error(error.message);
    } else {
      toast.error('Failed to place order. Please try again.');
    }
  }
};

Common Error Messages

Error When it occurs
Cart is empty Trying to checkout with empty cart
Customer email is required Missing email at checkout
Shipping address is required Missing shipping address
Product not found Invalid product ID
Insufficient inventory Not enough stock
Invalid quantity Quantity < 1 or > available

Custom Hook for SDK Operations (Optional)

Create a reusable hook for SDK operations with automatic toast handling:

// hooks/useBrainerceAction.ts
import { useState } from 'react';
import { toast } from 'sonner';
import { BrainerceError } from 'brainerce';

export function useBrainerceAction<T>() {
  const [isLoading, setIsLoading] = useState(false);

  const execute = async (
    action: () => Promise<T>,
    options?: {
      successMessage?: string;
      errorMessage?: string;
      onSuccess?: (result: T) => void;
    }
  ): Promise<T | null> => {
    setIsLoading(true);
    try {
      const result = await action();
      if (options?.successMessage) {
        toast.success(options.successMessage);
      }
      options?.onSuccess?.(result);
      return result;
    } catch (error) {
      const message =
        error instanceof BrainerceError
          ? error.message
          : options?.errorMessage || 'Something went wrong';
      toast.error(message);
      return null;
    } finally {
      setIsLoading(false);
    }
  };

  return { execute, isLoading };
}

// Usage:
const { execute, isLoading } = useBrainerceAction();

const handlePlaceOrder = () => {
  execute(() => client.submitGuestOrder(), {
    successMessage: 'Order placed successfully!',
    onSuccess: (order) => navigate(`/order/${order.orderId}`),
  });
};

Important Rules

DO:

  • Install brainerce and use it for ALL data
  • Import types from the SDK
  • Handle loading states and errors
  • Use toast notifications (Sonner) for user feedback on actions
  • Persist cart ID in localStorage
  • Persist customer token after login
  • Use isHtmlDescription(product) helper and render HTML with dangerouslySetInnerHTML when it returns true
  • Wrap SDK calls in try/catch and show error toasts

DON'T:

  • Create mock/hardcoded product data
  • Use localStorage for products
  • Skip implementing required pages
  • Write const products = [...] - use the API!
  • Use @apply group in CSS - Tailwind doesn't allow 'group' in @apply. Use className="group" on the element instead
  • Render product.description as plain text without using isHtmlDescription() - HTML will show as raw tags like <p>, <ul>, <li>!

License

MIT