{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shopify",
  "type": "registry:lib",
  "title": "Shopify",
  "description": "litt's commerce parts on a Shopify store: a small Storefront API client pinned to one version, getProduct, getProducts and getCollection that return litt's Product, and toProduct for your own queries. No React in it, so it runs in a server component too.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/cart.json",
    "https://littmd.vercel.app/r/option-picker.json",
    "https://littmd.vercel.app/r/price.json",
    "https://littmd.vercel.app/r/product-card.json"
  ],
  "files": [
    {
      "path": "registry/litt/lib/shopify.ts",
      "type": "registry:lib",
      "content": "// @litt/shopify 2026.10.24. What changed: https://littmd.vercel.app/changelog.md\n/**\n * Shopify: litt's commerce parts on a Shopify store, through the Storefront API (proposals/commerce.md, \"The Shopify\n * layer\"). A library, not a component: it fetches and maps, and draws nothing. Installed in lib/litt/shopify.ts.\n *\n * - createStorefront({ domain, token }) is a small fetch client for one store's Storefront GraphQL API, pinned to one\n *   API version (STOREFRONT_API_VERSION). The token is the store's public Storefront token, made for the browser: it\n *   reaches only what a shopper can see. The Admin API never appears. No package, no provider model: it runs in a\n *   Vite app, in a Next.js server component and in a route handler alike.\n * - getProduct, getCollection and getProducts fetch and map in one step: what they return is litt's Product, so it\n *   goes straight to ProductCard, OptionPicker and the product and collection blocks. getCart, createCart,\n *   addCartLines, updateCartLines and removeCartLines are the cart's calls; toCartItems maps a cart's lines. toProduct does the mapping on\n *   its own, for a query of yours that uses PRODUCT_FIELDS.\n * - The mapping: images become ProductImages with a srcSet from Shopify's image CDN; option values keep their swatch\n *   (Shopify's hex color, or its image), which OptionPicker draws as chips; a product with no options (Shopify's one\n *   \"Title\" option with \"Default Title\") has none; variants keep their price, compare-at price and stock.\n * - No React in this file, so it ships as lib/litt/shopify.ts without \"use client\" and runs anywhere: in the browser,\n *   in a Next.js server component, in a route handler. The cart, which needs React, is shopify-cart.tsx.\n * - When Shopify ships a new API version, STOREFRONT_API_VERSION and the fields change here; nothing visual moves.\n */\nimport type { CartItem } from \"../../components/litt/cart\";\nimport type { OptionValue } from \"../../components/litt/option-picker\";\nimport type { Money } from \"../../components/litt/price\";\nimport type { Product, ProductImage, ProductVariant } from \"../../components/litt/product-card\";\n\n/** The Storefront API version every query is written for. */\nexport const STOREFRONT_API_VERSION = \"2026-04\";\n\n/* ----------------------------------------------------------------- the client */\n\nexport type StorefrontConfig = {\n  /** The store's domain: \"your-store.myshopify.com\" or its own domain. */\n  domain?: string;\n  /** The store's public Storefront API access token. Leave it out for a store that needs none, such as mock.shop. */\n  token?: string;\n  /** The API version. Leave it out for STOREFRONT_API_VERSION, which the fields here are written for. */\n  version?: string;\n  /** The whole GraphQL address, in place of the one made from the domain: \"https://mock.shop/api\". */\n  endpoint?: string;\n  /** Prices and stock for this country, as an ISO code: \"IE\", \"US\". */\n  country?: string;\n  /** Text in this language, as an ISO code: \"EN\", \"DE\". */\n  language?: string;\n};\n\nexport type Storefront = {\n  /** Runs a query or a mutation and gives back its data. Throws a ShopifyError when Shopify answers with errors. */\n  query: <T>(query: string, variables?: Record<string, unknown>) => Promise<T>;\n};\n\n/** What Shopify said went wrong: its messages, joined. */\nexport class ShopifyError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = \"ShopifyError\";\n  }\n}\n\n/** A client for one store's Storefront API: a fetch, the token and the pinned version, nothing more. */\nexport function createStorefront({ domain, token, version = STOREFRONT_API_VERSION, endpoint, country, language }: StorefrontConfig): Storefront {\n  const url = endpoint ?? `https://${domain}/api/${version}/graphql.json`;\n  if (!endpoint && !domain) throw new ShopifyError(\"createStorefront needs a domain or an endpoint.\");\n  /* @inContext sets the country and the language for prices and text, when they are given. */\n  const context = country || language ? `@inContext(${[country && `country: ${country}`, language && `language: ${language}`].filter(Boolean).join(\", \")})` : \"\";\n  return {\n    async query<T>(query: string, variables: Record<string, unknown> = {}) {\n      const res = await fetch(url, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\", ...(token ? { \"X-Shopify-Storefront-Access-Token\": token } : {}) },\n        body: JSON.stringify({ query: context ? query.replace(/^(\\s*(?:query|mutation)\\s*\\w*\\s*(?:\\([^)]*\\))?)/, `$1 ${context}`) : query, variables }),\n      });\n      if (!res.ok) throw new ShopifyError(`Shopify answered ${res.status} ${res.statusText}.`);\n      const body = (await res.json()) as { data?: T; errors?: { message: string }[] };\n      if (body.errors?.length) throw new ShopifyError(body.errors.map((e) => e.message).join(\" \"));\n      if (!body.data) throw new ShopifyError(\"Shopify answered with no data.\");\n      return body.data;\n    },\n  };\n}\n\n/* ----------------------------------------------------------------- the fields */\n\nconst IMAGE = \"url altText width height\";\nconst MONEY = \"amount currencyCode\";\n\n/** The product fields toProduct reads. Put them in a query of your own: `product(handle: $handle) { ${PRODUCT_FIELDS} }`. */\nexport const PRODUCT_FIELDS = `id handle title vendor description\n  options { name optionValues { name swatch { color image { previewImage { url } } } } }\n  images(first: 10) { nodes { ${IMAGE} } }\n  variants(first: 100) { nodes { id title availableForSale price { ${MONEY} } compareAtPrice { ${MONEY} } selectedOptions { name value } image { ${IMAGE} } } }`;\n\n/** The cart fields toCartItems reads, for shopify-cart. */\nexport const CART_FIELDS = `id checkoutUrl totalQuantity cost { subtotalAmount { ${MONEY} } }\n  lines(first: 100) { nodes { id quantity\n    cost { amountPerQuantity { ${MONEY} } compareAtAmountPerQuantity { ${MONEY} } }\n    merchandise { ... on ProductVariant { id title image { ${IMAGE} } selectedOptions { name value } product { title handle featuredImage { ${IMAGE} } } } } } }`;\n\n/* ----------------------------------------------------------------- the Storefront API's shapes, as far as litt reads them */\n\ntype SImage = { url: string; altText: string | null; width: number | null; height: number | null };\ntype SMoney = { amount: string; currencyCode: string };\ntype SVariant = {\n  id: string;\n  title: string;\n  availableForSale: boolean;\n  price: SMoney;\n  compareAtPrice: SMoney | null;\n  selectedOptions: { name: string; value: string }[];\n  image: SImage | null;\n};\n/** A product as the Storefront API returns PRODUCT_FIELDS. */\nexport type StorefrontProduct = {\n  id: string;\n  handle: string;\n  title: string;\n  vendor: string;\n  description: string;\n  options: { name: string; optionValues: { name: string; swatch: { color: string | null; image: { previewImage: { url: string } | null } | null } | null }[] }[];\n  images: { nodes: SImage[] };\n  variants: { nodes: SVariant[] };\n};\n/** A cart as the Storefront API returns CART_FIELDS. */\nexport type StorefrontCart = {\n  id: string;\n  checkoutUrl: string;\n  totalQuantity: number;\n  cost: { subtotalAmount: SMoney };\n  lines: {\n    nodes: {\n      id: string;\n      quantity: number;\n      cost: { amountPerQuantity: SMoney; compareAtAmountPerQuantity: SMoney | null };\n      merchandise: { id: string; title: string; image: SImage | null; selectedOptions: { name: string; value: string }[]; product: { title: string; handle: string; featuredImage: SImage | null } };\n    }[];\n  };\n};\n\n/* ----------------------------------------------------------------- the mapping */\n\n/** Several widths of a Shopify CDN image, for the browser to pick from. */\nexport function shopifySrcSet(url: string, widths: number[] = [400, 800, 1200, 1600]): string {\n  return widths\n    .map((w) => {\n      try {\n        const u = new URL(url);\n        u.searchParams.set(\"width\", String(w));\n        return `${u.toString()} ${w}w`;\n      } catch {\n        return \"\";\n      }\n    })\n    .filter(Boolean)\n    .join(\", \");\n}\n\nconst toImage = (image: SImage, fallbackAlt: string): ProductImage => ({\n  src: image.url,\n  srcSet: shopifySrcSet(image.url),\n  alt: image.altText ?? fallbackAlt,\n  width: image.width ?? undefined,\n  height: image.height ?? undefined,\n});\n/** Maps a Storefront API MoneyV2 to litt's Money. They are the same shape; this drops anything else Shopify sends. */\nexport const toMoney = (m: SMoney): Money => ({ amount: m.amount, currencyCode: m.currencyCode });\n/** Shopify gives a product with no options one option, \"Title\", with one value, \"Default Title\". litt shows none. */\nconst isDefault = (options: { name: string }[], values: string[]) => options.length === 1 && values.length === 1 && values[0] === \"Default Title\";\n\n/** Maps a Storefront API product (PRODUCT_FIELDS) to litt's Product, for ProductCard, OptionPicker and the blocks. */\nexport function toProduct(p: StorefrontProduct): Product {\n  const noOptions = isDefault(p.options, p.options[0]?.optionValues.map((v) => v.name) ?? []);\n  const variants: ProductVariant[] = p.variants.nodes.map((v) => ({\n    id: v.id,\n    title: v.title,\n    available: v.availableForSale,\n    price: toMoney(v.price),\n    compareAt: v.compareAtPrice ? toMoney(v.compareAtPrice) : null,\n    options: noOptions ? {} : Object.fromEntries(v.selectedOptions.map((o) => [o.name, o.value])),\n    image: v.image ? toImage(v.image, p.title) : undefined,\n  }));\n  return {\n    id: p.id,\n    handle: p.handle,\n    title: p.title,\n    vendor: p.vendor || undefined,\n    description: p.description || undefined,\n    images: p.images.nodes.map((i) => toImage(i, p.title)),\n    options: noOptions\n      ? []\n      : p.options.map((o) => ({\n          name: o.name,\n          values: o.optionValues.map(\n            (v): OptionValue => ({\n              value: v.name,\n              swatch: v.swatch && (v.swatch.color || v.swatch.image?.previewImage) ? { color: v.swatch.color ?? undefined, image: v.swatch.image?.previewImage?.url } : undefined,\n            }),\n          ),\n        })),\n    variants,\n  };\n}\n\n/** Maps a Storefront API cart's lines to litt's CartItems. */\nexport function toCartItems(cart: StorefrontCart): CartItem[] {\n  return cart.lines.nodes.map((line) => {\n    const m = line.merchandise;\n    const picks = m.selectedOptions.filter((o) => o.value !== \"Default Title\").map((o) => o.value);\n    const image = m.image ?? m.product.featuredImage;\n    return {\n      id: line.id,\n      variantId: m.id,\n      title: m.product.title,\n      variantTitle: picks.length ? picks.join(\" · \") : undefined,\n      quantity: line.quantity,\n      price: toMoney(line.cost.amountPerQuantity),\n      compareAt: line.cost.compareAtAmountPerQuantity ? toMoney(line.cost.compareAtAmountPerQuantity) : null,\n      image: image ? toImage(image, \"\") : undefined,\n    };\n  });\n}\n\n/* ----------------------------------------------------------------- fetching */\n\n/** One product by its handle, as litt's Product, or null when the store has none by that handle. */\nexport async function getProduct(storefront: Storefront, handle: string): Promise<Product | null> {\n  const data = await storefront.query<{ product: StorefrontProduct | null }>(\n    `query Product($handle: String!) { product(handle: $handle) { ${PRODUCT_FIELDS} } }`,\n    { handle },\n  );\n  return data.product ? toProduct(data.product) : null;\n}\n\n/** The store's products, newest first, as litt's Products. */\nexport async function getProducts(storefront: Storefront, first = 24): Promise<Product[]> {\n  const data = await storefront.query<{ products: { nodes: StorefrontProduct[] } }>(\n    `query Products($first: Int!) { products(first: $first, sortKey: CREATED_AT, reverse: true) { nodes { ${PRODUCT_FIELDS} } } }`,\n    { first },\n  );\n  return data.products.nodes.map(toProduct);\n}\n\n/** One collection by its handle: its title and its products, as litt's Products, or null. */\nexport async function getCollection(storefront: Storefront, handle: string, first = 24): Promise<{ title: string; products: Product[] } | null> {\n  const data = await storefront.query<{ collection: { title: string; products: { nodes: StorefrontProduct[] } } | null }>(\n    `query Collection($handle: String!, $first: Int!) { collection(handle: $handle) { title products(first: $first) { nodes { ${PRODUCT_FIELDS} } } } }`,\n    { handle, first },\n  );\n  return data.collection ? { title: data.collection.title, products: data.collection.products.nodes.map(toProduct) } : null;\n}\n\n/* ----------------------------------------------------------------- the cart's calls */\n\ntype CartResult = { cart: StorefrontCart | null; userErrors: { message: string }[] };\nconst cartCall = async (storefront: Storefront, name: string, mutation: string, variables: Record<string, unknown>) => {\n  const data = await storefront.query<Record<string, CartResult>>(mutation, variables);\n  const result = data[name]!;\n  if (result.userErrors.length) throw new ShopifyError(result.userErrors.map((e) => e.message).join(\" \"));\n  return result.cart;\n};\n\n/** A line to add: the variant's id and how many. */\nexport type CartLineInput = { merchandiseId: string; quantity: number };\n\n/** A cart by its id, or null when Shopify no longer has it. */\nexport async function getCart(storefront: Storefront, id: string): Promise<StorefrontCart | null> {\n  return (await storefront.query<{ cart: StorefrontCart | null }>(`query Cart($id: ID!) { cart(id: $id) { ${CART_FIELDS} } }`, { id })).cart;\n}\n\n/** Makes a cart with these lines. */\nexport const createCart = (storefront: Storefront, lines: CartLineInput[]) =>\n  cartCall(storefront, \"cartCreate\", `mutation Create($lines: [CartLineInput!]) { cartCreate(input: { lines: $lines }) { cart { ${CART_FIELDS} } userErrors { message } } }`, { lines });\n\n/** Adds lines to a cart; a variant already in it gets more. */\nexport const addCartLines = (storefront: Storefront, cartId: string, lines: CartLineInput[]) =>\n  cartCall(storefront, \"cartLinesAdd\", `mutation Add($id: ID!, $lines: [CartLineInput!]!) { cartLinesAdd(cartId: $id, lines: $lines) { cart { ${CART_FIELDS} } userErrors { message } } }`, { id: cartId, lines });\n\n/** Sets lines' quantities, by the lines' ids. */\nexport const updateCartLines = (storefront: Storefront, cartId: string, lines: { id: string; quantity: number }[]) =>\n  cartCall(storefront, \"cartLinesUpdate\", `mutation Update($id: ID!, $lines: [CartLineUpdateInput!]!) { cartLinesUpdate(cartId: $id, lines: $lines) { cart { ${CART_FIELDS} } userErrors { message } } }`, { id: cartId, lines });\n\n/** Takes lines out of a cart, by the lines' ids. */\nexport const removeCartLines = (storefront: Storefront, cartId: string, lineIds: string[]) =>\n  cartCall(storefront, \"cartLinesRemove\", `mutation Remove($id: ID!, $lineIds: [ID!]!) { cartLinesRemove(cartId: $id, lineIds: $lineIds) { cart { ${CART_FIELDS} } userErrors { message } } }`, { id: cartId, lineIds });\n",
      "target": "lib/litt/shopify.ts"
    }
  ],
  "docs": "Installed in lib/litt/shopify.ts. Make a client with createStorefront({ domain, token }) using the store's public Storefront token, and pass what getProduct returns to ProductCard, OptionPicker or the product block. For the cart, add shopify-cart.",
  "meta": {
    "version": "2026.10.24",
    "released": "2026-10-24",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
