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

# Utilities

> Framework-free helpers: the variant matrix, images, money, currencies, SEO, storage, campaign refusals, consent and analytics.

<Info>
  This page is the curated reference. The complete, generated type reference lives at [/kit/api/core](/kit/api/core) and is regenerated from the published typings.
</Info>

Everything on this page is exported from the root entry, `@quickbutik/kit`, and runs on a server and in a browser alike. The React hooks and the web components are thin bindings over these same functions.

## Variant matrix

Pure functions over a `Product` and a `VariantSelection` (`Record<optionId, valueId>`). A server can resolve the initial variant and price before anything hydrates.

```ts theme={null}
import {
  getOptionGroups, buildVariantMatrix, resolveVariant, buildPriceState,
  selectOptionValue, clearOptionValue, selectionForVariant,
  firstAvailableVariant, variantMatchesSelection,
} from "@quickbutik/kit"
```

| Function | Signature | Returns |
| - | - | - |
| `getOptionGroups` | `(product) => ProductOptionType[]` | The product's option groups, rebuilt from the variants when `product.options` is absent. |
| `buildVariantMatrix` | `(product, selection) => VariantOptionGroupState[]` | Every group with its values and their `selected` / `available` state. |
| `resolveVariant` | `(product, selection) => ProductVariant \| null` | The variant the selection pins, or `null`. A simple product resolves to its only variant. |
| `buildPriceState` | `(product, selection) => VariantPriceState` | `amount`, `compareAtAmount`, `min`, `max`, `isRange`. |
| `selectOptionValue` | `(product, selection, optionId, valueId) => VariantSelection` | The next selection. Clears whatever contradicts the click, never the clicked group. |
| `clearOptionValue` | `(selection, optionId) => VariantSelection` | |
| `selectionForVariant` | `(product, variantId) => VariantSelection` | For `?variant=` deep links. |
| `firstAvailableVariant` | `(product) => ProductVariant \| null` | First non-hidden variant. Ignores stock. |
| `variantMatchesSelection` | `(variant, selection) => boolean` | |

<ResponseField name="VariantOptionGroupState" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="number" />

    <ResponseField name="name" type="string | null" />

    <ResponseField name="position" type="number | null" />

    <ResponseField name="values" type="VariantOptionValueState[]">Each with `id`, `name`, `optionId`, `position`, `selected`, `available`, `variantIds`.</ResponseField>

    <ResponseField name="selectedValueId" type="number | null" />
  </Expandable>
</ResponseField>

<ResponseField name="VariantPriceState" type="object">
  <Expandable title="properties">
    <ResponseField name="amount" type="number | null">Minor units. `null` until a variant resolves.</ResponseField>
    <ResponseField name="compareAtAmount" type="number | null">For a struck-through price.</ResponseField>
    <ResponseField name="min" type="number | null">Lowest price still reachable.</ResponseField>

    <ResponseField name="max" type="number | null" />

    <ResponseField name="isRange" type="boolean">The cue for "from 99 kr".</ResponseField>
  </Expandable>
</ResponseField>

<Note>
  `available` means "this combination exists and is not hidden", not "in stock". `stock.stock` is `null` for shops that do not track inventory, and `null` means purchasable. Keep unavailable values clickable; `selectOptionValue` repairs the selection.
</Note>

### ProductController

The same logic as a stateful, subscribable object. `<ProductProvider>` and `<qb-product>` are both bindings over it.

```ts theme={null}
import { ProductController } from "@quickbutik/kit"

const controller = new ProductController(product, { initialVariantId: 10 })
controller.subscribe(() => render(controller.getSnapshot()))
controller.select(optionId, valueId)
```

<ParamField path="options.initialVariantId" type="number" />

<ParamField path="options.selectFirstAvailable" type="boolean" default="false" />

| Member | Does |
| - | - |
| `getSnapshot()` | `{ product, options, selection, selectedVariant, isComplete, hasOptions, price }` |
| `subscribe(listener)` | Returns an unsubscribe function. |
| `select(optionId, valueId)` | |
| `clear(optionId)` | |
| `reset()` | Clears the selection. |
| `selectVariant(variantId)` | |
| `setProduct(product, options?)` | Swap the product. |
| `product` | The current product. |

## Images

Never render `image.path`: it is a bare storage filename, not a URL. These helpers build CDN URLs from `image.url`. See [Images](/kit/concepts/images).

```ts theme={null}
import { pickProductImage, resolveProductImageUrl, buildImageSrcSet } from "@quickbutik/kit"

const image = pickProductImage(product)
const src = resolveProductImageUrl(image, { width: 600, height: 600 })
const srcSet = buildImageSrcSet(image, { width: 600 })
```

