Przejdź do głównej zawartości

Konfigurator produktu

Framework: React / Next.js
Pobieranie danych jest przenośne — w blokach z przykładami zakładka Raw pokazuje czysty fetch działający w dowolnym frameworku.
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

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

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:

ProductConfigurator.tsx
'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 je isConfiguratorFieldVisible z SDK — z kaskadą: ukrycie rodzica unieważnia odpowiedzi jego pod-pól, co może ukryć kolejne poziomy (pętla resolveVisible).
  • 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_MISSING przy cartComplete). 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

ConfiguratorFieldobjectPełna referencja →

A single field of a product configurator — an input the shopper sees on the product page.

PoleTypOpis
descriptionStringOptional help text shown beneath the field.
filledByAttributeFillingMode!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.
handleString!URL-friendly key for the field.
idID!Stable identifier of this field.
isVisibleBoolean!Whether this field should be shown on the storefront.
maxValueFloatUpper bound — maximum value (NUMBER) or maximum length (TEXT).
minValueFloatLower bound — minimum value (NUMBER) or minimum length (TEXT).
nameString!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, …).
pricingModeAttributeBillingModeIf 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.
requiredBoolean!Whether the shopper must provide a value before adding the product to the cart.
sortOrderInt!Order in which to render this field.
typeAttributeType!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

ConfiguratorOptionobjectPełna referencja →

A selectable choice within a product configurator field (e.g. a size, finish or add-on).

PoleTypOpis
colorHexStringHex colour for swatch rendering (COLOR fields).
idID!Stable identifier of this choice.
isDefaultBoolean!Whether this choice is pre-selected by default.
labelString!Human-readable label shown to the shopper.
linkedVariantLinkedVariantSummaryThe 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.
sortOrderInt!Order in which to render this choice.
surchargeAmountIntExtra 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%).
surchargeTypeAttributeOptionSurchargeTypeHow to interpret surchargeAmount — FIXED (a money amount) or PERCENT.
valueString!Machine value (slug-style) — use it for form state and when submitting the choice to the cart.

Powiązane