Strona produktu z wariantami
UI (komponenty z hookami i JSX) jest specyficzne dla React / Next.js. W innym frameworku weź zapytanie z zakładki Raw i napisz własny widok.
Co zbudujesz
Stronę produktu (/products/[handle]): wybór wariantu (rozmiar / kolor), cena z przekreśleniem ceny promocyjnej, status dostępności i przycisk „Dodaj do koszyka".
Trzy kroki:
const { data } = useProduct(handle); // 1. pobierz produkt
const variants = data.product.variants.nodes; // 2. connection → tablica wariantów
await addToCart(variant.id, 1); // 3. dodaj wariant (backend resolwuje resztę)
Wymagania
- Skonfigurowany SDK i provider — Konfiguracja Next.js.
- Działający koszyk (
useCartActions) — Koszyk.
Krok 1 — Pobierz produkt po handle
handle to slug z adresu URL (/products/niebieska-koszulka). Wybierz kontekst renderowania:
- Server Component
- Client Component
- Raw (dowolny framework)
import { fetchProduct } from '@/lib/graphql/server';
const data = await fetchProduct({ id: '…', handle: '…' });
'use client';
import { useProduct } from '@/lib/graphql/hooks';
const { data, isLoading, error } = useProduct({ id: '…', handle: '…' });
// Działa w dowolnym frameworku (Vue, Svelte, vanilla JS, Node, Edge)
const QUERY = `query Product($id: ID, $handle: String) {
product(id: $id, handle: $handle) {
...ProductFull
}
}
fragment ProductFull on Product {
...ProductBase
images(first: 20) {
edges {
cursor
node {
...ImageFull
}
}
nodes {
...ImageFull
}
pageInfo {
...PageInfo
}
totalCount
}
variants(first: 100) {
edges {
cursor
node {
...ProductVariant
priceReduction {
...PriceReduction
}
}
}
nodes {
...ProductVariant
priceReduction {
...PriceReduction
}
}
pageInfo {
...PageInfo
}
totalCount
}
seo {
title
description
}
}
fragment ImageFull on Image {
id
url(transform: {maxWidth: 1600})
altText
width
height
thumbhash
}
fragment PageInfo on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
fragment PriceReduction on PriceReductionInfo {
lowestPriorPrice {
...Money
}
reductionStartedAt
referenceWindowStartedAt
referenceWindowTruncated
percentageOffLowestPrior
label
}
fragment Money on Money {
amount
currencyCode
}
fragment ProductBase on Product {
...ProductCard
description
descriptionHtml
stockTotal
type
visibility
attributes {
...AttributeValue
}
createdAt
updatedAt
}
fragment AttributeValue on EntityAttributeField {
id
definitionId
handle
name
namespace
type
value
isVisibleOverride
}
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 ProductVariant on ProductVariant {
id
title
sku
price {
...Money
}
compareAtPrice {
...Money
}
isAvailable
availableStock
image {
...ImageThumbnail
}
selectedOptions {
...SelectedOption
}
barcode
weight {
value
unit
}
sortOrder
}
fragment ImageThumbnail on Image {
id
url(transform: {maxWidth: 300})
altText
width
height
thumbhash
}
fragment SelectedOption on SelectedOption {
name
value
}`;
const res = await fetch(`${apiUrl}/storefront/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: QUERY, variables: { id: '…', handle: '…' }, }),
});
const { data } = await res.json();
Krok 2 — Rozpakuj listy (Relay Connection, nie tablica)
To najczęstsza pułapka: useProduct zwraca surowy wynik zapytania (bez normalizacji), a variants i images to Relay Connections, nie tablice. Wyciągnij listy przez .nodes (skrót schematu, równoważny edges.map(e => e.node)):
const variants = data.product.variants.nodes; // ProductVariant[]
const images = data.product.images.nodes; // Image[]
data.product.variants nie jest tablicą — to Relay Connection, więc indeksowanie ([0]) zwróci undefined. Zawsze rozpakuj przez .nodes (lub .edges). Inaczej niż useProducts (lista), useProduct nie normalizuje odpowiedzi za Ciebie.
Krok 3 — Komponent wariantu (React)
Ten komponent jest weryfikowany typami przeciw @doswiftly/storefront-sdk — błędny dostęp do pola wariantu lub zła sygnatura dodania do koszyka nie przejdą weryfikacji, dlatego ten przykład nie może zdryfować od API:
'use client';
import { useState } from 'react';
import type { ProductVariant, CartLineInput } from '@doswiftly/storefront-sdk';
// Picker wariantu produktu: wybór wariantu, cena z przekreśleniem ceny promocyjnej,
// status dostępności i dodanie do koszyka.
export function VariantPicker({
variants,
onAddToCart,
}: {
variants: ProductVariant[];
onAddToCart: (line: CartLineInput) => void | Promise<void>;
}) {
const [selected, setSelected] = useState(0);
const variant = variants[selected];
return (
<div>
{/* Wybór wariantu — selectedOptions to [{ name, value }] */}
<div>
{variants.map((v, idx) => (
<button key={v.id} aria-pressed={idx === selected} onClick={() => setSelected(idx)}>
{v.selectedOptions.map((o) => o.value).join(' / ')}
</button>
))}
</div>
{/* Cena wariantu + przekreślona cena promocyjna (opcjonalna) */}
<div>
<strong>
{variant.price.amount} {variant.price.currencyCode}
</strong>
{variant.compareAtPrice ? (
<s>
{variant.compareAtPrice.amount} {variant.compareAtPrice.currencyCode}
</s>
) : null}
</div>
{/* Dostępność wariantu */}
<p>{variant.isAvailable ? 'Dostępny' : 'Niedostępny'}</p>
{/* Dodanie do koszyka — CartLineInput przyjmuje tylko variantId + quantity.
Tytuł, cenę i obraz backend resolwuje z wariantu po stronie serwera. */}
<button
disabled={!variant.isAvailable}
onClick={() => onAddToCart({ variantId: variant.id, quantity: 1 })}
>
Dodaj do koszyka
</button>
</div>
);
}
Zwróć uwagę: onAddToCart buduje CartLineInput z samym variantId i quantity. Tytuł, cenę i obraz backend resolwuje z wariantu po stronie serwera — front ich nie przekazuje.
Krok 4 — Podłącz koszyk
Przekaż rozpakowane warianty do komponentu i podłącz onAddToCart do useCartActions. Sygnatura jest pozycyjna — addToCart(variantId, quantity?, attributeSelections?):
const { addToCart } = useCartActions();
const variants = data.product.variants.nodes;
<VariantPicker
variants={variants}
onAddToCart={(line) => addToCart(line.variantId, line.quantity)}
/>;
attributeSelections (trzeci, opcjonalny argument addToCart) służą do konfiguratora produktu — np. wybór komponentu z dopłatą. Orkiestracja koszyka (recovery, optymistyczne aktualizacje) — Koszyk.
Typy
Renderowane ze schematu GraphQL — nigdy nie rozjeżdżają się z API:
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. |
Powiązane
- Hook
useProduct, lista i wyszukiwanie — Produkty. - Koszyk i checkout — Koszyk → Checkout.
- Rdzeń SDK (klienty, store, middleware) — Referencja TypeScript SDK.
- Kontrakt operacji
Product— Referencja GraphQL API.