| Function | Signature | Returns |
| - | - | - |
| `resolveProductImageUrl` | `(image, options?: ProductImageUrlOptions) => string \| null` | The `src`, or `null` when there is nothing to show. |
| `resolveCategoryImageUrl` | `(category, options?) => string \| null` | The same for a category cover. |
| `buildImageSrcSet` | `(image, options?: ImageSrcSetOptions) => string \| null` | A `srcset` from `densities` (default `[1, 2]`) or `widths`. |
| `pickProductImage` | `(product, options?: { index?, imageId?, includePending? }) => ProductImage \| null` | One image in display order. `imageId` picks a variant's own image. |
| `productImages` | `(product, includePending?) => ProductImage[]` | Every renderable image, ordered. Images still processing are dropped. |
| `applyImageTransform` | `(url, transform?, cacheBust?) => string` | Raw parameter builder for a URL you got elsewhere (for example `cartItem.imageUrl`). |

<ParamField body="ImageTransform" type="object">
  imgix-compatible parameters.

  <Expandable title="properties">
    <ParamField body="width" type="number">`w`</ParamField>
    <ParamField body="height" type="number">`h`</ParamField>

    <ParamField body="dpr" type="number" />

    <ParamField body="quality" type="number">`q`</ParamField>
    <ParamField body="format" type="string">`fm`</ParamField>

    <ParamField body="fit" type="'clip' | 'crop' | 'fill' | 'facearea' | 'max' | 'min' | 'scale'" />

    <ParamField body="crop" type="string" />

    <ParamField body="background" type="string">`bg`</ParamField>
    <ParamField body="auto" type="string | null" default="format">Pass `null` to drop it.</ParamField>
  </Expandable>
</ParamField>

`ProductImageUrlOptions` adds `baseUrl` (a fallback base for a bare `path`) and `cacheBust` (default `true`, appends `contentHash` as `?v=`).

## Money

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

formatMoney(129900, "SEK", { locale: "sv-SE" }) // "1 299,00 kr"
formatMoneyRange(9900, 99900, "SEK")           // "99,00 kr – 999,00 kr"
```

| Function | Signature |
| - | - |
| `formatMoney` | `(amount: number, currency: string, options?: { locale?: string; symbol?: boolean }) => string` |
| `formatMoneyRange` | `(min: number \| null, max: number \| null, currency: string, options?) => string \| null` |
| `currencyDecimals` | `(currency: string) => number` |
| `formatSchemaPrice` | `(amount: number, currency: string) => string` |
| `toMajorUnits` | `(amount: number, currency?: string \| null) => number` |

All amounts are minor units. Decimals follow the currency (JPY and ISK have none). Format with the currency the response states (`product.currency`, `cart.currency`), never a constant.

## Currencies

See [Currencies](/kit/concepts/currencies).

```ts theme={null}
import { describeCurrency, normalizeCurrency, getShopPromise } from "@quickbutik/kit"

