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

ProductCard

componentBeginnerserverbrowserlive demo

The product tile of every grid: image, badge, name, price, rating, wishlist heart and a real Add to cart button, in five layouts.

import { ProductCard, ProductCardProps, ProductCardLayout } from '@salla.sa/twilight-theme-engine/components/product';

In plain words

A component is a function that returns a piece of page. ProductCard is the one that draws a single product: give it one product object, the kind the Salla API returns, and it draws the picture, the name, the price and the buttons.

The things you pass to a component are its props, written like HTML attributes: <ProductCard product={item} layout="horizontal" />. layout picks one of five looks, and a few on/off props add a shadow, a stock badge, or hide the Add to cart button.

The buttons are live. Add to cart talks to Salla and adds the product to the shopper's cart; the heart toggles the wishlist.

Signature

const ProductCard: React.MemoExoticComponent<(props: ProductCardProps) => JSX.Element>

type ProductCardLayout = 'vertical' | 'horizontal' | 'fullImage' | 'minimal' | 'special';

interface ProductCardProps {
  product: Product;
  layout?: ProductCardLayout;   // 'vertical'
  className?: string;
  withShadow?: boolean;         // false
  withQuantity?: boolean;       // false: "Remained N" / "Out of Stock" badge
  withoutAddButton?: boolean;   // false
  index?: number;               // 0: animation delay of index × 100ms
  imagePriority?: boolean;      // false: eager image for the first cards
  sizes?: string;               // the image sizes hint
}

Try it live

The store's newest products, each drawn by the engine's ProductCard. Add to cart is the real Salla button.Try this: switch the layout to horizontal and watch the heart button move from the image to the footer; then tick withoutAddButton.
Real requests to the demo store
Storefront canvas · en · LTR

Loading products…

Controls
withShadow
withQuantityShows "Remained N" or "Out of Stock" when no promotion title takes the badge.
withoutAddButton
What a theme writes
import { useQuery } from '@tanstack/react-query';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { ProductCard } from '@salla.sa/twilight-theme-engine/components/product';

export function LatestProducts() {
  const { data } = useQuery(product.queries.list({ source: 'latest', perPage: 8 }));
  return (
    <div className="s-products-list-wrapper s-products-list-vertical-cards">
      {data?.items.map((item, index) => (
        <ProductCard
          key={item.id}
          product={item}
          index={index}
          imagePriority={index < 2}
        />
      ))}
    </div>
  );
}

Example

app/components/LatestProducts.tsx
import { useQuery } from '@tanstack/react-query';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { ProductCard } from '@salla.sa/twilight-theme-engine/components/product';

export function LatestProducts() {
  const { data } = useQuery(product.queries.list({ source: 'latest', perPage: 8 }));
  return (
    <div className="s-products-list-wrapper s-products-list-vertical-cards">
      {data?.items.map((item, index) => (
        <ProductCard key={item.id} product={item} withShadow index={index} imagePriority={index < 2} />
      ))}
    </div>
  );
}

How it behaves

  • Not lazy: ProductCard is a plain memo export and needs no <Suspense>. It reads useMoney, useWishlist, useAsset, useNumber, useTranslation and useTwilight, so it must render inside TwilightProvider.

  • Price: sale_price with the regular_price struck through when is_on_sale, else "Starting from" starting_price, else price, all through useMoney().format. A donation product (donation.can_donate) shows no price.

  • Badge, first match wins: preorder.label, promotion_title, then with withQuantity "Remained N" (quantity) or "Out of Stock" (is_out_of_stock). fullImage and minimal never show a badge; special adds a remaining-quantity pie when quantity is set.

  • The heart sits on the image for vertical, minimal and special, and in the footer next to Add to cart for horizontal and fullImage. The footer is gone with withoutAddButton, and so is their heart.

  • Add to cart is SallaAddProductButton (the Salla web component) with the product id, status, type and pre-order flag: the SDK sends the request and shows its own toast.

  • Image: srcset widths 150/300/450/600 with sizes of (min-width: 768px) 25vw, 50vw, or (min-width: 1024px) 50vw, 100vw for fullImage and minimal. Pass sizes when your grid has other columns. The fit class is s-product-card-image-<fit>, from store.settings.product.fit_type, else cover.

  • Links are plain <a href={product.url}>. They navigate inside the app because TwilightProvider intercepts link clicks (client.interceptLinks, on by default).

  • Every engine block that shows products (ProductsSlider, FixedProducts, FeaturedProductsStyle2 and 3) and the product listing page render this component, so replacing it through the registry (product:card) changes them all.

  • @salla.sa/twilight-theme-engine/product is the same module under a shorter name; the reference theme imports the card from there.

Gotchas

  • Replacing the card with registry.override('product:card', MyCard) alone does nothing: ProductCard uses the key only when registry.getOriginal('product:card') is not null, and override records an original only when the key already exists. Nothing in the engine registers it, so call registry.register('product:card', ProductCard) first, then override (src/components/product/ProductCard.tsx; packages/theme-tania/app/router.tsx does exactly this). See registry.

  • Your replacement cannot render the engine ProductCard inside itself: that call resolves the key again, finds your card, and recurses. To restyle rather than replace, wrap the engine card in your own component and use yours in your grids, as the reference theme does in app/components/product/ProductCard.tsx.

  • The registry lookup runs once per mounted card (useMemo with no dependencies). Register at module scope in app/router.tsx, before anything renders.

  • A product with quantity: 0 in the special layout prints a stray "0": the pie is guarded with isSpecial && product?.quantity && (…), and React renders the number 0. A rating of 0 stars does the same in every layout, through product.rating?.stars && (…).

  • Salla's placeholder picture is cropped like a real one. The card means to show it with contain, but it detects it with useAsset().isPlaceholder(), which always returns false (its placeholder URL is hard-coded to null).

Related

Source and docs