Przejdź do głównej zawartości

Produkty

Kompletny przewodnik po wyświetlaniu produktów w storefront: lista produktów, strona produktu, wyszukiwanie i filtrowanie.

Dostępne hooki

HookTypOpis
useProductsQueryLista produktów z paginacją, sortowaniem i filtrami; wyszukiwanie pełnotekstowe przez parametr query
useProductQueryPojedynczy produkt po handle lub ID
useProductFiltersQueryDynamiczne 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).

import { fetchProducts } from '@/lib/graphql/server';

const data = await fetchProducts({ first: 20, after: '…' });

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)

KluczOpis
BEST_SELLINGNajlepiej sprzedające się (domyślny)
CREATED_ATData utworzenia
IDIdentyfikator
PRICECena
RELEVANCETrafność (przy wyszukiwaniu)
TITLETytuł alfabetycznie
UPDATED_ATData aktualizacji
VENDORProducent

Hook useProducts mapuje przyjazne nazwy na enum GraphQL:

Wartość frontenduGraphQL sortKeyreverse
'relevance'RELEVANCEfalse
'best-selling'BEST_SELLINGfalse
'price-low-to-high'PRICEfalse
'price-high-to-low'PRICEtrue
'title-asc'TITLEfalse
'title-desc'TITLEtrue
'created-desc'CREATED_ATtrue
'created-asc'CREATED_ATfalse

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

AttributeDefinitionobjectPełna referencja →

Filterable attribute definition

PoleTypOpis
displayOrderInt!Display order
filterValues[AttributeFilterValue!]Available filter values (for SELECT, CHECKBOX, RADIO, COLOR, BOOLEAN)
handleString!URL-friendly identifier
idID!Attribute ID
isFilterableBoolean!Whether attribute is filterable (admin toggle). For storefront UI rendering signal use `isUsableAsFilter` (computed).
isVisibleBoolean!Whether attribute is visible on product pages
nameString!Attribute name (e.g., "Color", "Size")
namespaceStringOptional grouping namespace (e.g. "inventory", "marketing", "content"). NULL = ungrouped. Helps distinguish custom ERP-data attributes from filterable catalog facets.
rangeBoundsAttributeRangeBoundsRange bounds (for NUMBER, CURRENCY)
typeAttributeType!Attribute data type
Dwie role AttributeDefinition

Ten sam typ AttributeDefinition pełni dwie role w storefront:

  1. Filtry produktów (tu opisane) — wartości jako facet w listach kategorii/kolekcji.
  2. Konfigurator produktu — wybieralne komponenty/dopłaty na stronie produktu, wysyłane jako attributeSelections przy cartAddLines. Obsługuje linkedVariantId → 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

Typy renderowane ze schematu (zero driftu)

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

PoleTypOpis
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.
averageRatingFloatAverage rating (1-5)
brandBrandCanonical 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.
compareAtPriceRangeProductPriceRangeCompare-at price range (Money pair). Null when no variant has a compareAtPrice.
compareAtPriceRangeWithConversionConvertedPriceRangeOpt-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.
createdAtDateTime!Creation timestamp
descriptionStringProduct description. Returns ready-to-render HTML by default; pass `format: TEXT` for plain text or `format: JSON` for the structured document.
descriptionHtmlStringHTML product description (DEPRECATED — use `description(format: HTML)`).
featuredImageImageFeatured/primary image
handleString!URL-friendly handle (slug)
idID!Unique identifier
imagesImageConnection!Product images (Relay Connection)
isAvailableBoolean!Whether product is available for sale (any variant in stock)
isPurchasableBoolean!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.
priceRangeProductPriceRangePrice range (Money pair). Default field — industry-standard schema. Null when the store restricts prices to signed-in customers — fetch via `variantPrices` after login.
priceRangeWithConversionConvertedPriceRangeOpt-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.
priceReductionPriceReductionInfoStatutory 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`.
primaryCategoryCategoryDefault category for breadcrumb/nav (= categories[0] by sortOrder). Null when the product is in no category.
recommendationsProductRecommendationsSimilar products recommendations
reviewCountIntNumber of reviews
seoSEOSEO metadata
stockTotalIntTotal stock across all variants
tags[String!]!Product tags
titleString!Product title
typeProductTypeEnum!Product type enum (PHYSICAL, DIGITAL, SERVICE, SUBSCRIPTION, GIFT_CARD)
updatedAtDateTime!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.
variantsProductVariantConnection!Product variants (Relay Connection)
vendorStringLegacy vendor/brand name (free-text). Preferred: `brand` field (canonical Brand entity).
visibilityProductVisibility!Catalog visibility — PUBLIC | HIDDEN | BUNDLE_ONLY

ProductVariant

ProductVariantobjectPełna referencja →

A purchasable unit of a product — one variant out of the product variants list, identified by its `id` and described by its options.

PoleTypOpis
attributes[EntityAttributeField!]!Variant custom field values (post-Opcja A unified custom fields).
availableStockIntAvailable 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).
barcodeStringBarcode (EAN/UPC/etc.) as set by the merchant. Null when not configured.
compareAtPriceMoneyStrike-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`.
compareAtPriceWithConversionPriceMoneyOptional opt-in: `compareAtPrice` with full conversion transparency.
idID!Stable identifier of the variant. Pass to `cartAddLines.variantId` to add the variant to a cart.
imageImageImage specific to this variant. Null when the variant inherits the parent product image.
isAvailableBoolean!True when the variant can be purchased right now (in stock, or backorder allowed by the merchant).
priceMoneyCurrent 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.
priceReductionPriceReductionInfoStatutory 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.
priceWithConversionPriceMoneyOptional 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).
skuStringStock-keeping unit code as set by the merchant. Null when not configured.
sortOrderIntDisplay position of this variant within the parent product list. Lower values come first.
storeAvailabilityStoreAvailabilityConnectionPer-location stock availability. Null for single-location shops. Resolved per-location with `near`, `locationType`, and `@inContext(preferredLocationId)` support.
titleString!Display title of the variant (e.g. "Large / Red"). Composed from the variant options.
weightWeightVariant 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

ProductTypeEnumenumPełna referencja →

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ą)

PriceMoneyobjectPełna referencja →

Price with full conversion transparency

PoleTypOpis
amountString!Price amount in display currency
baseAmountString!Original price in shop base currency
baseCurrencyCodeString!Shop base currency code
currencyCodeString!Display currency code (ISO 4217)
exchangeRateFloatExchange rate used (null if same currency)
isConvertedBoolean!Whether price was converted from different currency
marginAppliedFloatMargin applied by shop (e.g., 0.02 = 2%)
rateTimestampDateTimeWhen 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>
);
}
Pole descriptionHtml jest wycofywane

product.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

KontekstImportFunkcjeOpis
Server Component@/lib/graphql/serverfetch* (await)Async helpers (generowane z codegen), automatyczne wstrzykiwanie cookies (waluta, token)
Client Component@/lib/graphql/hooksuse* (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