const shop = await getShopPromise(shopkit)        // one shop.get() per client, shared
describeCurrency(shop, shopkit.currency)
// { currency: "EUR", baseCurrency: "SEK", mode: "charge", rate: 0.0871, chargeCurrency: "EUR", currencies: [...] }
```

| Export | Signature | Does |
| - | - | - |
| `describeCurrency` | `(shop: Pick<Shop, "currency" \| "currencies"> \| null \| undefined, selected: string \| null \| undefined) => CurrencyInfo` | Resolves a choice the way the platform does: a code the shop does not offer (converter off, not enabled, no rate) falls back to the shop's own currency with `mode: "base"`. Never throws. |
| `normalizeCurrency` | `(value: string \| null \| undefined, context?: string) => string \| null` | Upper-cases a three-letter ISO 4217 code; `null` for empty. Throws `ShopkitConfigError` for anything else. Use it to validate a `qb_currency` cookie you read yourself. |
| `getShopPromise` | `(client: ShopkitClient) => Promise<Shop>` | One `shop.get()` per client, shared by every switcher; a rejected read is evicted so the next caller retries. |
| `clearShopCache` | `(client: ShopkitClient) => void` | Forget it. |

`CurrencyInfo` is on [Types](/kit/reference/types#currencyinfo).

## SEO

See [SEO](/kit/concepts/seo) for how to use them.

| Export | Signature | Does |
| - | - | - |
| `buildSeo` | `(options: SeoInput & SeoDefaults) => SeoTags` | Titles, canonical, OpenGraph, Twitter card and schema.org JSON-LD from a product, a category or the shop. |
| `toNextMetadata` | `(tags: SeoTags) => NextLikeMetadata` | For Next.js `generateMetadata`. JSON-LD does not come along; render it separately. |
| `productJsonLd` | `(product, options?: { url?, currency?, images?, brand? }) => object` | `Product` with an `Offer` or `AggregateOffer`. Offers are omitted without a currency. |
| `breadcrumbJsonLd` | `(items: { name, url? }[]) => object \| undefined` | |
| `organizationJsonLd` | `(shop, options?: { url? }) => object` | |
| `serializeJsonLd` | `(node: unknown) => string` | Escapes every `<` so merchant content cannot close the script tag. |
| `productAvailability` / `variantAvailability` | `(product) / (variant) => SchemaAvailability` | `stock: null` reads as InStock; `preorder` wins. |
| `absoluteUrl` | `(url?, baseUrl?) => string \| undefined` | |
| `applyTitleTemplate` | `(title, siteName?, template?) => string` | `%s` is the title. |
| `plainText` | `(html?, maxLength?) => string \| undefined` | For meta descriptions. |

## Storage, cookies and runtime

See [Storage & SSR](/kit/concepts/storage-and-ssr).

| Export | Signature |
| - | - |
| `createMemoryStorage` | `() => StorageAdapter` |
| `createLocalStorage` | `() => StorageAdapter` |
| `createDocumentCookieStorage` | `(attributes?: CookieAttributes) => StorageAdapter` |
| `createCookieStorage` | `(accessor: CookieAccessor, attributes?) => StorageAdapter` |
| `createRequestCookieStorage` | `(source: string \| Headers \| Request \| null, options?: { collect?: string[]; attributes? }) => StorageAdapter` |
| `resolveStorage` | `(options: { preference?, cookies?, cookieAttributes? }) => StorageAdapter` |
| `parseCookieHeader` | `(header) => Record<string, string>` |
| `serializeCookie` | `(name, value, attributes?) => string` |
| `readDocumentCookie` / `writeDocumentCookie` / `deleteDocumentCookie` | document-cookie helpers |
| `detectRuntime` | `() => "browser" \| "server"` |
| `isBrowser` | `() => boolean`: needs a real `window` and `document`. |
| `hasDocumentCookies` / `hasLocalStorage` | Probe with a real write. |
| `canNavigateTopWindow` | `() => boolean`: false inside a sandboxed preview frame. |
| `SessionStore` | The class behind the remembered ids, for advanced use. |

A `StorageAdapter` is any `{ kind, get(key), set(key, value, { ttlSeconds }), remove(key) }`; each method may return a promise.

`createRequestCookieStorage` reads cookies from a `Request`, a `Headers` or a raw `Cookie` header string, and pushes every write as a `Set-Cookie` string into `collect`:

```ts theme={null}
const setCookies: string[] = []
const shopkit = createShopkitClient({ publishableKey }).withStorage(
  createRequestCookieStorage(request, { collect: setCookies }),
)
// after rendering:
for (const cookie of setCookies) response.headers.append("set-cookie", cookie)
```

## Async state

`AsyncResource<T>` is the framework-free state behind the React catalog hooks: `run(operation)` aborts the previous run and discards a superseded result, `abort()`, `hydrate(data)`, `subscribe`, `getSnapshot()` returning `{ data, status, loading, error }`.

## Campaign storefronts

```ts theme={null}
import { storefrontRefusal } from "@quickbutik/kit"

try {
  const { url } = await shopkit.checkout.start({ successUrl })
  location.assign(url)
} catch (error) {
  const refusal = storefrontRefusal(error)
  if (refusal?.reason === "storefront_not_live") showCampaignClosed(refusal.effectiveState)
  else if (refusal?.reason === "storefront_quantity_limit") showLimit(refusal)
  else throw error
}
```

`storefrontRefusal(error)` narrows any thrown value to a typed refusal, or `null`. The shapes are on [Errors & scopes](/kit/reference/errors#campaign-storefront-refusals). `isStorefrontId`, `isStorefrontSurface`, `STOREFRONT_ID_PATTERN`, `STOREFRONT_SURFACES` and `DEFAULT_STOREFRONT_SURFACE` are exported alongside it.

## Consent and analytics

The framework-free layer under `<ShopkitProvider>` and the elements, for a page that composes it itself. The provider and the elements set all of this up by default; see [Consent and analytics](/kit/concepts/consent-and-analytics).

### ConsentStore

```ts theme={null}
import { ConsentStore } from "@quickbutik/kit"

