Product data
Every field of the Product object that useProduct, ProductCard and the product page hand you, with real example values.
Read this when you build a product page, a card or a slider and need to know what a Product looks like and which fields can be missing.
The type is Product from @salla.sa/twilight-theme-engine/types. Prices are numbers in the store's currency. The fields on this page match what the demo store's API returns today; names in the examples are translated to English.
Where products come from
A Product reaches your code in three ways. They are the same type but not the same data.
| Source | What you get |
|---|---|
product.list (cards, sliders, search, wishlist) | A light list item: one image, no options, skus, images or full detail flags. |
product.find (the product page loader) | The detail object: everything in the list item plus images, options, skus, brand, category, tags, rating, bundle and more. |
useProduct(initial) | The product you gave it, kept live. It returns { product, reload }. |
useProduct adds no fields. When the customer picks options it replaces price, sale_price, regular_price, is_on_sale, weight, sku and barcode, clears starting_price and sets is_out_of_stock, and reload() swaps in a fresh detail object. Pass { isPreview: true } while you only have a list item, so the hook re-seeds when the detail arrives.
ProductCard takes a Product (list item or detail). The product page puts the detail object in the loader data as product. ProductContextProvider and useProductContext() from @salla.sa/twilight-theme-engine/contexts share one product down your component tree: useProductContext() returns { product }, or null outside a provider. HookSlot does not read it, so a slot handler gets the product only through the slot's context.
Fields only the detail has
images, options, skus, bundle, included_products, notify_availability, barcode, base_currency_price, has_read_more, can_show_remained_quantity, has_size_guide, is_wallet_gift_card, supports_pickup and supports_delivery. On a list item they are undefined. Do not use them in a card without a fallback.
Read a product in a component
import { useProduct } from '@salla.sa/twilight-theme-engine/hooks/useProduct';
import { useMoney } from '@salla.sa/twilight-theme-engine/hooks/useMoney';
import type { Product } from '@salla.sa/twilight-theme-engine/types';
export function ProductSummary({ initial }: { initial: Product }) {
const { product } = useProduct(initial);
const { format } = useMoney();
return (
<div>
<img src={product.image?.url} alt={product.image?.alt ?? product.name} />
<h1>{product.name}</h1>
{product.is_on_sale ? (
<p>
<strong>{format(product.sale_price ?? product.price)}</strong> <s>{format(product.regular_price)}</s>
</p>
) : (
<p>{format(product.price)}</p>
)}
{product.is_out_of_stock && <p>Out of stock</p>}
</div>
);
}Example: a simple product on sale
The detail object of a product with no options, on sale (trimmed: description and images are shortened, images has 2 of its items).
Example: a product with options
The demo product "Light": one color option, a SKU per value, a 3D main image, and a starting_price lower than price.
A list item for the same kind of product is much smaller. Here is the real list item of a product with variants (description removed). Note the string ids, sale_price: null, the single image, and has_options instead of options.
Field reference
The tables are generated from Product in @salla.sa/twilight-theme-engine/types. "Detail" means the field is reliably present only on product.find. Nested objects (images, options, SKUs, category, brand) get their own tables. Prices on Product are plain numbers in currency; use useMoney().format to display them. For the { amount, currency } object, see shared types.
Product
| Field | Type | Description |
|---|---|---|
id | number | Product id. Number on products/{id}/details, but a numeric STRING on list responses (products, e.g. "677709975"). Typed number: use Number(product.id) when you compare ids from a list item. |
name | string | Product name in the current language. |
description | string | Product description, as HTML. May be empty. |
url | string | Absolute URL of the product page. |
promotion_title? | string | Badge text the merchant set. Can be "" or null. |
subtitle? | string | Short line under the name. |
type | ProductType | Product type. |
status | ProductStatus | Product availability status. |
weight? | string | null | Weight with its unit, as text. |
calories? | number | null | Calories, for food products. |
sku? | string | null | Merchant SKU. Can be "" or null. |
barcode? | string | null | Barcode. Can be "" or null. |
mpn? | string | null | Manufacturer part number. Can be "" or null. |
gtin? | string | null | Global trade item number. Can be "" or null. |
price | string | number | What the customer pays now. Show this when the product is not on sale. |
sale_price | number | null | The discounted price. null on list responses when the product is not on sale; 0 on products/{id}/details in the same case. Check is_on_sale rather than the truthiness of this field. |
regular_price | number | The price before the discount. |
starting_price? | number | null | Lowest SKU price of a product with variants, otherwise null. |
base_currency_price | number | { currency: string; amount: number; } | Price in the store's base currency. |
currency | string | Currency code of the prices. |
discount_percentage? | string | Declared but never sent by the Salla API (absent on list and detail). Compute it from regular_price and price. |
price_as_float? | number | Price as a plain float. |
price_as_float_for_payment? | number | Price as a plain float, as used for payment. |
currency_for_payment? | string | Currency code used for payment. |
quantity? | number | null | Stock on hand. -1 means UNLIMITED stock (what products/{id}/details sends for products without a tracked quantity), not "one short". List responses send null and expose unlimited_quantity instead. |
sold_quantity? | number | Units sold. Absent or null when not shown. |
max_quantity | number | Most the customer can add to the cart. Null on list responses. |
discount_ends? | string | When the discount ends. |
rating? | ProductRating | Reviews summary. Null on the demo store. |
category? | ProductCategory | The product's category. |
brand? | ProductBrand | The product's brand. |
tags? | ProductTag[] | The product's tags. Empty when there are none. |
image | ProductImage | The main image. On list items it has only url. |
images? | ProductImage[] | The image gallery. Detail responses only. |
options? | ProductOption[] | Options the customer picks, such as size or color. Detail responses only. |
skus? | ProductSku[] | Variant rows. Present only when requested with with[]=skus. |
notify_availability? | NotifyAvailability | "Notify me when available" settings and subscription state. Detail responses only. |
donation? | ProductDonation | Donation progress, for products of type 'donating'. |
is_taxable | boolean | Tax applies to the product. |
has_read_more | boolean | The description is long: collapse it behind "Read more". |
can_add_note | boolean | The add-to-cart form shows a note field. |
can_show_remained_quantity | boolean | Whether to show quantity to customers. |
can_show_sold? | boolean | Declared but never sent by the Salla API (absent on list and detail). |
can_upload_file | boolean | The add-to-cart form shows a file upload. |
has_custom_form | boolean | The merchant added a custom form to the product. |
has_metadata | boolean | The product has extra spec data. |
has_options? | boolean | The product has options. |
has_bundle_products? | boolean | The product is a bundle of other products. |
is_on_sale | boolean | A discount is active. |
is_hidden_quantity? | boolean | The quantity input is hidden (the customer buys one). Sent on products/{id}/details only. List responses use the misspelled is_hidded_quantity. |
is_hidded_quantity? | boolean | The API's own (misspelled) name for is_hidden_quantity on list responses. |
is_available | boolean | The product can be bought. |
is_in_wishlist? | boolean | Whether the product is in the wishlist. Prefer useWishlist over reading this. |
is_out_of_stock | boolean | The customer cannot buy it now. |
is_require_shipping | boolean | The product needs shipping. |
has_3d_image? | boolean | The product has a 3D image. |
has_size_guide | boolean | A size guide exists for the product. |
giftable? | boolean | The product can be bought as a gift. |
can_quick_buy? | boolean | Show a "buy now" button. |
show_availability? | boolean | Show the product's availability state. |
has_preorder_campaign? | boolean | A pre-order campaign is active. |
is_wallet_gift_card? | boolean | The product is a wallet gift card. Sent on products/{id}/details. |
supports_pickup? | boolean | The product can be picked up. |
supports_delivery? | boolean | The product can be delivered. |
included_products? | Product[] | null | Products bundled into this one (bundle products); null/absent otherwise. |
add_to_cart_label? | string | Custom add-to-cart button label. Not sent by the demo store. |
notes? | string | The customer's note for the product. |
digital_files_settings? | JsonObject | File settings of a digital product. |
preorder? | JsonObject | Pre-order details; preorder.label is shown as the card badge. |
rating
| Field | Type | Description |
|---|---|---|
count | number | Number of ratings. |
stars | number | Average star rating. |
category
| Field | Type | Description |
|---|---|---|
id? | number | Category id. |
name | string | Category name. Can be null on list responses. |
url | string | Category page URL. Can be null on list responses. |
icon? | string | CSS icon class of the category. |
image? | string | null | Category image URL, or null. |
sub_categories? | Category[] | Child categories. |
category.sub_categories
| Field | Type | Description |
|---|---|---|
id | string | number | Id as the API sends it, a hashed string such as "xzjvNQ". |
id_? | number | The raw numeric id, e.g. 868669273. |
name | string | Category name. |
description? | string | Category description. |
url | string | Full URL of the category page. |
icon? | string | null | CSS icon class, e.g. "sicon-store", or null. |
image? | string | null | Flat image URL string (NOT a nested ProductImage object), or null. Aligns with both the API response and Twig. |
products_count? | number | Number of products in the category. |
sub_categories? | Category[] | Child categories, possibly empty. |
items? | Category[] | null | Mirror of sub_categories on parent categories (an array, possibly empty); null on some root categories without children. |
brand
| Field | Type | Description |
|---|---|---|
id | number | Brand id. A slug string on the demo store. |
name? | string | Brand name. |
description? | string | Brand description. |
url? | string | Brand page URL. |
logo? | string | Brand logo URL. |
tags
| Field | Type | Description |
|---|---|---|
id? | number | Tag id. |
name | string | Tag text. |
url | string | Tag page URL. |
image
| Field | Type | Description |
|---|---|---|
id? | number | Image id. |
url? | string | Image URL. |
alt? | string | Alt text; usually the product name. |
video_url? | string | Video URL when the item is a video, otherwise null. |
video_type? | "video" | "youtube" | null | youtube for a YouTube link, video for an uploaded file, null for an image. |
type? | "image" | "video" | Media kind. The API also sends '3d-image' for a 3D main image. |
three_d_image_url? | string | .glb 3D model URL, or "" when none. |
main? | boolean | True for the main image. |
sort? | number | Position in the gallery. |
images
Same fields as image.
options
| Field | Type | Description |
|---|---|---|
id | number | Option id. |
name | string | Option label. |
type | string | Kind of input. Unknown types should be treated as a plain list. |
required | boolean | The customer must choose a value. |
values? | ProductOptionValue[] | Values in the older listing shape. Absent on products/{id}/details, which sends details instead. |
details? | ProductOptionDetail[] | Values on a detail response. |
options.values
| Field | Type | Description |
|---|---|---|
id | number | Value id. |
name | string | Value label. |
price? | number | Extra price when this value is picked. |
image_url? | string | Image URL of the value. |
image? | string | Image URL of the value. |
is_selected? | boolean | This value is preselected. |
options.details
| Field | Type | Description |
|---|---|---|
id | number | Value id. This is what a SKU's related_option_values lists. |
name | string | Value label. |
option_id | number | Id of the option the value belongs to. |
additional_price | number | Extra price when this value is picked. |
option_value | string | The raw value: a color hex, an image id, or "". |
image | string | Image URL, or the hex for a color. |
color | string | Color hex for color options. |
code | string | Merchant code of the value. |
is_out | boolean | This value is out of stock. |
is_default | 0 | 1 | 1 for the preselected value, otherwise 0. |
skus_availability | Record<string, boolean> | SKU id to in-stock flag, for the SKUs that use this value. |
skus
| Field | Type | Description |
|---|---|---|
id | number | SKU id. |
product_id | number | Id of the parent product. |
price | Money | SKU price as a { amount, currency } object. |
regular_price | Money | SKU price before any discount. |
sale_price | Money | null | Discounted SKU price, or null when not discounted. |
has_special_price | boolean | A discount applies to this SKU. |
stock_quantity | number | null | Stock on hand, or null when unlimited. |
unlimited_quantity | boolean | Stock is not tracked. |
is_default | boolean | This is the default SKU. |
related_options | number[] | Ids of the options the SKU is made of. |
related_option_values | number[] | Ids of the option values that select this SKU. |
skus.price
| Field | Type | Description |
|---|---|---|
amount | number | The amount as a number. |
currency | string | Currency code. |
formatted? | string | Ready-made text, when the API sends one. |
skus.regular_price
Same fields as skus.price.
skus.sale_price
Same fields as skus.price.
notify_availability
| Field | Type | Description |
|---|---|---|
channels | string[] | Channels the customer can be notified through. |
subscribed | boolean | The customer has asked to be notified. |
subscribed_options? | number[] | JsonObject | Options the customer subscribed to, when they subscribed per option. |
options? | boolean | Whether subscription is offered per option. |
donation
| Field | Type | Description |
|---|---|---|
target_message? | string | Message shown with the donation goal. |
collected_amount | number | Amount collected so far. |
target_amount | number | Amount the donation aims to collect. |
target_percent | number | Progress towards the goal, as a percentage. |
target_end_date? | string | When the donation campaign ends. |
can_donate | boolean | Donations are currently accepted. ProductCard hides the price when this is true. |
Allowed values and detail-only flags
type is one of product, service, group_products, codes, digital, food, donating, booking or financial_support. status is one of sale, out, out-and-notify or hidden. An option's type is text, textarea, radio, checkbox, select, image or color; the demo store also sends thumbnail and single-option. has_options is true on list items and is not on the detail object, so check options?.length on a detail.
What useProduct returns
| Field | Type | Description |
|---|---|---|
product | Product | Current product state (updates on option changes) |
reload | () => Promise<void> | Reload product data from API |
Variants by product type
The engine has no separate type per kind of product. It reads type and these fields:
| Kind | type | What is different |
|---|---|---|
| Donation | donating | donation is { target_message?, collected_amount, target_amount, target_percent, target_end_date?, can_donate }. ProductCard hides the price when donation.can_donate is true. |
| Digital | digital | digital_files_settings holds the file settings. Check is_require_shipping to know if it ships. |
| Booking | booking | The quantity is fixed to 1 in the add-to-cart form. |
| Group bundle | group_products | bundle and included_products hold the products it is made of, and has_bundle_products is the flag. |
| Service, food, codes, financial support | service, food, codes, financial_support | No extra fields in the type. calories is for food. |
Common tasks
Show the discount percentage
function discountPercent(product: Product): number {
if (!product.is_on_sale || !product.regular_price) return 0;
return Math.round((1 - Number(product.price) / product.regular_price) * 100);
}For regular_price: 199 and price: 188 it returns 6. Merchants often type the same text in promotion_title, so the badge below may already say it.
Is it in stock?
const { product } = useProduct(initial);
const canBuy = product.is_available && !product.is_out_of_stock;useProduct flips is_out_of_stock when the SDK reports that the chosen option combination is not available.
To show a stock message, treat quantity === -1 as unlimited:
const unlimited = product.quantity === -1;
const remaining = product.can_show_remained_quantity && !unlimited ? product.quantity : null;Which variant is selected?
The product object does not store the customer's choice. Keep the chosen value ids yourself (from your option inputs), then find the SKU:
import type { Product } from '@salla.sa/twilight-theme-engine/types';
function findSku(product: Product, selectedValueIds: number[]) {
return product.skus?.find(
(sku) =>
sku.related_option_values.length === selectedValueIds.length &&
selectedValueIds.every((id) => sku.related_option_values.includes(id))
);
}Then sku.price.amount is the price of that variant and sku.stock_quantity its stock (null means unlimited).
Image gallery URLs
const urls = product.images?.map((img) => img.url) ?? [product.image?.url];
const main = product.images?.find((img) => img.main)?.url ?? product.image?.url;Check type before you render an item as <img>: it can be a video (video_url) or a 3D image (three_d_image_url).
Show the badge
There is no badge field. The card chooses one text in this order: preorder.label, promotion_title, then (with withQuantity) the remaining quantity, then the out-of-stock label.
const badge = (typeof product.preorder?.label === 'string' && product.preorder.label) || product.promotion_title || null;Digital, booking and donation
if (product.type === 'donating' && product.donation) {
const { collected_amount, target_amount, target_percent, can_donate } = product.donation;
}
const needsShipping = product.is_require_shipping;
const fixedQuantity = product.type === 'booking' || product.is_hidden_quantity;Gotchas
- Ids differ by source. Lists send string ids, detail sends numbers, but the type says
number. Compare withString(a.id) === String(b.id), and passNumber(product.id)touseWishlist. sale_pricewhen there is no sale. With no sale it can benull,0or equal toprice. Branch onis_on_sale, not onsale_price.- Prices are numbers on
Productbut{ amount, currency }objects onskus[](andbase_currency_price). See shared types. null, empty and missing all happen.promotion_titlecan be""ornull;ratingisnullwithout reviews; list items leave out whole sections. Use optional chaining and?? [].optionscan be[]on a product without options, not missing. Check.length.- List items are partial.
imagehas noalt;category.url,brand.urlandtags[].urlcan benull;quantityandmax_quantityarenull;is_hidden_quantityis spelledis_hidded_quantity. descriptionis HTML written by the merchant. Render it withdangerouslySetInnerHTML, and pass it throughstripUnsafeHtmlfrom@salla.sa/twilight-theme-engine/utilsfirst. List items from the API may have it HTML-escaped (<p>), so use the detail description on a product page.- Live changes are browser-only. On the server
useProductreturns exactly the product you passed.
Next
useProductandProductCardproductAPI module forfind,listand the query keys- Product route
- Shared types for
Moneyand friends