← Files BrainerceARCHIVED FILE
skills/brainerce-sdk/references/sdk-products.md
19.7 KB · Oct 2, 2026 · 00:22 UTC
## Products & Variants
### Fetching Products
```typescript
const { data: products, meta } = await client.getProducts({ page: 1, limit: 20 });
const product = await client.getProductBySlug('product-slug'); // USE THIS for product detail pages
const product = await client.getProduct(productId);
// Search autocomplete (min 2 chars, 300ms debounce)
const suggestions = await client.getSearchSuggestions(query);
// Returns: { products: ProductSuggestion[], categories: CategorySuggestion[] }
// ⚠️ ProductSuggestion.slug can be null — use id as fallback key
// Filter products
const filtered = await client.getProducts({
categories: ['cat_123'], minPrice: 10, maxPrice: 100,
sortBy: 'price', sortOrder: 'asc',
});
// Filter by custom fields (metafields). Only fields the merchant marked
// `filterable: true` are honored; supported types are SELECT, MULTI_SELECT,
// BOOLEAN. AND across keys, OR within a key.
const byCustom = await client.getProducts({
metafields: { color: ['red', 'blue'], in_stock: ['true'] },
});
// Discover which custom fields are filterable for the current store:
const { definitions } = await client.getPublicMetafieldDefinitions();
const facets = definitions.filter(d => d.filterable);
// Render a checkbox group per SELECT/MULTI_SELECT, a switch per BOOLEAN.
// Facet values WITH product counts — "Color: red (12) / blue (3)" — in one
// call instead of one getProducts round trip per value:
const { filters } = await client.getMetafieldFilters();
// filters: [{ id, key, name, type, enumValues, values: [{ value, count }] }]
// Counts are DISTINCT active products; MULTI_SELECT arrays split per element;
// BOOLEAN buckets are 'true'/'false'; zero-count enumValues are included.
// Pair each key with getProducts({ metafields: { [key]: [value] } }).
```
### i18n — translated fields come back on every request
When `storeInfo.i18n.enabled` is true, call `client.setLocale(locale)` once
— all SDK calls return translated content automatically. No per-call
`{ locale }` params needed. Translated fields include: `name`,
`description`, `slug`, `seoTitle`, `seoDescription`, plus
`categories[].name`, `brands[].name`, `tags[].name`, `variants[].name`,
`variant.attributes` keys and values (so `getVariantOptions()` returns
translated attribute/option names), `productAttributeOptions[].attribute.name`/
`attributeOption.name`, `metafields[].value`, cart item product/variant names,
and recommendation/bundle/bump product names. No client-side overlay. See the
full `i18n` section (`get-sdk-docs` topic `i18n`) for the `[locale]` route
pattern, SDK setup, and the brand/tag badge examples.
### Price Display (Use SDK Helper!)
```typescript
const { price, originalPrice, isOnSale, discountPercent } = getProductPriceInfo(product);
if (isOnSale) {
return <><span className="text-red-600">{formatPrice(price.toString(), { currency: storeInfo.currency })}</span> <s>{formatPrice(originalPrice.toString(), { currency: storeInfo.currency })}</s> -{discountPercent}%</>;
}
return <span>{formatPrice(price.toString(), { currency: storeInfo.currency })}</span>;
```
### Variable Products — MUST switch image & price on variant change!
```typescript
import { getVariantOptions, getVariantPrice, formatPrice } from 'brainerce';
import type { Product, ProductVariant, StoreInfo } from 'brainerce';
function ProductPage({ product, storeInfo }: { product: Product; storeInfo: StoreInfo }) {
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
product.type === 'VARIABLE' && product.variants?.length ? product.variants[0] : null
);
// ✅ Image MUST update when variant changes!
const displayImage = (() => {
if (selectedVariant?.image) {
const img = selectedVariant.image;
return typeof img === 'string' ? img : img.url; // variant.image can be string OR {url, thumbnailUrl?}
}
return product.images?.[0]?.url || '/placeholder.jpg';
})();
// ✅ Price MUST update when variant changes!
const displayPrice = selectedVariant
? getVariantPrice(selectedVariant, product.basePrice).toString()
: product.salePrice || product.basePrice;
// ✅ For catalog cards: use pre-computed priceMin/priceMax instead of iterating variants
// product.priceMin / product.priceMax are always set for VARIABLE products with explicit variant prices.
// product.priceVaries is true when the range should be shown as "₪49 – ₪199".
// Build attribute buttons (size, color, etc.)
const allOptions = product.variants?.map(v => getVariantOptions(v)) || [];
const attrNames = [...new Set(allOptions.flatMap(opts => opts.map(o => o.name)))];
return (
<div>
<img src={displayImage} alt={product.name} />
<p>{formatPrice(displayPrice, { currency: storeInfo.currency })}</p>
{product.type === 'VARIABLE' && attrNames.map(attrName => {
const uniqueValues = [...new Set(allOptions.flatMap(opts => opts.filter(o => o.name === attrName).map(o => o.value)))];
const currentValue = selectedVariant ? getVariantOptions(selectedVariant).find(o => o.name === attrName)?.value : undefined;
return (
<div key={attrName}>
<label>{attrName}: {currentValue}</label>
<div className="flex gap-2">
{uniqueValues.map(value => (
<button key={value} onClick={() => {
const match = product.variants?.find(v => getVariantOptions(v).some(o => o.name === attrName && o.value === value));
if (match) setSelectedVariant(match);
}} className={currentValue === value ? 'bg-black text-white' : 'border'}>
{value}
</button>
))}
</div>
</div>
);
})}
</div>
);
}
```
### Variant Attribute Selector with Swatches (color + size **mix**)
The simple text-button picker above works, but loses the merchant-configured
**swatch metadata**. A single product often mixes one attribute that should
render as **color swatches** (e.g. `Color`) and another that should render
as **text buttons** (e.g. `Size`). The product response includes everything
you need:
- `product.productAttributeOptions[]` — the swatch definitions: `attribute.name`,
`attribute.displayType` (the `AttributeDisplayType` enum: `DEFAULT` | `COLOR_SWATCH` | `IMAGE_SWATCH` | `MIXED_SWATCH`),
`attributeOption.name`, `swatchColor` (hex), `swatchColor2` (hex — used for dual-color swatches
like Black & White), `swatchImageUrl` (for fabric / pattern thumbnails).
`MIXED_SWATCH` means each option in the group can independently pick image OR color
(the merchant decides per option). Treat any unrecognized value as `DEFAULT`.
- `variant.attributes` — the selected value per attribute on each variant.
Use `getProductSwatches(product)` to get a render-ready, grouped structure:
```typescript
import { getProductSwatches } from 'brainerce';
const groups = getProductSwatches(product);
// [
// { attributeName: 'color', displayType: 'COLOR_SWATCH', options: [
// { name: 'Full Color', swatchColor: '#1D9435', swatchColor2: null, swatchImageUrl: null },
// { name: 'Black & White', swatchColor: '#000000', swatchColor2: '#FFFFFF', swatchImageUrl: null },
// ]},
// { attributeName: 'size', displayType: 'DEFAULT', options: [
// { name: '16" × 20"' }, { name: '24" × 32"' }, { name: '36" × 48"' },
// ]},
// ]
```
Render one branch per `displayType`. The same loop handles all-swatch, all-text,
and the mix case:
```typescript
function VariantPicker({
product,
selections,
onSelect,
}: {
product: Product;
selections: Record<string, string>; // { color: 'Full Color', size: '24" × 32"' }
onSelect: (attrName: string, value: string) => void;
}) {
const groups = getProductSwatches(product);
if (groups.length === 0) return null;
return (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.attributeName}>
<label className="block mb-2 text-sm font-medium">
{group.attributeName}: <span className="text-gray-500">{selections[group.attributeName] ?? ''}</span>
</label>
<div className="flex flex-wrap gap-2">
{group.options.map((opt) => {
const isSelected = selections[group.attributeName] === opt.name;
// COLOR_SWATCH — round button; dual-color uses a 50/50 gradient.
if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
const bg = opt.swatchColor2
? `linear-gradient(135deg, ${opt.swatchColor} 50%, ${opt.swatchColor2} 50%)`
: opt.swatchColor;
return (
<button
key={opt.name}
type="button"
title={opt.name}
aria-pressed={isSelected}
onClick={() => onSelect(group.attributeName, opt.name)}
className={`h-9 w-9 rounded-full border-2 ${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}`}
style={{ background: bg }}
/>
);
}
// IMAGE_SWATCH — square thumbnail (fabric/pattern preview).
if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
return (
<button
key={opt.name}
type="button"
aria-pressed={isSelected}
onClick={() => onSelect(group.attributeName, opt.name)}
className={`h-10 w-10 overflow-hidden rounded border-2 ${isSelected ? 'border-black' : 'border-gray-300'}`}
>
<img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
</button>
);
}
// MIXED_SWATCH — each option is independently image OR color (image wins if present).
if (group.displayType === 'MIXED_SWATCH') {
if (opt.swatchImageUrl) {
return (
<button
key={opt.name}
type="button"
aria-pressed={isSelected}
onClick={() => onSelect(group.attributeName, opt.name)}
className={`h-10 w-10 overflow-hidden rounded border-2 ${isSelected ? 'border-black' : 'border-gray-300'}`}
>
<img src={opt.swatchImageUrl} alt={opt.name} className="h-full w-full object-cover" />
</button>
);
}
if (opt.swatchColor) {
const bg = opt.swatchColor2
? `linear-gradient(135deg, ${opt.swatchColor} 50%, ${opt.swatchColor2} 50%)`
: opt.swatchColor;
return (
<button
key={opt.name}
type="button"
title={opt.name}
aria-pressed={isSelected}
onClick={() => onSelect(group.attributeName, opt.name)}
className={`h-9 w-9 rounded-full border-2 ${isSelected ? 'border-black ring-2 ring-black/30' : 'border-gray-300 hover:border-black'}`}
style={{ background: bg }}
/>
);
}
// option has neither image nor color → fall through to text button below.
}
// DEFAULT (or any swatch type missing its data) — plain text button.
return (
<button
key={opt.name}
type="button"
aria-pressed={isSelected}
onClick={() => onSelect(group.attributeName, opt.name)}
className={`rounded border px-3 py-1.5 text-sm ${isSelected ? 'bg-black text-white border-black' : 'border-gray-300 hover:border-black'}`}
>
{opt.name}
</button>
);
})}
</div>
</div>
))}
</div>
);
}
```
Wire it up in the product page: keep `selections` in state, find the matching
variant after each pick, and update price / image / stock from it.
```typescript
const [selections, setSelections] = useState<Record<string, string>>({});
const matchedVariant = useMemo(() => (
product.variants?.find((v) =>
Object.entries(selections).every(([k, val]) => v.attributes?.[k] === val)
) ?? null
), [product.variants, selections]);
// Greying-out unavailable combinations: for each candidate value, run
// the same find() and check `inventory.canPurchase`. Disable the button
// if no purchasable variant exists under that selection.
```
**i18n:** `getProductSwatches` returns translated `attributeName` and
`option.name` automatically when `client.setLocale()` is active. No
client-side overlay needed. Swatch colors are language-agnostic.
### Description Rendering (handles HTML vs text)
```typescript
function ProductDescription({ product }: { product: Product }) {
const content = getDescriptionContent(product);
if (!content) return null;
// Descriptions are merchant HTML and may contain <video> + host-locked
// YouTube/Vimeo <iframe> embeds. NEVER render unsanitized — sanitize and
// allowlist video/source + iframe (restricted to youtube/vimeo hosts).
if ('html' in content) {
return <div dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(content.html) }} />;
}
return <p>{content.text}</p>;
}
```
**Sanitize descriptions.** `sanitizeProductHtml` is the host-locked sanitizer the
`create-brainerce-store` scaffold ships at `src/lib/sanitize-html.ts`. If you roll
your own, allow `video`/`source` and `iframe` **restricted** to `www.youtube.com`,
`www.youtube-nocookie.com`, `player.vimeo.com` (never arbitrary iframes), and add
those hosts to your CSP `frame-src` or embeds render blank.
### Metafields (Custom Product Fields) — display on product detail!
**IMPORTANT: Render metafield values based on `mf.type`!** Don't just display `mf.value` as text for all types.
```typescript
import { getProductMetafieldValue } from 'brainerce';
import type { ProductMetafield } from 'brainerce';
// Type-aware renderer — MUST switch on mf.type!
function MetafieldValue({ field }: { field: ProductMetafield }) {
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 urls.length > 0 ? <div className="flex gap-2">{urls.map((url, i) => <img key={i} src={url} alt={`${field.definitionName} ${i+1}`} className="h-16 w-16 rounded object-cover" />)}</div> : <>-</>;
}
case 'URL':
return field.value ? <a href={field.value} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{field.value}</a> : <>-</>;
case 'COLOR':
return field.value ? <span className="inline-flex items-center gap-2"><span className="inline-block h-4 w-4 rounded-full border" style={{ backgroundColor: field.value }} />{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: // TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON
return <>{field.value || '-'}</>;
}
}
// Display all custom fields as a spec table
{product.metafields?.length > 0 && (
<table>
{product.metafields.map(mf => (
<tr key={mf.id}>
<td className="font-medium">{mf.definitionName}</td>
<td><MetafieldValue field={mf} /></td>
</tr>
))}
</table>
)}
// Or get a specific field by key
const material = getProductMetafieldValue(product, 'material');
```
**Metafield fields:** `definitionName` (display label), `definitionKey` (lookup key), `value`, `type` (IMAGE, GALLERY, URL, COLOR, BOOLEAN, DATE, DATETIME, TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON)
**Translations:** When the store has i18n enabled and `client.setLocale()` is active, both the `definitionName` (the merchant-authored label like "Warranty Info" / "מידע אחריות") **and** free-text `value` come back already translated. No per-call locale param needed.
### Product Customization Fields (Customer Input)
Some products allow customers to provide input at purchase time (e.g., text on a cake, upload a logo). Check `product.customizationFields` and render input fields accordingly.
Merchants can also flag a `MetafieldDefinition` as `appliesToAllProducts: true` — those fields are folded into every product's `customizationFields` array automatically by the backend. Client code does not need to merge or union anything: always render exactly what's in `product.customizationFields`.
```typescript
import { getProductCustomizationFields } from 'brainerce';
import type { ProductCustomizationField } from 'brainerce';
const fields = getProductCustomizationFields(product);
// Render input for each field based on field.type:
// TEXT/TEXTAREA → text input, NUMBER → number input, BOOLEAN → checkbox,
// COLOR → color picker, DATE → date picker, IMAGE → file upload, etc.
// Check field.required, field.minLength, field.maxLength, field.enumValues for validation.
// Pass customer values in metadata when adding to cart:
await client.addToCart(cartId, {
productId: product.id,
quantity: 1,
metadata: { cake_text: 'Happy Birthday!' },
});
// For IMAGE fields, upload first:
const { url } = await client.uploadCustomizationFile(file);
// Then use the URL in metadata: metadata: { logo: url }
```
**Customization field properties:** `key`, `name` (display label), `type`, `required`, `minLength`, `maxLength`, `minValue`, `maxValue`, `enumValues` (array of `{label, value, swatchColor?, swatchImageUrl?}`), `defaultValue`, `position`
**SELECT / MULTI_SELECT — use `option.value` to submit, `option.label` to display:**
```typescript
for (const option of field.enumValues ?? []) {
// option.value → what to submit in metadata
// option.label → what to show the customer
// option.swatchColor → optional hex color for color swatches
}
```
**Display customizations in cart / checkout — no extra API call needed:**
CartItem and CheckoutLineItem both include a `customizations` object alongside `metadata`. Use `customizations` for display — it contains resolved labels, not raw keys.
```typescript
// Works for CartItem, CheckoutLineItem, and OrderItem — same shape
const lines = Object.values(item.customizations ?? {})
.map(c => `${c.label}: ${Array.isArray(c.value) ? c.value.join(', ') : c.value}`);
// e.g. ["Frame color: Gold", "Add-ons: Gift wrap"]
```
### Downloadable / Digital Products
Products with `isDownloadable: true` are digital products. Show a digital badge instead of stock badge, list included files, and note that download links appear after purchase.
```typescript
import type { DownloadFile } from 'brainerce';
// Check if product is digital
if (product.isDownloadable) {
// Show "Instant Download" badge instead of StockBadge
// List files: product.downloads?.map((file: DownloadFile) => file.name)
// Show "Download available after purchase" note
}
// After purchase — get download links from order
const downloads = await client.getOrderDownloads(orderId);
// downloads[]: { fileName, downloadUrl, downloadsUsed, downloadLimit, expiresAt }
```
### Categories
```typescript
const { categories } = await client.getCategories();
// Product.categories is Array<{id, name, slug?}> — NOT string[]
product.categories?.map(cat => cat.name);
// Use cat.slug to link to that category's landing page: /category/{slug}
```SHA-256: ed80390d14a7094c8b0a875733f70fcc7951d0e9d8baa6d2cd59d173b8f52321