Konfigurator produktu
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
Konfigurator na stronie produktu (kopiarka z finiszerem, podajnikiem i materiałami eksploatacyjnymi): pola do wyboru z dopłatami, komponenty fizyczne z magazynu, pod-komponenty pokazywane dopiero po wybraniu opcji rodzica — i dodanie skonfigurowanego produktu do koszyka jedną mutacją.
Trzy kroki:
const fields = data.product.configuratorFields; // 1. pobierz pola konfiguratora
const visible = fields.filter((f) => // 2. filtruj pod-komponenty warunkowe
isConfiguratorFieldVisible(f, { selectedOptionIds }),
);
await addToCart(variant.id, 1, selections); // 3. dodaj z selekcjami klienta
Wymagania
- Skonfigurowany SDK i provider — Konfiguracja Next.js.
- Działający koszyk (
useCartActions) — Koszyk.
Krok 1 — Pobierz produkt z polami konfiguratora
Operacja ProductConfigurator zwraca produkt razem z polami konfiguratora w jednym zapytaniu (filledBy: CUSTOMER — tylko pola, które wypełnia kupujący):
- Raw (dowolny framework)
Brak gotowego helpera SDK dla tej operacji — użyj raw operation (działa w każdym frameworku).
// Działa w dowolnym frameworku (Vue, Svelte, vanilla JS, Node, Edge)
const QUERY = `query ProductConfigurator($handle: String!, $filledBy: AttributeFillingMode = CUSTOMER) {
product(handle: $handle) {
...ProductFull
configuratorFields(filter: {filledBy: $filledBy}) {
...ConfiguratorField
}
}
}
fragment ConfiguratorField on ConfiguratorField {
id
name
handle
description
type
filledBy
pricingMode
required
isVisible
sortOrder
minValue
maxValue
options {
...ConfiguratorOption
}
visibleIf {
fieldId
operator
optionIds
value
}
}
fragment ConfiguratorOption on ConfiguratorOption {
id
value
label
sortOrder
colorHex
surchargeAmount
surchargeType
isDefault
linkedVariant {
...LinkedVariantSummary
}
}
fragment LinkedVariantSummary on LinkedVariantSummary {
id
productId
title
sku
description
availableStock
isAvailable
trackQuantity
}
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: { handle: '…', filledBy: /* … */ }, }),
});
const { data } = await res.json();
Produkt bez konfiguratora zwraca pustą listę configuratorFields — komponent z kroku 2 po prostu niczego nie wyrenderuje, więc jeden kod obsługuje oba przypadki.
Krok 2 — Komponent konfiguratora (React)
Komponent renderuje pola posortowane po sortOrder, rozróżnia typy (jednokrotny wybór, wielokrotny wybór CHECKBOX, pola tekstowe/liczbowe) i pokazuje dopłaty przy etykietach. Kod jest weryfikowany typami przeciw @doswiftly/storefront-sdk:
'use client';
import { useState } from 'react';
import {
isConfiguratorFieldVisible,
type CartAttributeSelectionInput,
type ConfiguratorField,
} from '@doswiftly/storefront-sdk';
// Stan odpowiedzi: zaznaczone opcje per pole + wolny tekst per pole.
type SelectedByField = Record<string, string[]>;
type TextByField = Record<string, string>;
// Widoczność z kaskadą: ukrycie rodzica unieważnia odpowiedzi jego pod-pól,
// co może ukryć kolejne pola. Pętla stabilizuje się w głębokości łańcucha
// (serwer i tak waliduje autorytatywnie — to tylko wygoda renderowania).
function resolveVisible(fields: ConfiguratorField[], selected: SelectedByField, texts: TextByField) {
let sel = selected;
let txt = texts;
for (;;) {
const visible = new Set(
fields
.filter((f) => isConfiguratorFieldVisible(f, { selectedOptionIds: sel, textValues: txt }))
.map((f) => f.id),
);
const nextSel = Object.fromEntries(Object.entries(sel).filter(([id]) => visible.has(id)));
const nextTxt = Object.fromEntries(Object.entries(txt).filter(([id]) => visible.has(id)));
const stable =
Object.keys(nextSel).length === Object.keys(sel).length &&
Object.keys(nextTxt).length === Object.keys(txt).length;
if (stable) return { visible, selected: nextSel, texts: nextTxt };
sel = nextSel;
txt = nextTxt;
}
}
// Dopłata przy etykiecie opcji. Komponent magazynowy (linkedVariant) nie nosi
// dopłaty — jego cena pochodzi z wariantu i wraca w koszcie pozycji koszyka.
function surchargeHint(option: ConfiguratorField['options'][number]): string {
if (option.surchargeAmount == null || option.surchargeAmount === 0) return '';
if (option.surchargeType === 'PERCENT') return ` (+${option.surchargeAmount / 1000}%)`;
return ` (+${(option.surchargeAmount / 100).toFixed(2)})`;
}
export function ProductConfigurator({
fields,
onSelectionsChange,
}: {
fields: ConfiguratorField[];
/** Gotowe selekcje — przekaż je jako 3. argument `addToCart(variantId, qty, selections)`. */
onSelectionsChange: (selections: CartAttributeSelectionInput[]) => void;
}) {
const [selected, setSelected] = useState<SelectedByField>({});
const [texts, setTexts] = useState<TextByField>({});
const eff = resolveVisible(fields, selected, texts);
const visibleFields = [...fields]
.sort((a, b) => a.sortOrder - b.sortOrder)
.filter((f) => eff.visible.has(f.id));
// Selekcje budujemy WYŁĄCZNIE z pól widocznych po kaskadzie — odpowiedź pola
// ukrytego serwer odrzuca kodem ATTRIBUTE_CONDITION_NOT_MET.
const emit = (sel: SelectedByField, txt: TextByField) => {
const next = resolveVisible(fields, sel, txt);
const selections: CartAttributeSelectionInput[] = [];
for (const field of fields) {
if (!next.visible.has(field.id)) continue;
const ids = next.selected[field.id] ?? [];
if (field.type === 'CHECKBOX' && ids.length > 0) {
selections.push({ attributeDefinitionId: field.id, optionIds: ids });
} else if (ids.length > 0) {
selections.push({ attributeDefinitionId: field.id, optionId: ids[0] });
} else if (next.texts[field.id]) {
selections.push({ attributeDefinitionId: field.id, textValue: next.texts[field.id] });
}
}
onSelectionsChange(selections);
};
const pick = (fieldId: string, optionId: string, multi: boolean) => {
const current = selected[fieldId] ?? [];
const nextIds = multi
? current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId]
: [optionId];
const sel = { ...selected, [fieldId]: nextIds };
setSelected(sel);
emit(sel, texts);
};
const type = (fieldId: string, value: string) => {
const txt = { ...texts, [fieldId]: value };
setTexts(txt);
emit(selected, txt);
};
return (
<div>
{visibleFields.map((field) => (
<fieldset key={field.id}>
<legend>
{field.name}
{field.required ? ' *' : ''}
</legend>
{field.description ? <p>{field.description}</p> : null}
{field.options.length > 0 ? (
field.options.map((option) => (
<label key={option.id}>
<input
type={field.type === 'CHECKBOX' ? 'checkbox' : 'radio'}
name={field.id}
checked={(eff.selected[field.id] ?? []).includes(option.id)}
onChange={() => pick(field.id, option.id, field.type === 'CHECKBOX')}
/>
{option.label}
{surchargeHint(option)}
</label>
))
) : (
<input
type={field.type === 'NUMBER' ? 'number' : 'text'}
value={eff.texts[field.id] ?? ''}
onChange={(e) => type(field.id, e.target.value)}
/>
)}
</fieldset>
))}
</div>
);
}
Dwa mechanizmy, na które warto zwrócić uwagę:
- Pod-komponenty warunkowe (
visibleIf): pole „Zestaw dziurkacza" pojawia się dopiero po wybraniu finiszera, z którym współpracuje. Filtruje jeisConfiguratorFieldVisiblez SDK — z kaskadą: ukrycie rodzica unieważnia odpowiedzi jego pod-pól, co może ukryć kolejne poziomy (pętlaresolveVisible). - Selekcje budowane tylko z pól widocznych — odpowiedź pola ukrytego serwer odrzuca kodem
ATTRIBUTE_CONDITION_NOT_MET, a pole wymagane liczy się tylko wtedy, gdy jest widoczne (ATTRIBUTE_REQUIRED_MISSINGprzycartComplete). Ewaluacja po stronie klienta to wygoda renderowania — granicą bezpieczeństwa jest zawsze serwer.
Krok 3 — Dodaj do koszyka z selekcjami
Selekcje z komponentu przekazujesz jako trzeci argument addToCart (albo jako CartLineInput.attributeSelections przy ręcznym budowaniu mutacji):
'use client';
import { useState } from 'react';
import { useCartActions } from '@/lib/cart'; // Twój hook z useCartManager
import type { CartAttributeSelectionInput } from '@doswiftly/storefront-sdk';
const [selections, setSelections] = useState<CartAttributeSelectionInput[]>([]);
const { addToCart } = useCartActions();
<ProductConfigurator fields={fields} onSelectionsChange={setSelections} />
<button onClick={() => addToCart(variantId, 1, selections)}>Dodaj do koszyka</button>
Serwer waliduje każdą selekcję (istnienie opcji, kompletność pól wymaganych, warunki widoczności), wycenia dopłaty i rezerwuje stan magazynowy komponentów (linkedVariant). Niepoprawny input wraca jako userErrors[].code — pełna tabela kodów i semantyka rozliczeń (BUNDLED vs SEPARATE_LINE vs komponent z magazynu): Koszyk → Konfigurator produktu.
Krok 4 — Rozpiska konfiguracji w koszyku
Wybory klienta wracają na pozycji koszyka jako line.attributeSelections — rozpiskę (nazwa pola, wybrana opcja, dopłata) renderujesz bez dodatkowego zapytania. Przykład i zasady sum (czego nie doliczać po swojej stronie): Koszyk → Rozpiska selekcji.
Typy
Renderowane ze schematu GraphQL — nigdy nie rozjeżdżają się z API:
ConfiguratorField
A single field of a product configurator — an input the shopper sees on the product page.
| Pole | Typ | Opis |
|---|---|---|
description | String | Optional help text shown beneath the field. |
filledBy | AttributeFillingMode! | Who provides the value — CUSTOMER (the shopper fills it in) or BOTH (seller sets a default the shopper can change). Seller-only metadata fields are not returned here. |
handle | String! | URL-friendly key for the field. |
id | ID! | Stable identifier of this field. |
isVisible | Boolean! | Whether this field should be shown on the storefront. |
maxValue | Float | Upper bound — maximum value (NUMBER) or maximum length (TEXT). |
minValue | Float | Lower bound — minimum value (NUMBER) or minimum length (TEXT). |
name | String! | Field label shown to the shopper (e.g. "Finish"). |
options | [ConfiguratorOption!]! | Selectable choices for SELECT / RADIO / CHECKBOX fields; empty for free-input types (TEXT, NUMBER, …). |
pricingMode | AttributeBillingMode | If a choice adds cost, how it is billed — BUNDLED (folded into the product price) or SEPARATE_LINE (its own order line). Null when the field has no price impact. |
required | Boolean! | Whether the shopper must provide a value before adding the product to the cart. |
sortOrder | Int! | Order in which to render this field. |
type | AttributeType! | Input type — controls how to render the field (TEXT, TEXTAREA, SELECT, RADIO, CHECKBOX, NUMBER, COLOR, …). |
visibleIf | [ConfiguratorFieldCondition!] | Visibility rules (AND). Null/absent = always visible. Render the field only when every rule is met against the current selections; when it becomes hidden, clear its value — the server rejects selections for hidden fields. Use this to render dependent sub-components indented under their parent. |
ConfiguratorOption
A selectable choice within a product configurator field (e.g. a size, finish or add-on).
| Pole | Typ | Opis |
|---|---|---|
colorHex | String | Hex colour for swatch rendering (COLOR fields). |
id | ID! | Stable identifier of this choice. |
isDefault | Boolean! | Whether this choice is pre-selected by default. |
label | String! | Human-readable label shown to the shopper. |
linkedVariant | LinkedVariantSummary | The stocked variant this choice maps to (used for inventory-backed components). Null when the choice maps to no variant or the variant no longer exists. |
sortOrder | Int! | Order in which to render this choice. |
surchargeAmount | Int | Extra charge applied when this choice is selected. With surchargeType FIXED it is an amount in minor currency units (e.g. 500 = 5.00). With PERCENT it is thousandths of a percent (e.g. 1500 = 1.5%). |
surchargeType | AttributeOptionSurchargeType | How to interpret surchargeAmount — FIXED (a money amount) or PERCENT. |
value | String! | Machine value (slug-style) — use it for form state and when submitting the choice to the cart. |
Powiązane
- Wysyłka selekcji, kody błędów, wielokrotny wybór — Koszyk.
- Strona produktu (warianty, galeria, cena) — Strona produktu z wariantami.
- Helper
isConfiguratorFieldVisible— Referencja TypeScript SDK. - Kontrakt operacji
ProductConfigurator— Referencja GraphQL API.