const consent = new ConsentStore({ revision: 1, maxAgeDays: 180 })
consent.getSnapshot()          // { status, analytics, marketing, decidedAt, revision, open }
consent.subscribe(() => {})    // returns an unsubscribe
consent.allows("marketing")
consent.acceptAll(); consent.rejectAll(); consent.save({ analytics: true, marketing: false })
consent.reset()                // withdraw: forget the decision and ask again
consent.openSettings(); consent.closeSettings()
```

<ParamField path="cookieName" type="string" default="qb_consent" />

<ParamField path="revision" type="number" default="1">Bump it to ask every shopper again: a decision stored under an older revision reads as undecided.</ParamField>

<ParamField path="maxAgeDays" type="number" default="180" />

<ParamField path="domain" type="string">Host-only by default. `.myshop.com` shares it across subdomains.</ParamField>

<ParamField path="sameSite" type="'lax' | 'strict' | 'none'" default="lax" />

<ParamField path="secure" type="boolean">On https by default.</ParamField>
<ParamField path="persist" type="boolean" default="true">`false` when a third-party consent platform owns the cookie; call `save()` from its callback.</ParamField>
<ParamField path="initialState" type="ConsentState | null">The server's read. Without it the store reads the cookie; `null` starts undecided without reading.</ParamField>

Every decision is also dispatched on `document` as `qb:consent` (`CONSENT_EVENT`) with the state as `detail`.

### Consent helpers

| Export | Signature | Does |
| - | - | - |
| `readConsentCookie` | `(cookieHeader: string \| null \| undefined, options?: { cookieName?, revision? }) => ConsentState` | Read the decision from a raw `Cookie` header, on a server with no client. |
| `parseConsentCookieValue` | `(raw, revision?) => ConsentState` | Parse one cookie value. |
| `writeConsentCookie` / `clearConsentCookie` | `(state, options?) => void` / `(options?) => void` | Browser-only cookie writes. |
| `serializeConsentState` | `(state) => string` | The cookie's JSON. Throws for an undecided state. |
| `undecidedConsent` | `(revision?) => ConsentState` | |
| `consentModeSignals` | `(state) => ConsentModeSignals` | Google Consent Mode v2 values (`analytics_storage`, `ad_storage`, `ad_user_data`, `ad_personalization`) for a decision. Undecided is denied. |
| `consentCategoriesParam` | `(state) => string \| null` | The `consentCategories` value the hosted checkout reads. |
| `consentLabels` | `(lang?, overrides?) => ConsentLabels` | The banner's built-in copy. `CONSENT_LABEL_LANGUAGES` lists the languages (`en`, `sv`, `da`, `nb`, `fi`); `resolveLanguage(lang?)` picks one. |
| `CONSENT_STYLES` / `injectConsentStyles(doc?)` | `string` / `(doc?: Document) => void` | The default banner's stylesheet, in `@layer qb-consent`. `CONSENT_STYLE_ID` is its `<style>` id. |
| `DEFAULT_CONSENT_COOKIE`, `DEFAULT_CONSENT_MAX_AGE_DAYS`, `DEFAULT_CONSENT_REVISION` | `"qb_consent"`, `180`, `1` | |

### Analytics

```ts theme={null}
import { Analytics, ConsentStore, destinationsFromShop, viewItemEvent } from "@quickbutik/kit"

