Produkty
Kompletny przewodnik po wyświetlaniu produktów w storefront: lista produktów, strona produktu, wyszukiwanie i filtrowanie.
Dostępne hooki
| Hook | Typ | Opis |
|---|---|---|
useProducts | Query | Lista produktów z paginacją, sortowaniem i filtrami; wyszukiwanie pełnotekstowe przez parametr query |
useProduct | Query | Pojedynczy produkt po handle lub ID |
useProductFilters | Query | Dynamiczne filtry atrybutów (kolory, rozmiary, ceny, kategorie) |
Import
Client Components importują hooki use*; Server Components importują odpowiedniki fetch* (różne nazwy, nie te same):
// Client Components (React Query hooks — use*)
import { useProducts, useProduct, useProductFilters } from '@/lib/graphql/hooks';
// Server Components (async helpers — fetch*, generowane lokalnie z codegen)
import { fetchProducts, fetchProduct, fetchProductFilters } from '@/lib/graphql/server';
Wywołanie w kontekście (Server / Client / Raw)
To samo zapytanie w trzech kontekstach — przełącz zakładkę. Wszystkie trzy renderują się ze schematu (operacje GraphQL + realne eksporty z codegen), więc nigdy nie rozjeżdżają się z API. Operacje bez helpera SDK pokazują tylko zakładkę Raw (działa w dowolnym frameworku).
- Server Component
- Client Component
- Raw (dowolny framework)
import { fetchProducts } from '@/lib/graphql/server';
const data = await fetchProducts({ first: 20, after: '…' });
'use client';
import { useProducts } from '@/lib/graphql/hooks';
const { data, isLoading, error } = useProducts({ first: 20, after: '…' });
// Działa w dowolnym frameworku (Vue, Svelte, vanilla JS, Node, Edge)
const QUERY = `query Products($first: Int = 20, $after: String, $query: String, $sortKey: ProductSortKeys = RELEVANCE, $reverse: Boolean = false, $filters: [ProductFilter!]) {
products(
first: $first
after: $after
query: $query
sortKey: $sortKey
reverse: $reverse
filters: $filters
) {
edges {
node {
...ProductCard
}
cursor
}
nodes {
...ProductCard
}
pageInfo {
...PageInfo
}
totalCount
}
}
fragment PageInfo on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
fragment ProductCard on Product {
id
handle
title
vendor
categories {
id
handle
name
}
isAvailable
averageRating
reviewCount
tags
featuredImage {
...ImageCard
}
priceRange {
minVariantPrice {
...Money
}
maxVariantPrice {
...Money
}
}
compareAtPriceRange {
minVariantPrice {
...Money
}
maxVariantPrice {
...Money
}
}
priceReduction {
...PriceReduction
}
}
fragment ImageCard on Image {
id
url(transform: {maxWidth: 800})
altText
width
height
thumbhash
}
fragment Money on Money {
amount
currencyCode
}
fragment PriceReduction on PriceReductionInfo {
lowestPriorPrice {
...Money
}
reductionStartedAt
referenceWindowStartedAt
referenceWindowTruncated
percentageOffLowestPrior
label
}`;
const res = await fetch(`${apiUrl}/storefront/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: QUERY, variables: { first: 20, after: '…' }, }),
});
const { data } = await res.json();
useProducts
Pobiera listę produktów z paginacją, sortowaniem i filtrami. Automatycznie normalizuje odpowiedź GraphQL (edges/nodes) do płaskiej tablicy.
const { data, isLoading, error } = useProducts({
first: 20, // liczba produktów na stronę
after: cursor, // kursor paginacji
query: 'koszulka', // wyszukiwanie tekstowe
sortKey: 'price-low-to-high',
reverse: false,
});
// Znormalizowana odpowiedź:
// data.products -> Product[]
// data.pageInfo -> { hasNextPage, hasPreviousPage, startCursor, endCursor }
// data.totalCount -> number
Klucze sortowania (ProductSortKeys)
| Klucz | Opis |
|---|---|
BEST_SELLING | Najlepiej sprzedające się (domyślny) |
CREATED_AT | Data utworzenia |
ID | Identyfikator |
PRICE | Cena |
RELEVANCE | Trafność (przy wyszukiwaniu) |
TITLE | Tytuł alfabetycznie |
UPDATED_AT | Data aktualizacji |
VENDOR | Producent |
Hook useProducts mapuje przyjazne nazwy na enum GraphQL:
| Wartość frontendu | GraphQL sortKey | reverse |
|---|---|---|
'relevance' | RELEVANCE | false |
'best-selling' | BEST_SELLING | false |
'price-low-to-high' | PRICE | false |
'price-high-to-low' | PRICE | true |
'title-asc' | TITLE | false |
'title-desc' | TITLE | true |
'created-desc' | CREATED_AT | true |
'created-asc' | CREATED_AT | false |
useProduct
Pobiera pojedynczy produkt po handle (slug URL) lub id (format gid://). Automatycznie rozpoznaje format.
// Po handle (slug)
const { data, isLoading } = useProduct('niebieska-koszulka');
// Po ID
const { data } = useProduct('gid://Product/123');
// Odpowiedź zawiera pełny produkt:
// data.product.title
// data.product.variants.nodes -> ProductVariant[]
// data.product.images.nodes -> Image[]
// data.product.priceRange -> { minVariantPrice, maxVariantPrice }
// data.product.seo -> { title, description }
Filtrowanie (ProductFilterInput)
Filtry pozwalają zawęzić listę produktów po atrybutach, cenie i kategorii.
const { data } = useProducts({
first: 20,
filters: {
// Filtrowanie po atrybutach dynamicznych
attributes: [
{
attributeId: 'color', // ID lub handle atrybutu
values: ['red', 'blue'], // dla SELECT, CHECKBOX, RADIO, COLOR
},
{
attributeId: 'weight',
minValue: 100, // dla NUMBER, CURRENCY
maxValue: 500,
},
],
// Filtrowanie po zakresie cen
minPrice: 10.00,
maxPrice: 100.00,
// Filtrowanie po kategorii
categoryId: 'cat-electronics',
// Filtrowanie po kolekcji
collectionId: 'collection-123',
// Filtrowanie po dostepnosci
available: true,
},
});
AttributeFilterInput
interface AttributeFilterInput {
attributeId: string; // ID lub handle atrybutu
values?: string[]; // wartości dla SELECT, CHECKBOX, RADIO, COLOR, BOOLEAN
minValue?: number; // minimum dla NUMBER, CURRENCY
maxValue?: number; // maksimum dla NUMBER, CURRENCY
textSearch?: string; // wyszukiwanie tekstowe dla TEXT, TEXTAREA
}
useProductFilters
Pobiera dostępne filtry dla aktualnego kontekstu (kolekcja, kategoria, wyszukiwanie). Zwraca definicje atrybutów, zakresy cen i kategorie z licznikami produktów.
// Server Component
const filters = await fetchProductFilters({
input: { categoryId: 'cat-electronics' },
});
// filters.productFilters.attributes -> AttributeDefinition[]
// filters.productFilters.priceRange -> { min: Money, max: Money }
// filters.productFilters.categories -> CategoryFilterOption[]
// filters.productFilters.totalCount -> number // produkty w current context (PRZED faceted filters, Relay-aligned)
// filters.productFilters.availableCount -> number // boolean facet count dla `available` (exclude-self gdy input.available provided)
// filters.productFilters.activeCount -> number // liczba aktywnych filtrów (length of currentFilters)
AttributeDefinition
Filterable attribute definition
| Pole | Typ | Opis |
|---|---|---|
displayOrder | Int! | Display order |
filterValues | [AttributeFilterValue!] | Available filter values (for SELECT, CHECKBOX, RADIO, COLOR, BOOLEAN) |
handle | String! | URL-friendly identifier |
id | ID! | Attribute ID |
isFilterable | Boolean! | Whether attribute is filterable (admin toggle). For storefront UI rendering signal use `isUsableAsFilter` (computed). |
isVisible | Boolean! | Whether attribute is visible on product pages |
name | String! | Attribute name (e.g., "Color", "Size") |
namespace | String | Optional grouping namespace (e.g. "inventory", "marketing", "content"). NULL = ungrouped. Helps distinguish custom ERP-data attributes from filterable catalog facets. |
rangeBounds | AttributeRangeBounds | Range bounds (for NUMBER, CURRENCY) |
type | AttributeType! | Attribute data type |
Ten sam typ AttributeDefinition pełni dwie role w storefront:
- Filtry produktów (tu opisane) — wartości jako facet w listach kategorii/kolekcji.
- Konfigurator produktu — wybieralne komponenty/dopłaty na stronie produktu, wysyłane jako
attributeSelectionsprzycartAddLines. ObsługujelinkedVariantId→ child OrderItem przy checkout.
Konfigurator: Konfigurator produktu.
interface AttributeFilterValue {
id: string;
value: string; // "red", "xl"
label: string; // "Czerwony", "Extra Large"
productCount: number; // ile produktów ma tę wartość
swatch?: { // wizualny swatch dla COLOR
colorHex?: string; // "#FF5733"
image?: { // obraz wzoru (typ Image)
url: string;
altText?: string;
};
};
priceModifier?: Money; // modyfikator ceny
sortOrder: number;
}
Typy GraphQL
Bloki typów poniżej renderują się bezpośrednio ze schematu GraphQL (@doswiftly/storefront-operations) — nigdy nie rozjeżdżają się z rzeczywistym API. Pełna referencja: Types Reference.
Product
Product - main catalog item
| Pole | Typ | Opis |
|---|---|---|
attachments | [ProductAttachment!]! | Files publicly attached to the product — manuals, certificates, warranty cards. Material delivered after purchase does NOT appear here: it requires an entitlement and is reached through the order line item. Resolved lazily, so a product list that does not ask for this field pays no extra query. |
attributes | [EntityAttributeField!]! | This product's stored custom-field values — merchant-managed metadata such as manufacturer, licence, material or EAN. Only fields the merchant marked visible are returned. Pass `namespace` to fetch a single group. |
averageRating | Float | Average rating (1-5) |
brand | Brand | Canonical brand entity. Resolved via DataLoader (N+1 safe for list queries). Null when the product has no assigned brand. |
categories | [Category!]! | All categories the product belongs to (M2M via the ProductCategory junction). Sorted by junction.sortOrder ASC. Empty list when the product is in no category. |
compareAtPriceRange | ProductPriceRange | Compare-at price range (Money pair). Null when no variant has a compareAtPrice. |
compareAtPriceRangeWithConversion | ConvertedPriceRange | Opt-in: compare-at price range with conversion transparency. |
configuratorFields | [ConfiguratorField!]! | Configurable fields shown on the product page for the shopper to fill in or choose (combines shared template fields and product-specific ones). Pass `{ filledBy: CUSTOMER }` to return only the fields a shopper can edit. |
createdAt | DateTime! | Creation timestamp |
description | String | Product description. Returns ready-to-render HTML by default; pass `format: TEXT` for plain text or `format: JSON` for the structured document. |
descriptionHtml | String | HTML product description (DEPRECATED — use `description(format: HTML)`). |
featuredImage | Image | Featured/primary image |
handle | String! | URL-friendly handle (slug) |
id | ID! | Unique identifier |
images | ImageConnection! | Product images (Relay Connection) |
isAvailable | Boolean! | Whether product is available for sale (any variant in stock) |
isPurchasable | Boolean! | Whether the storefront should offer a direct purchase path (Add to cart / Buy now). Mirrors `visibility === PUBLIC`. BUNDLE_ONLY ("Komponent") products resolve to false — the PDP renders a Komponent banner explaining access is only via configurator. |
options | [ProductOption!]! | Per-product option definitions (Color, Size, …) with their available values. Use these to build a variant picker without aggregating `selectedOptions` manually. |
priceRange | ProductPriceRange | Price range (Money pair). Default field — industry-standard schema. Null when the store restricts prices to signed-in customers — fetch via `variantPrices` after login. |
priceRangeWithConversion | ConvertedPriceRange | Opt-in: price range with full conversion transparency (exchangeRate, baseCurrency, isConverted). Use for a currency-converter UI when the customer has a preferred currency different from the shop base. |
priceReduction | PriceReductionInfo | Statutory reference of the price reduction shown on product cards — computed for the variant behind `priceRange.minVariantPrice`. Null when that variant is not reduced or the reference cannot be proven. Use it wherever a listing shows `compareAtPriceRange`. |
primaryCategory | Category | Default category for breadcrumb/nav (= categories[0] by sortOrder). Null when the product is in no category. |
recommendations | ProductRecommendations | Similar products recommendations |
reviewCount | Int | Number of reviews |
seo | SEO | SEO metadata |
stockTotal | Int | Total stock across all variants |
tags | [String!]! | Product tags |
title | String! | Product title |
type | ProductTypeEnum! | Product type enum (PHYSICAL, DIGITAL, SERVICE, SUBSCRIPTION, GIFT_CARD) |
updatedAt | DateTime! | When the product catalogue definition last changed. Covers the whole product, not just its main record: variants, images, options, categories, tags, attachments and configurator fields all move this timestamp. Stock movements caused by orders, cart reservations and page views deliberately do NOT — so this is a trustworthy `lastmod` for sitemaps and a reliable trigger for re-fetching cached product content. |
variants | ProductVariantConnection! | Product variants (Relay Connection) |
vendor | String | Legacy vendor/brand name (free-text). Preferred: `brand` field (canonical Brand entity). |
visibility | ProductVisibility! | Catalog visibility — PUBLIC | HIDDEN | BUNDLE_ONLY |
ProductVariant
A purchasable unit of a product — one variant out of the product variants list, identified by its `id` and described by its options.
| Pole | Typ | Opis |
|---|---|---|
attributes | [EntityAttributeField!]! | Variant custom field values (post-Opcja A unified custom fields). |
availableStock | Int | Available stock — what the buyer can buy now (on-hand minus active reservations, never below 0). Null when the merchant has disabled stock tracking for this variant (e.g. digital, made-to-order). |
barcode | String | Barcode (EAN/UPC/etc.) as set by the merchant. Null when not configured. |
compareAtPrice | Money | Strike-through compare-at price when the variant is on sale. Null when the variant is not discounted; show the strike-through only when this is present and greater than `price`. |
compareAtPriceWithConversion | PriceMoney | Optional opt-in: `compareAtPrice` with full conversion transparency. |
id | ID! | Stable identifier of the variant. Pass to `cartAddLines.variantId` to add the variant to a cart. |
image | Image | Image specific to this variant. Null when the variant inherits the parent product image. |
isAvailable | Boolean! | True when the variant can be purchased right now (in stock, or backorder allowed by the merchant). |
price | Money | Current price of the variant in the buyer preferred currency (auto-converted from the shop base currency when multi-currency is enabled). Null when the store restricts prices to signed-in customers — fetch via `variantPrices` after login. |
priceReduction | PriceReductionInfo | Statutory reference of the current price reduction (EU price-indication rules). Null when the variant is not reduced, when the shop has not enabled price-history tracking, or when the reference cannot be proven — do not show a "lowest price" sentence then. |
priceWithConversion | PriceMoney | Optional opt-in: `price` with full conversion transparency (original shop-currency amount, exchange rate, conversion timestamp). Use when building a currency-converter UI. |
selectedOptions | [SelectedOption!]! | Option values that define this variant (e.g. Size = Large, Color = Red). |
sku | String | Stock-keeping unit code as set by the merchant. Null when not configured. |
sortOrder | Int | Display position of this variant within the parent product list. Lower values come first. |
storeAvailability | StoreAvailabilityConnection | Per-location stock availability. Null for single-location shops. Resolved per-location with `near`, `locationType`, and `@inContext(preferredLocationId)` support. |
title | String! | Display title of the variant (e.g. "Large / Red"). Composed from the variant options. |
weight | Weight | Variant weight with its unit. Stored in grams; pick a display unit on the storefront if needed. Null when the merchant has not set a weight. |
ProductTypeEnum
Product type classification. PHYSICAL = ships to an address (requires the shipping step). DIGITAL = delivered electronically. SERVICE = booking / appointment, no shipment. SUBSCRIPTION = recurring billing. GIFT_CARD = issues a redeemable card on completion.
| Wartość | Opis |
|---|---|
DIGITAL | |
GIFT_CARD | |
PHYSICAL | |
SERVICE | |
SUBSCRIPTION |
PriceMoney (opt-in, z konwersją walutową)
Price with full conversion transparency
| Pole | Typ | Opis |
|---|---|---|
amount | String! | Price amount in display currency |
baseAmount | String! | Original price in shop base currency |
baseCurrencyCode | String! | Shop base currency code |
currencyCode | String! | Display currency code (ISO 4217) |
exchangeRate | Float | Exchange rate used (null if same currency) |
isConverted | Boolean! | Whether price was converted from different currency |
marginApplied | Float | Margin applied by shop (e.g., 0.02 = 2%) |
rateTimestamp | DateTime | When the exchange rate was fetched |
Przykłady kodu
Lista produktów z sortowaniem
'use client';
import { useProducts } from '@/lib/graphql/hooks';
import { ProductCard } from '@/components/product/product-card';
import { useState } from 'react';
export function ProductList() {
const [sortKey, setSortKey] = useState('best-selling');
const { data, isLoading, error } = useProducts({
first: 20,
sortKey,
});
if (isLoading) return <div>Ładowanie produktów...</div>;
if (error) return <div>Błąd: {error.message}</div>;
return (
<div>
{/* Sortowanie */}
<select value={sortKey} onChange={(e) => setSortKey(e.target.value)}>
<option value="best-selling">Najpopularniejsze</option>
<option value="price-low-to-high">Cena: rosnąco</option>
<option value="price-high-to-low">Cena: malejąco</option>
<option value="title-asc">Nazwa: A-Z</option>
<option value="created-desc">Najnowsze</option>
</select>
{/* Siatka produktów */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{data?.products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{/* Paginacja */}
{data?.pageInfo.hasNextPage && (
<button>Załaduj więcej</button>
)}
</div>
);
}
Strona pojedynczego produktu
'use client';
import { useProduct } from '@/lib/graphql/hooks';
import { useCartActions } from '@/hooks/use-cart-actions';
import { sanitizeHtml } from '@doswiftly/storefront-sdk';
import { useState } from 'react';
export function ProductPage({ handle }: { handle: string }) {
const { data, isLoading } = useProduct(handle);
const { addToCart, isLoading: isAdding } = useCartActions();
const [selectedVariant, setSelectedVariant] = useState(0);
if (isLoading) return <div>Ładowanie...</div>;
const product = data?.product;
if (!product) return <div>Produkt nie znaleziony</div>;
const variant = product.variants.nodes[selectedVariant];
const handleAddToCart = async () => {
// Pozycyjna sygnatura: (variantId, quantity) — backend resolwuje resztę
// (cena, tytuł, obraz) z wariantu, nie przekazujesz ich ręcznie.
await addToCart(variant.id, 1);
};
return (
<div>
<h1>{product.title}</h1>
{/* Galeria zdjęć */}
<div className="grid grid-cols-2 gap-2">
{product.images.nodes.map((img) => (
<img key={img.id} src={img.url} alt={img.altText || ''} />
))}
</div>
{/* Wybór wariantu */}
<div>
{product.variants.nodes.map((v, idx) => (
<button
key={v.id}
onClick={() => setSelectedVariant(idx)}
className={idx === selectedVariant ? 'border-2 border-primary' : ''}
>
{v.selectedOptions.map((o) => o.value).join(' / ')}
</button>
))}
</div>
{/* Cena */}
<div>
<span className="text-2xl font-bold">
{variant.price.amount} {variant.price.currencyCode}
</span>
{variant.compareAtPrice && (
<span className="line-through text-muted-foreground ml-2">
{variant.compareAtPrice.amount} {variant.compareAtPrice.currencyCode}
</span>
)}
</div>
{/* Dostępność */}
<p>{variant.isAvailable ? 'Dostępny' : 'Niedostępny'}</p>
{/* Dodaj do koszyka */}
<button
onClick={handleAddToCart}
disabled={!variant.isAvailable || isAdding}
>
{isAdding ? 'Dodawanie...' : 'Dodaj do koszyka'}
</button>
{/* Opis — `description` zwraca gotowy HTML (domyślny format).
Owiń w sanitizeHtml z SDK przed wstawieniem. */}
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(product.description) }} />
</div>
);
}
descriptionHtml jest wycofywaneproduct.descriptionHtml działa, ale jest oznaczone @deprecated (usunięcie po 2026-09-02). Używaj product.description — zwraca gotowy do renderu HTML (format domyślny). Potrzebujesz czystego tekstu (np. podgląd/snippet)? Zażądaj description(format: TEXT) (do meta tagów SEO użyj dedykowanych pól seo). Szczegóły: Zapytania — treść rich-text.
Server vs Client
| Kontekst | Import | Funkcje | Opis |
|---|---|---|---|
| Server Component | @/lib/graphql/server | fetch* (await) | Async helpers (generowane z codegen), automatyczne wstrzykiwanie cookies (waluta, token) |
| Client Component | @/lib/graphql/hooks | use* (hook) | React Query hooks z automatycznym kluczem cache zawierającym walutę |
Server Components używają funkcji fetch* (fetchProducts, fetchProduct, fetchProductFilters) wywoływanych przez await. Client Components używają hooków use* (useProducts, useProduct, useProductFilters). Różnią się nazwą (fetch* vs use*), importem i sposobem wywołania (async/await vs hook reaktywny).
Filtrowanie po marce i nawigacja facetowa
Pobieranie dostępnych marek (facets)
// Server Component — pobranie filtrów wraz z markami
const filters = await fetchProductFilters({
input: { categoryId: 'cat-figurki' },
});
// filters.productFilters.brands -> BrandFilterValue[]
// Każdy element: { id, name, handle, logo: { url, altText } | null, productCount }
Pełny przykład: katalog z filtrem marki
'use client';
import { useProducts, useProductFilters } from '@/lib/graphql/hooks';
import { useState } from 'react';
export function BrandCatalog({ categoryId }: { categoryId: string }) {
const [selectedBrandHandle, setSelectedBrandHandle] = useState<string | null>(null);
// Pobierz dostępne marki dla kategorii
const { data: filtersData } = useProductFilters({
input: { categoryId },
});
// Buduj filtry — gdy brand wybrany, dodaj do tablicy filters[]
const filters = selectedBrandHandle
? [{ brand: { handle: selectedBrandHandle } }]
: [];
const { data, isLoading } = useProducts({
first: 20,
filters,
});
const brands = filtersData?.productFilters?.brands ?? [];
return (
<div className="flex gap-6">
{/* Sidebar z markami */}
<aside>
<h3>Marka</h3>
<button
onClick={() => setSelectedBrandHandle(null)}
className={!selectedBrandHandle ? 'font-bold' : ''}
>
Wszystkie ({filtersData?.productFilters?.totalCount ?? 0})
</button>
{brands.map((brand) => (
<button
key={brand.id}
onClick={() => setSelectedBrandHandle(brand.handle)}
className={selectedBrandHandle === brand.handle ? 'font-bold' : ''}
>
{brand.logo?.url && (
<img
src={brand.logo.url}
alt={brand.logo.altText ?? brand.name}
className="w-4 h-4 inline mr-1"
/>
)}
{brand.name} ({brand.productCount})
</button>
))}
</aside>
{/* Lista produktów */}
<main>
{isLoading ? (
<div>Ładowanie...</div>
) : (
<div className="grid grid-cols-3 gap-4">
{data?.products.map((product) => (
<div key={product.id}>
<h4>{product.title}</h4>
{product.brand && (
<span className="text-sm text-muted-foreground">
{product.brand.name}
</span>
)}
</div>
))}
</div>
)}
</main>
</div>
);
}
Agregacja atrybutów TEXT i TEXTAREA
Pole attributes[].filterValues zwraca wartości również dla atrybutów tekstowych (TEXT, TEXTAREA). Pozwala to budować filtry facet-nav dla nieskategoryzowanych cech (np. Producent jako pole tekstowe, Materiał, Licencja) bez konwersji na typ SELECT:
const { data } = useProductFilters({ input: { categoryId } });
// Atrybuty TEXT/TEXTAREA mają teraz filterValues z unikalnymi wartościami i licznikami
const textAttributes = data?.productFilters?.attributes?.filter(
(attr) => attr.type === 'TEXT' || attr.type === 'TEXTAREA'
) ?? [];
// textAttributes[0].filterValues -> [{ value: "Funko", productCount: 42 }, ...]
Jeśli zależy Ci na ustrukturyzowanym filtrze z logiką wyszukiwania, rozważ konwersję atrybutu na typ SELECT — szczegóły w dokumentacji Marki produktów.
Następne kroki
- Koszyk — Dodawanie produktów do koszyka
- Filtrowanie — Kolekcje i kategorie produktów