3U

Headless Themes (Advanced)

Own every pixel: normalized domain models, the useProduct controller, an adapter-based cart, and capability-gated pages.

The Principle

ThreeU is headless-first: the SDK owns data and commerce behavior; your theme owns markup and presentation. No connected component (TProductCard, TCheckoutForm) is ever the required way to build a page — each one only composes publicly-exported hooks you can use directly. Reach for the hooks and you can build a 3D product viewer, an Apple-style scroll story, or a brutalist catalog from the same data that powers a starter theme.

Ownership never changes: ThreeU owns the infrastructure, the brand owns the products/prices/inventory, and you own the experience.

Normalized Domain Models

Catalog hooks return stable `StorefrontProduct` models, not raw brand rows. The SDK's normalization layer maps the brand's messy DB shape (flat ar_name, discount_price, fixed color/size variants, {product} envelopes) onto a clean contract — so your theme never couples to the schema and keeps working when the backend evolves.

StorefrontProduct {
  id, slug, type,
  translations: { en: {name, description, tags}, ar: {…} },
  images: [{ url, alt }],
  pricing: { amount, compareAtAmount?, currency, onSale },
  inventory: { available, quantity? },
  options: [{ name, values: [{ value, available }] }],
  variants: [{ id, sku, selections, pricing, inventory, images }],
}

Pricing follows the server rule exactly (amount = discount_price ?? price), so the SDK and checkout never disagree. Import the models from threeu-sdk/theme (or threeu-sdk/domain).

The useProduct Controller

useProduct({ id | slug }) fetches a normalized product, tracks option selections → resolves the matching variant, and manages quantity. It owns no DOM and performs no cart mutation — you render everything. Note the split: product is the canonical, locale-independent model; localized is the resolved view for the active locale.

Why no monolithic component

A single <TProduct/> would decide DOM structure, layout, animation boundaries, and variant placement — a creativity ceiling. The controller hook gives you the commerce logic (valid variant, server-checked price, inventory) while you design freely.

Fully custom product page over useProduct
TSX
import { useProduct, useAddToCart, TPrice } from "threeu-sdk/theme";

function ProductPage({ slug }) {
  const p = useProduct({ slug });
  const addToCart = useAddToCart(p);

  if (p.status === "loading" || !p.localized) return <CustomSkeleton />;

  return (
    <motion.article>
      <motion.h1>{p.localized.name}</motion.h1>
      <ImmersiveGallery images={p.selectedVariant?.images ?? p.localized.images} />
      {p.options.map((opt) => (
        <ExperimentalSwatch key={opt.name} option={opt}
          value={p.selections[opt.name]} onSelect={(v) => p.selectOption(opt.name, v)} />
      ))}
      <TPrice pricing={p.selectedVariant?.pricing ?? p.product.pricing} />
      <QuantityOrb value={p.quantity} onChange={p.setQuantity} />
      <motion.button onClick={() => addToCart()}>Add to cart</motion.button>
    </motion.article>
  );
}

Adapter-Based Cart

useCart() is storage-agnostic. Today the guest cart is client-side (the checkout takes an items[] payload); tomorrow it may be a server or cross-device customer cart. Your theme never learns the difference — it calls addItem / updateQuantity / removeItem / clear. Cart mutations live in `useCart()`, never in `useProduct()` (so bundles and service items share one cart, and product pages work without cart support). Totals are optimistic; money is authoritative only at checkout.

Cart over a swappable adapter
TSX
import { useCart } from "threeu-sdk/theme";

function MiniCart() {
  const { cart, updateQuantity, removeItem } = useCart();
  return (
    <aside>
      <span>{cart.itemCount} items · {cart.subtotal}</span>
      {cart.items.map((it) => (
        <div key={it.id}>
          {it.name}
          <input type="number" value={it.quantity}
            onChange={(e) => updateQuantity(it.id, Number(e.target.value))} />
          <button onClick={() => removeItem(it.id)}>×</button>
        </div>
      ))}
    </aside>
  );
}

// Inject a custom adapter (e.g. a future server cart) via the provider:
// <ThemeProvider cartAdapter={myServerAdapter}>…</ThemeProvider>

Capability-Based Manifest

Declare the capabilities your theme supports instead of hard-coding page logic. A capability is effective only when three independent signals agree:

effective = themeSupports && brandEnabled && platformAvailable

A theme declaring booking: true does not mean every brand sells bookings — the platform gates the route on all three. Brand enablement is derived from the storefront config (brandCapabilitiesFromConfig).

Capabilities in defineTheme
TSX
import { defineTheme } from "threeu-sdk/theme";

export default defineTheme({
  name: "Aurora", slug: "aurora",
  pages: { home: Home, product: Product, checkout: Checkout },
  capabilities: {
    catalog: { required: true, features: { variants: true, filters: true } },
    checkout: { required: true, modes: ["guest"] },
    services: { required: false, features: { booking: true } },
    customerAccount: false,
  },
});

Primitives vs. Recipes

Two classes of component, and the distinction is a rule:

  • Primitives are stable building blocks that solve platform concerns without dictating layout: editor-aware (TText, THeading, TImage, TColor) and functional (TPrice, TMoney, TLocalizedText, TJsonLd). Use them anywhere.
  • Recipes are optional convenience components (TProductCard, TCartSummary, TCheckoutForm) — replaceable examples, never the required path. Anything a recipe does is reachable through hooks.

Rule: no connected component may be the exclusive access point for any capability. TCheckoutForm exposes nothing useCheckout() does not.