> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Images, money and product content

> Render product images from the CDN, format prices from minor units and use variant images, content sections and related products.

## Images: render `url`, never `path`

```ts theme={null}
interface ProductImage {
  id: number
  productId: number
  url: string | null         // absolute, no resize params: RENDER THIS
  path: string | null        // bare storage filename: NOT a URL
  position: number | null
  date: string | null
  altText: string | null
  contentHash: string | null // cache-buster; "temp" = file still processing
}
```

<Warning>
  `image.path` is a bare filename like `5c7b0e7e1802c.jpeg`. Completing it needs the shop's storage prefix, which no storefront key can read, so `<img src={image.path}>` resolves against your own origin and 404s. This is the most common way a headless Quickbutik shop ships broken images.
</Warning>

`url` deliberately carries no resize parameters, because only the renderer knows the size. The Quickbutik image CDN takes imgix-compatible parameters, and the kit builds them:

<Tabs>
  <Tab title="Vanilla">
    ```ts theme={null}
    import { pickProductImage, resolveProductImageUrl, buildImageSrcSet } from "@quickbutik/kit"

    const image = pickProductImage(product)                       // main image
    const src = resolveProductImageUrl(image, { width: 600 })
    // → https://cdn.quickbutik.com/images/ABC1/products/5c7….jpeg?auto=format&w=600
    const srcSet = buildImageSrcSet(image, { width: 600 })        // "… 1x, …&dpr=2 2x"
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={null}
    import { ProductImage, useProductImage } from "@quickbutik/kit/react"

    // Inside <ProductProvider>: the main image, no props needed
    <ProductImage width={800} height={800} priority />

    // In a card
    <ProductImage product={product} width={400} height={400} />

    // For next/image or a CSS background
    const { src, srcSet, alt } = useProductImage({ width: 800 })
    ```
  </Tab>

  <Tab title="Web components">
    ```html theme={null}
    <qb-product slug="cotton-tee">
      <qb-product-image width="800" height="800" priority></qb-product-image>
    </qb-product>
    ```
  </Tab>
</Tabs>

### Helpers

| Helper | Does |
| - | - |
| `resolveProductImageUrl(image, opts)` | `src` for one image, or `null` when there is nothing to show |
| `resolveCategoryImageUrl(category, opts)` | The same for a category cover |
| `buildImageSrcSet(image, opts)` | A `srcSet` from `densities` (`2x`) or `widths` (`800w`) |
| `pickProductImage(product, opts)` | One image by `index` (default 0) or `imageId`, in display order |
| `productImages(product)` | Every renderable image, ordered, pending ones dropped |
| `applyImageTransform(url, opts)` | Raw parameter builder, for a URL you got elsewhere (such as `cartItem.imageUrl`) |

Transform options: `width` (`w`), `height` (`h`), `dpr`, `quality` (`q`), `format` (`fm`), `fit`, `crop`, `background` (`bg`), and `auto`, which defaults to `"format"` (pass `null` to drop it).

```ts theme={null}
// A cart line thumbnail
const thumb = item.imageUrl
  ? applyImageTransform(item.imageUrl, { width: 128, height: 128, fit: "crop" })
  : null
```

### Behaviours worth knowing

* **Images still processing are skipped.** A freshly uploaded image exists before its file does, marked with `contentHash: "temp"`. Pass `includePending: true` to include it.
* **`contentHash` is appended as `?v=`**, so a replaced image is not served stale. Pass `cacheBust: false` to turn that off.
* **`null` instead of a broken URL.** Render your own placeholder. `<ProductImage>` renders its `fallback` (nothing by default) and `<qb-product-image>` gets an `empty` attribute.
* **Only if you front the CDN yourself**, set `imageBaseUrl` on the client to the shop-scoped base without the `products/` segment, and the helpers complete `path` with it.
* If you use `next/image`, add the CDN host (`cdn.quickbutik.com`) to `images.remotePatterns`. Never use a wildcard pattern, which turns `/_next/image` into an open proxy.

## Variant images

`variant.imageId` is the `id` of one of the product's own `images`, or `null` when the variant has none. Swap the gallery when the shopper picks a variant, falling back to the main image:

```ts theme={null}
const image =
  pickProductImage(product, { imageId: variant.imageId ?? undefined }) ??
  pickProductImage(product)
```

## Content sections

`product.sections` are the merchant's content sections ("Size guide", "Care", "Delivery") in their order: `{ id, title, content }`. `content` is merchant-authored **HTML**: sanitize it before injecting it. Sections arrive rendered the way the Quickbutik theme renders them, with template text filled in and the `[STOCKLEFT]`, `[PRICE]` and `[BEFOREPRICE]` merge tags replaced. Empty sections are left out.

```ts theme={null}
for (const section of product.sections ?? []) render(section.title, section.content)
```

## Related products

`products.get()` (and so `getBySlug()`) returns `relatedProducts: { mode, products }`. `list()` and `search()` do not.

| `mode` | `products` |
| - | - |
| `"category"` (default) | Up to 8 from the product's category, newest first |
| `"specific"` | The merchant's hand-picked list, in their order, up to 12 |
| `"none"` | Always empty |

Each entry is a card-sized summary, `{ id, name, slug, price: { price, comparePrice }, image }`, priced like the product list (automatic discounts and campaign prices included). Fetch the full product with `products.get(id)` when a card is opened.

<Note>
  `imageId`, `sections` and `relatedProducts` are typed from kit **1.5.0**. The API serves them to older builds too; read them through a cast there.
</Note>

## Money

**Every amount in the kit and the Storefront API is an integer in minor units** (öre, cents): `24900` is 249,00 kr. Never a float, never a pre-formatted string. The `MinorUnits` type alias marks every such field.

```ts theme={null}
import { formatMoney, formatMoneyRange } from "@quickbutik/kit"

formatMoney(129900, "SEK", { locale: "sv-SE" })   // "1 299,00 kr"
formatMoneyRange(9900, 99900, "SEK")              // a "from … to …" range
```

`formatMoney` uses the currency's real number of decimals, so JPY and ISK (which have none) are not divided by 100.

| Rule | Detail |
| - | - |
| Format with the response's currency | Every product states `product.currency` and every cart `cart.currency`: the shopper's chosen currency when the shop offers it, else the shop's own. Never hardcode `"SEK"`. See [Currencies](/kit/concepts/currencies) |
| Prices include VAT | Never add, compute or convert VAT, discounts or currencies yourself. A converted price comes from the server, never from a rate applied in the browser |
| Cart totals come from the cart | `unitPrice`, `lineTotal`, `subtotal` and `total` follow the shop's incl./excl. VAT setting; the `…InclTax` / `…ExclTax` fields are unambiguous |
| Prices are recomputed on every read | Never cache cart numbers beyond the current render |
| The Merchant API is the exception | `api.quickbutik.com` takes and returns **major** units: a product created with `price: 249` reads `24900` here |

In React, the price hooks hand back raw `{ amount, currency }` (the product's own currency) and leave formatting to you. The web components format for you (`price.display`, `cart.totalFormatted`) because an HTML binding has nowhere else to do it; the raw integers (`price.amount`, `item.lineTotal`) are always there too.

### Stock

`variant.stock.stock` is `null` when the shop does not track inventory (and for preorder items). **`null` means purchasable, not sold out.** Only a number at or below zero is out of stock. Never fabricate "only 3 left".


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.