Skip to content
Twilight React Playground
ثيم رائدaren

ProductOption, ProductSku and friends

interfaceAdvancedlive demo

Describes a product's options and their choices, the SKU variants those choices select, back-in-stock alerts and donation progress.

import { ProductOption, ProductOptionDetail, ProductOptionValue, ProductSku, NotifyAvailability, ProductDonation } from '@salla.sa/twilight-theme-engine/types';

In plain words

A chair might come in three colors and two sizes. Each of those is an option (ProductOption), and each option has choices (ProductOptionDetail, such as "Red"). A SKU (ProductSku) is one combination of choices, with its own price and stock.

Only the details of a product carry this. findSku in the example finds the SKU for the choices a shopper made, and the live demo runs it on real demo store products.

Signature

interface ProductOption {
  id: number;  name: string;  required: boolean;
  type: 'text' | 'textarea' | 'radio' | 'checkbox' | 'select' | 'image' | 'color' | string;
  details?: ProductOptionDetail[];   // what products/{id}/details sends
  values?: ProductOptionValue[];     // the listing shape; absent from details
}

interface ProductOptionDetail {
  id: number;  option_id: number;  name: string;
  additional_price: number;
  option_value: string;  image: string;  color: string;  code: string;
  is_out: boolean;
  is_default: 0 | 1;
  skus_availability: Record<string, boolean>;   // sku id → available
}

interface ProductSku {
  id: number;  product_id: number;
  price: Money;  regular_price: Money;  sale_price: Money | null;  has_special_price: boolean;
  stock_quantity: number | null;  unlimited_quantity: boolean;
  is_default: boolean;
  related_options: number[];         // option ids
  related_option_values: number[];   // choice (ProductOptionDetail) ids
}

interface ProductOptionValue {
  id: number;  name: string;  price?: number;  image_url?: string;  image?: string;  is_selected?: boolean;
}

interface NotifyAvailability {
  channels: ('sms' | 'email' | string)[];
  subscribed: boolean;
  subscribed_options?: number[] | JsonObject;
  options?: boolean;
}

interface ProductDonation {
  collected_amount: number;  target_amount: number;  target_percent: number;
  can_donate: boolean;  target_message?: string;  target_end_date?: string;
}

Try it live

A demo store product's options[].details and skus: choose one value per option and the matching ProductSku is found by its related_option_values.Try this: choose a value for every option and compare the SKU price with the product price. Product 2 has options that pick no variant; product 4 has choices marked out.
Storefront canvas · en · LTR

Loading the latest products…

Controls
What a theme writes
import type { Product, ProductSku } from '@salla.sa/twilight-theme-engine/types';

/**
 * The variant for the chosen values, or undefined until every option that
 * takes part in a variant has one. `chosen` maps an option id to a choice id.
 */
export function findSku(product: Product, chosen: Record<number, number>): ProductSku | undefined {
  const skus = product.skus ?? []; // only products/{id}/details sends skus
  const optionIds = [...new Set(skus.flatMap((sku) => sku.related_options))];
  const picked = optionIds.map((optionId) => chosen[optionId]);
  if (picked.some((choiceId) => choiceId === undefined)) return undefined;
  return skus.find((sku) => picked.every((choiceId) => sku.related_option_values.includes(choiceId)));
}

Example

app/lib/findSku.ts
import type { Product, ProductSku } from '@salla.sa/twilight-theme-engine/types';

/**
 * The variant for the chosen values, or undefined until every option that
 * takes part in a variant has one. `chosen` maps an option id to a choice id.
 */
export function findSku(product: Product, chosen: Record<number, number>): ProductSku | undefined {
  const skus = product.skus ?? [];
  const optionIds = [...new Set(skus.flatMap((sku) => sku.related_options))];
  const picked = optionIds.map((optionId) => chosen[optionId]);
  if (picked.some((choiceId) => choiceId === undefined)) return undefined;
  return skus.find((sku) => picked.every((choiceId) => sku.related_option_values.includes(choiceId)));
}

How it behaves

  • Only product.find and product.queries.detail (products/{id}/details) return options[].details and skus. A list card has neither, but its has_options says whether the product has options.

  • related_options names the options that form variants. Options a shopper types into (textarea and number on the demo store) are not in it and send details: [].

  • On the demo store a SKU without a stock limit has stock_quantity: null and unlimited_quantity: true, and a choice whose SKU is out of stock has is_out: true.

  • The engine AddToCartForm does not match SKUs itself: it hands product.options to Salla's product options web component and passes notify_availability (channels, subscribed_options) to the add-to-cart button. useProduct then follows the SDK's price-updated events.

  • ProductDonation is the donation progress of a donating product; ProductCard adds an s-product-card-donation class when a product has one.

Gotchas

  • ProductOption.type lists "radio", "checkbox" and "select", but the demo store sends "single-option", "multiple-options", "thumbnail", "color", "country", "textarea" and "number". The trailing | string lets any value compile, so a switch on the listed names silently matches nothing.

  • values (ProductOptionValue) is absent from details: the choices are in details. Code written for values finds no choices on a product page.

  • is_default is 0 | 1 on a choice but a boolean on a SKU. choice.is_default === true is always false; test choice.is_default === 1.

  • Product-level quantity was -1 in details for every demo store product checked, including ones whose SKUs have 5 or 0 left. Read stock from the chosen SKU.

  • notify_availability.subscribed_options arrives as null on the demo store, which its type (number[] | JsonObject, optional) does not allow. Test it with != null.

Related

Source and docs