const analytics = new Analytics({ consent })
analytics.start()
analytics.attachCart(cartStore)                  // add_to_cart / remove_from_cart from the store's mutations
for (const d of destinationsFromShop(shop.tracking)) analytics.addDestination(d)
analytics.track(viewItemEvent(product, variant, product.currency))
```

`Analytics` is the hub: it fans GA4-shaped events out to destinations gated on consent, remembers events a destination cannot take yet (consent pending, ids still loading) and drops them on denial.

| Member | Does |
| - | - |
| `new Analytics(options?)` | `destinations`, `consent` (a `ConsentStore`; without one the hub fails closed), `requireConsent` (default `true`), `currency`, `storeId`, `bufferSize` (default 50), `debug` |
| `start()` / `stop()` / `destroy()` | Begin, pause and tear down dispatch |
| `addDestination(d)` / `removeDestination(d)` / `destinations` | Manage destinations |
| `track(event)` | Send a `CommerceEvent` |
| `trackPageView(params?)` | A `page_view`, for SPA navigations |
| `trackPurchase(purchase, { storeId?, orderNumber? })` | Report an order once per browser; `false` when already reported |
| `attachCart(cartStore)` | Track `add_to_cart` / `remove_from_cart` from the store's mutations, and learn the currency and store id from it. Returns a detach function |
| `observeCart(cartStore)` | Learn the store id from a cart store without tracking its mutations |
| `allows(category)`, `consentState`, `requireConsent` | The gate as the hub sees it |

### Destinations

| Export | Signature | Does |
| - | - | - |
| `googleTagDestination` | `(options: { ga4MeasurementId?, gtmContainerId?, adsConversionId?, sendPageView?, loadBeforeConsent?, gaLink? }) => AnalyticsDestination` | gtag.js with a GA4 or Ads id, or `dataLayer` with a GTM container (never both). Pushes a denied Consent Mode `default` before any script and an `update` on every change. `loadBeforeConsent` defaults to **false**: nothing from Google loads until `analytics` is granted. `sendPageView: false` for an SPA that calls `trackPageView()`. |
| `metaPixelDestination` | `(options: { pixelId: string }) => AnalyticsDestination` | The Meta pixel, not loaded at all until `marketing` is granted. Calls `fbq("consent", …)` on every change. |
| `destinationsFromShop` | `(tracking: ShopTracking \| null \| undefined, options?) => AnalyticsDestination[]` | The Google and Meta destinations for the ids in `shop.tracking`. |
| `CONSENT_UPDATE_EVENT` | `"qb_consent_update"` | The `dataLayer` event pushed on every consent change, for GTM triggers. |

Your own destination is one object implementing `AnalyticsDestination`:

<ResponseField name="id" type="string" required>Unique per hub.</ResponseField>
<ResponseField name="key" type="string">Which vendor account it reports to. Lets an inline `destinations` array keep the destination across renders without replaying events into it.</ResponseField>
<ResponseField name="requires" type="'necessary' | 'analytics' | 'marketing' | null" required>The category it needs before it receives anything, `load()` included. `null`: always, and it gates itself.</ResponseField>
<ResponseField name="load(context)" type="void">Put the vendor's script on the page. Called once, the first time it is allowed. Must be idempotent.</ResponseField>
<ResponseField name="onConsent(state, context)" type="void">The decision changed.</ResponseField>

<ResponseField name="track(event, context)" type="void" required />

<ResponseField name="trackPurchase(purchase, { eventId }, context)" type="void">The purchase, with the id Meta and the platform dedupe it by. Without it, the purchase arrives through `track()`.</ResponseField>

### Event builders

Pure mappers to GA4's vocabulary: money in **major** units, `item_id` the bare numeric product id, the variant as `item_variant`.

| Export | Signature |
| - | - |
| `viewItemEvent` | `(product, variant, currency?) => CommerceEvent` |
| `searchEvent` | `(term: string) => CommerceEvent` |
| `viewItemListEvent` | `(items: CommerceItem[], listName?) => CommerceEvent` |
| `itemFromProduct` | `(product, variant, currency?, quantity?) => CommerceItem` |
| `itemsFromCart` / `itemFromCartLine` | `(cart) => CommerceItem[]` / `(line, currency, quantity?) => CommerceItem` |
| `itemsFromCheckoutProducts` | `(products, currency) => CommerceItem[]` |
| `cartLineDiff` | `(before: Cart \| null, after: Cart \| null) => CartLineDiff`: what was added and removed between two carts |
| `purchaseFromSession` | `(snapshot, orderNumber, { affiliation? }?) => PurchaseEvent \| null`: the purchase from the checkout session's own lines and total |
| `purchaseToEvent` | `(purchase) => CommerceEvent` |
| `purchaseDedupKey` / `purchaseEventId` | `(storeId, orderNumber) => string`: `qb_purchase_tracked_{storeId}_{orderNumber}` / `purchase_{storeId}_{orderNumber}` |
| `bareProductId` | `(id) => string`: `"prod_27"` becomes `"27"` |
| `toMajorUnits` | `(amount, currency?) => number` |

### Checkout handoff

| Export | Signature | Does |
| - | - | - |
| `appendCheckoutHandoffParams` | `(url: string, consent: ConsentState \| null \| undefined, options?: { cookies?, requireConsent?, linkGa? }) => string` | Appends `consentCategories` (and `gaClientId` / `gaSessionId` when analytics is granted) to a hosted checkout URL, for a handoff the kit does not perform. `cookies` is a `Cookie` header or a `{ name, value }[]` list; defaults to `document.cookie`. Idempotent. |
| `readGaCookies` | `(cookies?: string \| null \| CookieList) => GaLink` | The GA client and session ids from the `_ga` and `_ga_<property>` cookies. |


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