Data model
Every object the contract uses. Types are the JSDoc definitions in src/lib/api/contracts.js, which is also where the runtime validation lives.
Money
{ amount: number, currency: string }amount is an integer of the currency's smallest unit. 12800 with "USD" is $128.00.
Never a float. 0.1 + 0.2 !== 0.3 in binary floating point, and a cart summed in floats is eventually a cent out on an invoice. This is also what every payment provider expects — Stripe's unit_amount, Razorpay's amount — so the value passes straight through with no conversion.
Zero-decimal currencies (JPY, KRW, VND, CLP, ISK) use whole units. Handled in src/lib/money.js.
Image
{ id?: string, url: string, alt: string, width?: number, height?: number, color?: string }alt is required. An empty string is a bug, not a styling choice. width and height prevent layout shift and should be sent when known.
color tags a shot with an option value, so the gallery follows the colour picker. id is what Variant.imageId references as the fallback.
Product
{
id, slug, title, subtitle, description: string
details: string[] // construction notes
care: string[]
price: Money // lowest variant price, for listings
compareAtPrice: Money | null
images: Image[] // at least two — the grid swaps on hover
options: { name: string, values: string[] }[]
swatches: Record<string, string> // colour name → hex
variants: Variant[] // source of truth for stock
categories: string[] // leaf and ancestors
tags: string[]
rating: { average: number, count: number }
badges: ('new'|'sale'|'bestseller'|'low-stock'|'sold-out')[]
published: boolean // false hides it from every storefront read
createdAt: string // ISO 8601
fit?: Fit
fabric?: Fabric
sizeChartId?: string | null // reference to a shared chart
sizeChart?: SizeChart // resolved from sizeChartId on read
social?: Social
enrichment?: Enrichment
relatedSlugs?: string[] // ordered; used only by the `manual` rail
}`images[].color` is what makes the gallery follow the picker. An image tagged with a colour shows only when that colour is selected; an untagged one — a fabric crop, a packshot, the size guide — belongs to every colourway and always shows. Point each variant's imageId at its own colour's first shot and the main image changes with the swatch, with no code involved.
A store with one set of photographs simply tags nothing, and sees what it saw before.
Variant
{
id, sku: string
options: Record<string, string> // { Color: "Oat", Size: "M" }
price: Money
compareAtPrice: Money | null
inventory: number
available: boolean // always derived from inventory > 0
imageId: string | null // shown when this variant is selected
}Stock lives here, not on the product. The size picker greys out sizes that are out of stock in the selected colour, which is only expressible per variant.
A sold-out product returns its variants with available: false — never an empty array, or the page has no picker and nothing to add.
The matrix is allowed to be sparse. Not every colour comes in every size, so a product with four colours and four sizes may legitimately have fewer than sixteen variants. The storefront renders three states per size in the selected colour: available, sold out (struck through), and not made (dashed outline). Collapsing the last two into one is a common mistake and it answers a shopper's question wrongly — one is worth waiting for, the other is not.
Fit
{
verdict: 'true-to-size' | 'runs-small' | 'runs-large' | null
feedback: { small: number, true: number, large: number } | null // percentages
sample: number
note: string
model: { height: number, size: string, label: string } | null // height in cm
}feedback comes from purchasers, not from the merchant. Omit it rather than invent it — see CRO.md.
Fabric
{
composition: [string, number][] // [["Merino wool", 100]]
weight: number | null // gsm
weave: string
origin: string
certifications: string[] // third-party marks only
}SizeChart
{
id: string
unit: 'cm' | 'in'
note: string
columns: string[] // first column is the size label
rows: (string | number)[][]
}Garment measurements laid flat, not body measurements. A letter size does not transfer between brands; a chest measurement does.
Social
{ unitsAvailable: number, boughtLast30Days: number, savedCount: number }Real counts. Below trust.socialProofThresholds the theme renders nothing — see CONFIGURATION.md.
Enrichment
{
highlights: { key: string, value: string }[] // ordered; first 6 render
features: { icon: string, title: string, body: string }[]
assurances?: { icon: string, label: string, note?: string }[]
maker?: { name: string, location?: string } // renders inside `manufacturer`
specs: Record<string, string> // flat; grouped on read
manufacturer?: {
genericName?, countryOfOrigin?, manufacturer?,
packer?, importer?, netQuantity?, packOf?: string
}
}highlights is an array because order is editorial and a JSON object does not guarantee it. specs is a map because grouping and ordering are derived from the attribute vocabulary on read — a backend never stores presentation order.
features[].icon is a name from GET /attributes → icons, or a URL.
manufacturer is compliance rather than marketing: India's Legal Metrology rules require the manufacturer and packer address, the country of origin and the net quantity on an e-commerce listing.
assurances is the services block under the buy button — returns, exchange, repair, payment. It answers *what happens if this is wrong*, which for apparel is usually the last question standing between a considered shopper and the button; the specification table answers *is this the right thing*, and the two are not interchangeable. note renders behind an (i) rather than in the row, because a returns policy printed in full is four lines of prose next to a call to action.
Omit it and the product inherits `storefront.trust.assurances`. Most stores have one returns policy. Sending an empty array is not the same thing — an empty array means *this product has none*, and the block renders nothing.
maker names the mill, and renders as the first two rows of manufacturer rather than as a block of its own. It started as a marketplace seller card — name, rating, years — and that was the wrong shape twice over. On an own-brand store the seller is never in doubt, so the card had nothing to reassure anyone about; and a rating for a supplier nobody can review is a number somebody typed, which makes the review count and the stock level look typed too.
What is left is the part that was actually information: who wove the cloth and where. It sits with the compliance rows because that is where a shopper already looks for manufacturing facts, and because the address on those rows is the *brand's* — so the mill is new information rather than a second version of the same one.
Keep `maker.location` consistent with `fabric.origin`. They render four rows apart. A mill in one country beside a country of origin in another is the kind of contradiction a shopper only has to notice once.
Attribute
{
key: string // stored on the product
label: string // shown to a shopper
group: string // which specifications section
unit?: string // appended to the label in brackets
highlight?: boolean // offered first when editing highlights
values?: string[] // suggested values
}Suggestions, not a schema. See API.md.
Cart and CartLine
CartLine {
id, variantId, productSlug, title: string
options: Record<string, string>
image: Image
quantity: number
unitPrice: Money
lineTotal: Money
}
Cart {
id: string
currency: string
lines: CartLine[]
subtotal, discount, shipping, tax, total: Money
discountCode: { code: string, label: string } | null
freeShippingThreshold?: Money
freeShippingRemaining?: Money
}The cart is server-owned. Every mutation returns the whole repriced cart.
Order
{
id, number, status: string // placed|paid|fulfilled|delivered|cancelled
placedAt: string
lines: CartLine[]
subtotal, discount, shipping, tax, total: Money
shippingAddress: Address
shippingMethod: string
email: string
tracking: { carrier, code, url } | null
}Address
{
id, name, line1: string
line2?: string
city: string
region?: string
postalCode: string
country: string // ISO 3166-1 alpha-2
phone?: string
isDefault?: boolean
}Customer
{ id, email, firstName, lastName: string, phone?: string, addresses: Address[] }Address mutations return the whole Customer, so the client never merges by hand.
Category
{
slug, name: string
parent: string | null
blurb: string
image: Image
count: number // includes descendants
children?: Category[]
}Review
{
id, author: string
rating: number // 1–5
body: string
createdAt: string
verified: boolean
title?: string
size?: string
height?: string
fit?: 'small' | 'true' | 'large'
photos?: Image[]
}Validation
Every type above has a JSDoc typedef in src/lib/api/contracts.js, which also asserts the critical ones at the response boundary. A 200 with a missing price throws a ContractError naming the endpoint and the field, rather than surfacing three components later as a null dereference. See ERRORS.md.