Koszyk
Kompletny przewodnik po zarządzaniu koszykiem w storefront: dodawanie produktów, aktualizacja ilości, kody rabatowe i integracja z Zustand store.
Architektura koszyka
Koszyk opiera sie na architekturze server-first z wzorcem DI (Dependency Injection):
- SDK Cart Store (
@doswiftly/storefront-sdk/react) --createCartStore(config)z DI pattern. SDK orchestruje stan (init, mutations, error handling) - CartActions DI (
hooks/use-cart-di.ts) -- template dostarcza implementacje transport viaCartActionsinterface (GraphQL mutations) - GraphQL API -- zrodlo prawdy o zawartosc koszyka (linie, ceny, rabaty)
- React Query -- cache i automatyczna synchronizacja danych koszyka
- useCartActions -- hook UX wrapper (debounce, openCart -- deleguje do SDK store actions)
Od wersji 9.x storefront-sdk + storefront-operations cart jest jedynym aggregate dla pelnego completion lifecycle. Brak osobnego Checkout ObjectType — wszystkie operacje (adres, metoda wysylki, platnosc, gift card, finalizacja) wystawione bezposrednio na Cart. Klient widzi jeden spojny surface. Szczegoly: Cart Completion Lifecycle ponizej.
Operacje modyfikujace koszyk (addItem, updateItem, clear, applyDiscountCodes, setNote) wymagaja statusu ACTIVE lub RECOVERED. Koszyki w statusie CONVERTED (po checkout) lub EXPIRED zwroca blad 400: "Cart is not editable". SDK automatycznie odzyskuje koszyk dla operacji bezpiecznych do replay (addItem etc.) i emituje event cart-expired dla operacji wymagajacych istniejacego stanu (updateItem, removeItem). Patrz Automatyczne odzyskiwanie koszyka.
Automatyczne odzyskiwanie koszyka
Koszyk moze stac sie nieuzywalny miedzy odczytem a zapisem: TTL wygasa, status zmienia sie na CONVERTED po checkout, nieaktywne koszyki sa usuwane po wygasnieciu. Backend rozni sie w sprawdzeniach: cart(id) query sprawdza tylko expires_at, mutacje sprawdzaja takze status. Skutek -- query zwraca koszyk, ale cartAddLines rzuca userErrors[{ code: 'CART_NOT_FOUND' }] albo 'ALREADY_COMPLETED'.
Od wersji 11.2 SDK robi to automatycznie dla wszystkich consumerów (React i non-React).
Klasyfikacja operacji (decyzja SDK, nie kodu wywolujacego)
| Operacja | Strategia | Dlaczego |
|---|---|---|
addItem / addLines | Auto-replay przez atomicy cartCreate({ lines }) | Storefront-dev oczekuje ze "add to cart" zawsze dziala |
updateBuyerIdentity | Auto-replay (cartCreate({ buyerIdentity })) | User wlasnie wpisal email/telefon -- nie tracimy danych |
setShippingAddress | Auto-replay (cartCreate({ shippingAddress })) | User wpisal adres -- nie tracimy danych |
updateDiscountCodes | Auto-replay (cartCreate({ discountCodes })) | Kod kuponu valid niezaleznie od koszyka |
updateNote | Auto-replay (cartCreate({ note })) | Bezstanowe, idempotentne |
updateItem / updateQuantity | Bail + event | lineId odnosi sie do linii w martwym koszyku — replay na pustym = silent lost items |
removeItem / removeFromCart | Bail + event | jw. |
Na "bail" SDK czysci cookie i wola wszystkie listenery onExpired. UI subscrybuje raz globalnie i pokazuje toast/banner -- kod wywolujacy NIGDY nie pisze try/catch per mutation.
React (useCartManager)
'use client';
import { useEffect } from 'react';
import { useCartManager } from '@doswiftly/storefront-sdk/react';
import { toast } from 'sonner';
export function CartButton() {
const { addItem, onExpired, isLoading } = useCartManager();
// Subskrybuj raz na cala aplikacje (np. w root layout/providerze)
useEffect(() => onExpired((e) => {
toast.error(e.reason === 'state-dependent'
? 'Twoj koszyk wygasl, dodaj produkty ponownie'
: 'Nie udalo sie odzyskac koszyka');
}), [onExpired]);
return <button onClick={() => addItem([{ variantId: 'v-123', quantity: 1 }])} disabled={isLoading}>Dodaj do koszyka</button>;
}
React (createCartStore)
Jezeli pracujesz bezposrednio z createCartStore (zustand-based, DI pattern), wstrzyknij opcjonalny createCartWithLines w CartActions zeby recovery bylo atomic (jeden round trip cartCreate({ lines })). Bez niego SDK uzyje fallback createCart() + addLines() — ten sam efekt funkcjonalny, dwa round-trips. CartStoreConfig.onExpired daje globalny callback dla UI.
import { createCartStore, type CartActions } from '@doswiftly/storefront-sdk/react';
const actions: CartActions = {
fetchCart: (id) => api.getCart(id),
createCart: () => api.createCart().then(c => c.id),
addLines: (id, lines) => api.addLines(id, lines),
updateLines: (id, lines) => api.updateLines(id, lines),
removeLines: (id, ids) => api.removeLines(id, ids),
// Optional — enables atomic add-to-cart recovery
createCartWithLines: (lines) => api.cartCreate({ lines }),
};
const cartStore = createCartStore({
getActions: () => actions,
onMutationSuccess: (action, cart) => queryClient.invalidateQueries(['cart']),
onMutationError: (action, err) => console.error(action, err),
onExpired: (event) => toast.error('Twoj koszyk wygasl'),
});
Non-React (Vue, Svelte, CLI, mobile)
import {
CartClient,
createCartRecoveryRunner,
recreateWithInput,
type CartCookieStore,
} from '@doswiftly/storefront-sdk';
// 1. Zaimplementuj cookie port dla swojego runtime
const cookieStore: CartCookieStore = {
get: () => readMyCookie('cart-id'),
set: (id) => writeMyCookie('cart-id', id, { maxAge: 30 * 24 * 60 * 60 }),
clear: () => deleteMyCookie('cart-id'),
};
// 2. Zbuduj runner (jeden per shop session — trzyma mutex pierwszego utworzenia w closure)
const cartClient = new CartClient(client);
const runner = createCartRecoveryRunner({ cartClient, cookieStore });
runner.onExpired((event) => {
console.warn(`Cart expired (${event.reason}). Local state cleared.`);
});
// 3. Wywoluj operacje przez runner
const { cart } = await runner.execute({
name: 'addItems',
run: (cartId) => cartClient.addItems(cartId, [{ variantId: 'v-123', quantity: 1 }]),
recreateAndRun: recreateWithInput({ lines: [{ variantId: 'v-123', quantity: 1 }] }),
});
Detekcja w wlasnym kodzie
Jezeli przechwytujesz StorefrontError w innym miejscu (np. global error boundary), nie matchuj po err.message -- ten string jest tlumaczony do locale uzytkownika i regresuje przy zmianach copy. Uzyj isCartRecoverableError:
import { isCartRecoverableError } from '@doswiftly/storefront-sdk';
try {
await someCartCall();
} catch (err) {
if (isCartRecoverableError(err)) {
// err.userErrors[].code in {'CART_NOT_FOUND', 'ALREADY_COMPLETED'}
// — klient powinien wyczyscic lokalny stan i pozwolic SDK utworzyc nowy koszyk
}
}
Model dostępu do koszyka (capability)
Dostęp do koszyka opiera się na zdolności (capability), nie na zalogowanym kliencie. Koszyk osiągasz parą cart_id + sekret — kto ma jedno i drugie, ten może czytać i modyfikować koszyk. Pole customer_id jest wyłącznie wzbogacające (pre-fill danych, ceny grupy klienta, powiązanie z kontem) i nigdy nie jest warunkiem dostępu.
W praktyce oznacza to:
- Koszyk przeżywa zalogowanie i wylogowanie — ten sam
cart_iddziała przed logowaniem i po nim. - Niezalogowany gość, który dostał z poprzedniej wizyty obcy
cart_idbez pasującego sekretu, nie zobaczy cudzego koszyka — dostanieCART_NOT_FOUNDi świeży koszyk. - Zalogowany kupujący z jeszcze niezhydratowanym tokenem nie jest odsyłany do logowania na własnym koszyku — sekret autoryzuje operację niezależnie od stanu sesji.
SDK obsługuje sekret za Ciebie (cookie cart-id, middleware klienta i serwera) — szczegóły w Sekret koszyka i cookie composite poniżej.
Jeden kod błędu dostępu — CART_NOT_FOUND
W modelu capability jest jeden kod błędu dostępu. Niedostępny lub wygasły koszyk, brakujący sekret albo zły sekret dają jednolicie CART_NOT_FOUND — nie da się rozróżnić "koszyk istnieje, ale nie masz sekretu" od "koszyka nie ma" (celowo, anty-enumeracja). SDK ma na to wbudowane auto-recovery: po CART_NOT_FOUND tworzy świeży koszyk.
| Kod | Kiedy backend zwraca | Recovery |
|---|---|---|
CART_NOT_FOUND | Brak koszyka o danym id, jego TTL wygasł, albo brak/zły sekret (cart-id cookie bez sekretu lub z niepasującym) | Wyczyść cookie cart-id, pozwól SDK utworzyć nowy koszyk (auto-replay dla addItems / updateBuyerIdentity / setShippingAddress / updateDiscountCodes / updateNote; bail dla updateItem / removeItem) |
ALREADY_COMPLETED | Koszyk zakończył lifecycle (CONVERTED po finalizacji, EXPIRED/ABANDONED) | Wyczyść cookie cart-id, utwórz nowy koszyk |
Dostępność koszyka nie zależy od stanu zalogowania — koszyk osiągasz sekretem, więc utrata lub brak sesji klienta nie wpływa na dostęp do koszyka. Sesja klienta to osobny mechanizm: odświeżanie tokenu jest automatyczne (autoRefresh w StorefrontProvider), a na jej wygaśnięcie reagujesz przez useSessionExpired — niezwiązane z dostępem do koszyka (patrz Autoryzacja klienta).
Wzorzec rozpoznania w switch (jeden kod dostępu + status terminalny):
import { StorefrontError } from '@doswiftly/storefront-sdk';
try {
await cartClient.selectPaymentMethod({ cartId, methodType: 'BLIK' });
} catch (err) {
if (err instanceof StorefrontError && err.hasUserErrors) {
switch (err.userErrors[0].code) {
case 'CART_NOT_FOUND':
case 'ALREADY_COMPLETED':
// Koszyk nieosiągalny / zakończony. Drop cookie, start od nowa.
cookies().delete('cart-id');
redirect('/cart/expired');
break;
}
}
throw err;
}
Detekcja w kodzie własnym (poza runnerem) — jeden predykat pokrywa oba recoverable kody:
import { isCartRecoverableError } from '@doswiftly/storefront-sdk';
try {
await someCartCall();
} catch (err) {
if (isCartRecoverableError(err)) {
// err.userErrors[].code in {'CART_NOT_FOUND', 'ALREADY_COMPLETED'}
// — wyczyść lokalny stan i pozwól SDK utworzyć nowy koszyk
}
}
Test driftu cart-recovery-drift.test.ts pilnuje, by zbiór recoverable kodów SDK pokrywał się z kontraktem backendu.
Sekret koszyka i cookie composite
Sekret koszyka podróżuje w composite cookie cart-id o wartości <cartId>.<secret>. Zarówno cart_id (UUID), jak i sekret (base64url) nie zawierają kropki, więc pierwsza kropka rozdziela obie połowy.
SDK obsługuje to automatycznie: useCartManager, przeglądarkowy cart cookie store oraz middleware klienta i serwera odczytują i zapisują sekret bez Twojego udziału. Poniższe szczegóły dotyczą Cię tylko, jeśli sam czytasz cookie cart-id lub robisz odczyt koszyka po stronie serwera.
Bezpośredni odczyt cookie (Server Component, własny runtime)
Nie traktuj surowej wartości cookie jako cart_id. Rozbij ją parserem parseCartCookieValue() (lub readCartCredentials() w react/server):
import { parseCartCookieValue, getCookie, CART_COOKIE_NAME } from '@doswiftly/storefront-sdk';
const credentials = parseCartCookieValue(getCookie(CART_COOKIE_NAME));
// credentials → { cartId, cartSecret } | null
// Stara wartość bez kropki (legacy) parsuje się z cartSecret: null
// i degraduje do świeżego koszyka przy następnym zapisie.
Odczyt koszyka po stronie serwera (SSR / edge) musi przekazać sekret
Server-side odczyt koszyka bez sekretu wróci pusty (backend traktuje koszyk jak nieosiągalny). Doczep serverCartSecretMiddleware(await readCartCredentials()) do server-side klienta — obok middleware waluty / języka:
import {
getStorefrontClient,
readCartCredentials,
serverCartSecretMiddleware,
} from '@doswiftly/storefront-sdk/react/server';
const client = getStorefrontClient({
apiUrl, shopSlug,
middleware: [serverCartSecretMiddleware(await readCartCredentials())],
});
const cart = await new CartClient(client).get(cartId); // sekret w nagłówku x-cart-secret
Po stronie klienta robi to za Ciebie StorefrontProvider (wpina cartSecretMiddleware czytający sekret z cookie lazy — sekret jest doczepiany tylko do operacji koszyka (rozpoznawanych po prefiksie Cart w nazwie operacji), więc publiczne odczyty katalogu pozostają cacheowalne nawet gdy gość ma już koszyk; rotacja sekretu jest podchwytywana bez przebudowy klienta).
Sekret jest pokazywany tylko raz
CartClient.create() (oraz recoveryRedeem()) zwracają jednorazowy secret w wyniku. SDK zapisuje go automatycznie w composite cookie, ale bezpośredni konsument API musi zapisać go od razu — nie da się go odczytać ponownie:
const { cart, secret } = await cartClient.create({ lines });
// secret: string | null — zapisz natychmiast do composite cookie cart-id.
// Kolejne odczyty koszyka go nie zwrócą.
Przejścia gość ↔ klient
Bo koszyk jest osiągany sekretem (a nie zalogowaniem), przejścia między gościem a klientem nie wymagają przerzucania koszyka między tożsamościami. SDK udostępnia trzy metody CartClient (oraz odpowiadające wrappery useCartManager).
Scalanie przy logowaniu — merge(guestCartId)
Przy logowaniu scal poprzedni koszyk klienta do koszyka gościa. Koszyk gościa zostaje (ten sam cart_id + sekret → zapisane cookie pozostaje ważne), ilości sumują się per wariant, a pola checkoutu gościa wygrywają. Metoda wymaga uwierzytelnionego żądania:
try {
const { cart } = await cartClient.merge(guestCartId);
} catch (err) {
// err instanceof StorefrontError — sprawdź err.userErrors[0].code:
// CART_MERGE_REQUIRES_AUTH — żądanie anonimowe (zaloguj klienta najpierw)
// CART_CURRENCY_MISMATCH — koszyki w różnych walutach (patrz niżej)
}
Wylogowanie — downgradeOnLogout(cartId)
Przy wylogowaniu degraduje koszyk do gościa: zeruje powiązanie z klientem, dane kontaktowe, adresy i wybór zapisanej metody płatności, zostawiając pozycje, kupony, metodę wysyłki, walutę, notatki i sekret. Sekret nie jest rotowany, więc zapisane cookie pozostaje ważne.
useLogout woła to automatycznie przed wylogowaniem (best-effort, jeden retry, nigdy nie blokuje wylogowania), żeby dane klienta nie zostały w koszyku na współdzielonym urządzeniu. Jeśli nie używasz useLogout, wywołaj metodę ręcznie przed wyczyszczeniem sesji.
Odzyskiwanie linkiem — recoveryRedeem(token)
Realizuje podpisany link odzyskiwania porzuconego koszyka. Na sukces sekret koszyka jest rotowany, a wynik niesie nowy jednorazowy secret — zapisz go do composite cookie cart-id (stary przestaje działać). Nieprawidłowy lub przeterminowany link rzuca StorefrontError z userErrors, bez wycieku zawartości koszyka.
const { cart, secret } = await cartClient.recoveryRedeem(token);
// Zapisz nowy secret do composite cookie cart-id — stary sekret już nie autoryzuje.
Dostepne hooki
Hooki zapytan
| Hook | Typ | Opis |
|---|---|---|
useCart | Query | Pobiera koszyk po ID |
Hooki mutacji
| Hook | Typ | Opis |
|---|---|---|
useCartCreate | Mutation | Tworzy nowy koszyk |
useCartLinesAdd | Mutation | Dodaje linie do koszyka |
useCartLinesUpdate | Mutation | Aktualizuje linie koszyka (ilosc) |
useCartLinesRemove | Mutation | Usuwa linie z koszyka |
useCartDiscountCodesUpdate | Mutation | Dodaje/usuwa kody rabatowe |
Buyer identity, notatka, adresy, shipping/payment, gift cards oraz complete — przez useCartManager (SDK), który pokrywa pełny lifecycle checkoutu (te operacje nie mają osobnych template-hooków).
Hooki SDK
| Hook | Typ | Opis |
|---|---|---|
useCartManager | Cart Manager | Cookie-driven cart (auto-init z composite cart-id cookie — cart_id + sekret) — flagowy hook dla browser-facing checkoutu z auto-recovery |
useCart(cartId, options?) | Bound cart | Server-driven cart bound do jawnego cartId z propsa (NIE z cookie). Use case: SSR-rendered checkout, deep-link order recovery, admin "view this cart" UI. Vanilla Zustand store per mount przez useMemo (recreate przy zmianie cartId). autoFetch: false + initialCart dla SSR-seedu bez round-tripu. Mutacje zwracają CartMutationOutcome ({ cart, warnings }). Brak auto-recovery — server-driven flow ma swój cartId z URL/token. |
Hook szablonu
| Hook | Typ | Opis |
|---|---|---|
useCartActions | Convenience | Wrapper laczacy mutacje ze store (auto-create, debounce, retry) |
Import
// SDK Cart Store (DI-based) — orchestracja stanu koszyka
import { createCartStore, CartProvider, useCartStore, useCartStoreApi } from '@doswiftly/storefront-sdk/react';
import type { CartActions, CartData, CartState } from '@doswiftly/storefront-sdk/react';
// Re-export facade (template-level — zachowuje kompatybilnosc importow)
import { useCartStore } from '@/stores/cart-store';
// CartActions DI implementation (template dostarcza transport)
import { useCartDI } from '@/hooks/use-cart-di';
// Hook UX wrapper (debounce, toast — deleguje do SDK store)
import { useCartActions } from '@/hooks/use-cart-actions';
// Low-level GraphQL hooki koszyka (Client Components)
import {
useCart,
useCartCreate,
useCartLinesAdd,
useCartLinesUpdate,
useCartLinesRemove,
useCartDiscountCodesUpdate,
} from '@/lib/graphql/hooks';
// Koszyk jest cookie-driven (client) — brak server-side fetchCart. Dla SSR/deep-link
// użyj useCart(cartId) z SDK z initialCart (patrz tabela "Hooki SDK" powyżej).
Hooki koszyka sa rowniez dostepne w @/lib/graphql/hooks z automatycznym wstrzykiwaniem waluty do kluczy cache.
Cart Store (SDK z DI)
Cart store pochodzi z @doswiftly/storefront-sdk/react i uzywa wzorca Dependency Injection. SDK orchestruje stan koszyka, template dostarcza transport via CartActions interface.
interface CartState {
cartId: string | null; // ID koszyka (sekret trzymany w composite cookie 'cart-id', SSR-visible)
isOpen: boolean; // czy drawer koszyka jest otwarty
isLoading: boolean; // trwa operacja (init, add, update, remove)
error: unknown | null; // ostatni blad
// UI Actions (pure state)
openCart: () => void;
closeCart: () => void;
toggleCart: () => void;
// Orchestrated Actions (DI — SDK zarzadza lifecycle)
initCart: () => Promise<void>;
addToCart: (lines: CartLineInput[]) => Promise<void>;
updateQuantity: (lines: CartLineUpdateInput[]) => Promise<void>;
removeFromCart: (lineIds: string[]) => Promise<void>;
clearCart: () => void;
}
Konfiguracja w StoresProvider:
// hooks/use-cart-di.ts — template dostarcza CartActions implementation
export function useCartDI(): CartActions {
const execute = useExecute();
return useMemo(() => ({
fetchCart: async (cartId) => { /* GraphQL query */ },
createCart: async () => { /* GraphQL mutation, return cart.id */ },
addLines: async (cartId, lines) => { /* GraphQL mutation */ },
updateLines: async (cartId, lines) => { /* GraphQL mutation */ },
removeLines: async (cartId, lineIds) => { /* GraphQL mutation */ },
}), [execute]);
}
// components/providers/stores-provider.tsx
const cartActions = useCartDI();
const actionsRef = useRef(cartActions);
actionsRef.current = cartActions;
const cartStore = useRef(createCartStore({
getActions: () => actionsRef.current,
onMutationSuccess: (action) => { queryClient.invalidateQueries(); toast.success('Done'); },
onMutationError: (action, error) => { toast.error(error.message); },
})).current;
useCartActions
Hook UX wrapper -- deleguje orchestracje do SDK cart store, dodaje template-specific UX:
- Debounce aktualizacji ilosci -- zapobiega ThrottlerException przy szybkich kliknieciach (+/- ilosc)
- openCart po dodaniu -- automatycznie otwiera drawer po addToCart
- Toast i cache invalidation -- via SDK callbacks
onMutationSuccess/onMutationError
const { addToCart, updateQuantity, removeFromCart, clearCart, isLoading } = useCartActions();
// Dodaj do koszyka (SDK auto-init jesli brak koszyka)
await addToCart('variant-123', 1);
// Aktualizuj ilosc (debounced, przyjmuje lineId)
updateQuantity('line-abc', 3);
// Usun z koszyka (przyjmuje lineId)
await removeFromCart('line-abc');
// Wyczysc caly koszyk
clearCart();
Typy GraphQL
Publiczne typy GraphQL (Cart, CartLine, Order, Customer itd.) eksportowane z @doswiftly/storefront-sdk są generowane ze schematu GraphQL przez pnpm codegen — aliasy wygenerowanych fragmentów. Pola nullable i typy enum poniżej odzwierciedlają faktyczny kontrakt schematu. Jeśli typ wygląda inaczej niż oczekujesz, zregeneruj typy zamiast edytować je ręcznie.
Cart
interface Cart {
id: string;
checkoutUrl?: string | null;
totalQuantity: number;
cost: CartCost;
lines: CartLineConnection; // { totalCount, nodes, pageInfo } — do 100 linii
buyerIdentity?: CartBuyerIdentity | null;
discountCodes: CartDiscountCode[];
discountAllocations: CartDiscountAllocation[];
note?: string | null;
email?: string | null; // buyer email
phone?: string | null; // buyer phone
requiresShipping: boolean; // false dla koszyka wyłącznie cyfrowego (single signal czy renderować shipping picker)
attributes: Attribute[];
status: CartStatus; // lifecycle state — patrz CartStatus poniżej
completedOrder?: Order | null; // ustawione tylko gdy status === 'CONVERTED'
createdAt: string;
updatedAt: string;
}
Cart.status (CartStatus)
Lifecycle status koszyka — odzwierciedla persistencję w backend'zie. ACTIVE jest jedynym stanem edytowalnym; pozostałe są terminalne i odrzucają mutacje z kodem ALREADY_COMPLETED.
enum CartStatus {
ACTIVE = 'ACTIVE', // working, editable
ABANDONED = 'ABANDONED', // cleanup, no order — utwórz świeży koszyk
CONVERTED = 'CONVERTED', // zakończony checkout — czytaj cart.completedOrder
RECOVERED = 'RECOVERED', // wcześniej abandoned, przywrócony przez recovery link
EXPIRED = 'EXPIRED', // TTL minął, no order — utwórz świeży koszyk
}
Użycie w storefront UI: czytaj cart.status przed renderowaniem formularza checkoutu — koszyk który już zostal sfinalizowany powinien przekierować na stronę potwierdzenia, a nie pokazać martwy formularz, którego pierwsza mutacja faila.
if (cart.status === 'CONVERTED' && cart.completedOrder) {
router.push(`/order/summary?token=${cart.completedOrder.accessToken}`);
return null;
}
if (cart.status === 'EXPIRED' || cart.status === 'ABANDONED') {
return <CreateFreshCartPrompt />;
}
// cart.status === 'ACTIVE' | 'RECOVERED' — render checkout form
Cart.completedOrder
Zamówienie utworzone w momencie cartComplete. Pole jest niepuste tylko gdy cart.status === 'CONVERTED'. Zawiera minimalny payload do redirect'u na stronę potwierdzenia: id, orderNumber, accessToken (opaque token dla guest order summary), status, paymentStatus, fulfillmentStatus. Backend pobiera dane w stałej liczbie zapytań (bez N+1) — listing N skonwertowanych koszyków daje jedno query do DB, nie N+1.
Dla pełnego widoku zamówienia po conversion zrób follow-up query customerOrder($orderId) (zalogowany customer) lub orderByToken($token) (guest po cart.completedOrder.accessToken).
CartCost
interface CartCost {
subtotal: PriceMoney; // suma przed podatkami i wysyłką
total: PriceMoney; // suma końcowa
totalTax?: PriceMoney | null; // łączny podatek
totalDuty?: PriceMoney | null; // łączne cło
totalShipping?: PriceMoney | null; // łączny koszt wysyłki (po `cartSelectShippingMethod`)
totalDiscount?: PriceMoney | null; // łączny rabat (suma `discountAllocations[].amount`)
feeTotal: PriceMoney; // opłata za wybraną formę płatności (zero, gdy żadna nie obowiązuje)
feeAllocations: CartFeeAllocation[]; // po jednym wpisie na opłatę; suma wpisów = feeTotal
checkoutCharge?: PriceMoney | null; // WYCOFANE — nie renderuj, patrz niżej
}
interface CartFeeAllocation {
label: string; // gotowa etykieta wiersza, przetłumaczona przez backend
amount: PriceMoney;
}
totalDiscount i totalShipping są agregatami — równie dobrze możesz iterować po cart.discountAllocations[] lub czytać cart.selectedShippingMethod.price, ale top-level agregat eliminuje konieczność dodawania custom field selection do każdego cart query w storefroncie. Renderuj je jako osobne linie podsumowania:
<dl>
<dt>Suma częściowa</dt> <dd>{formatPrice(cart.cost.subtotal)}</dd>
{cart.cost.totalShipping && (
<><dt>Wysyłka</dt> <dd>{formatPrice(cart.cost.totalShipping)}</dd></>
)}
{cart.cost.totalDiscount && (
<><dt>Rabat</dt> <dd>-{formatPrice(cart.cost.totalDiscount)}</dd></>
)}
{cart.cost.feeAllocations.flatMap((fee) => [
<dt key={`${fee.label}-label`}>{fee.label}</dt>,
<dd key={`${fee.label}-value`}>{formatPrice(fee.amount)}</dd>,
])}
{cart.cost.totalTax && (
<><dt>Podatek</dt> <dd>{formatPrice(cart.cost.totalTax)}</dd></>
)}
<dt>Razem</dt> <dd>{formatPrice(cart.cost.total)}</dd>
</dl>
Opłata za formę płatności
Sklep może pobierać opłatę za wybraną formę płatności — najczęściej za obsługę pobrania. feeTotal niesie kwotę zbiorczą, feeAllocations po jednym wpisie na opłatę, każdy z gotową etykietą do wyświetlenia. Oba pola są niepuste: koszyk bez opłaty zwraca zero i pustą listę, więc wiersz renderujesz bezwarunkowo, bez rozgałęziania.
Kwota jest już wliczona w total — dodaj ją jako osobny wiersz podsumowania, nigdy do sumy. Ta sama para pól występuje na zamówieniu (OrderTotals), gdzie wartości są zamrożone w chwili złożenia zamówienia: potwierdzenie, paragon i zwrot opisują tę samą opłatę, na którą zgodził się kupujący, nawet po późniejszej zmianie ustawień sklepu.
checkoutCharge jest wycofane i zniknie po 2026-10-27. Mimo nazwy nigdy nie niosło opłaty — zwraca tę samą wartość co total, więc wypisane jako wiersz podsumowania pokazuje kwotę do zapłaty dwa razy. Czytaj feeTotal. Do czasu usunięcia pole nadal zwraca swoją dotychczasową wartość, więc istniejące zapytania działają bez zmian.
CartLine
interface CartLine {
id: string;
quantity: number;
variant: ProductVariant; // wariant produktu (pole `variant`, nie union `merchandise` — patrz Migracja 5.0)
cost: CartLineCost;
discountAllocations: CartDiscountAllocation[]; // rabaty przypisane do tej pozycji — patrz sekcja nizej
attributes: Attribute[]; // Line Item Properties (grawerunek, prezent)
attributeSelections: AttributeSelection[]; // Typowany konfigurator — patrz sekcja nizej
productId?: string | null;
productTitle?: string | null;
productHandle?: string | null;
productType?: ProductTypeEnum | null; // PHYSICAL | DIGITAL | SERVICE | SUBSCRIPTION | GIFT_CARD
requiresShipping: boolean; // czy ta pozycja wymaga wysyłki kurierem
}
interface CartLineCost {
pricePerUnit: PriceMoney;
subtotal: PriceMoney;
total: PriceMoney;
compareAtPricePerUnit?: PriceMoney | null;
}
pricePerUnit (a więc i subtotal / total) zawiera dopłaty konfiguratora wliczone w cenę pozycji — selekcje z billingMode: BUNDLED. Dzięki temu suma line.cost.total po pozycjach zgadza się z cart.cost.subtotal i storefront nie musi doliczać niczego po swojej stronie (jeśli wcześniej doliczałeś dopłaty ręcznie, usuń tę arytmetykę — teraz podwoiłaby kwotę). Cena bazowa wariantu bez dopłat pozostaje w line.variant.price. Selekcje rozliczane jako osobne pozycje (SEPARATE_LINE) oraz komponenty magazynowe (linkedVariantId) są wliczone w cart.cost, ale nie w koszt tej pozycji — ich rozpiskę renderujesz z line.attributeSelections.
CartDiscountCode
interface CartDiscountCode {
code: string;
isApplicable: boolean; // czy kod jest prawidlowy i aktywny
}
interface CartDiscountAllocation {
discountCode: string;
amount: Money; // kwota znizki
}
Rabaty na poziomie pozycji (CartLine.discountAllocations)
Oprócz zbiorczego rozbicia rabatu na koszyk (cart.discountAllocations), każda pozycja koszyka niesie własną listę alokacji — po jednym wpisie na każdy kod, który obniżył cenę tej konkretnej pozycji. Dzięki temu wyświetlisz przy pozycji komunikat w stylu "oszczędzasz 12,00 zł kodem LATO10", bez samodzielnego rozdzielania kwoty rabatu między pozycje.
interface CartLine {
// ...
discountAllocations: CartDiscountAllocation[]; // [{ discountCode, amount }]
}
Zasady, o których warto wiedzieć:
- Pusta lista, gdy żaden kod nie obniża tej pozycji — kod może nadal dotyczyć innych pozycji (sprawdź
cart.discountCodes). - Suma alokacji ze wszystkich pozycji równa się
cart.cost.totalDiscount— kwoty pochodzą z tej samej kalkulacji co suma zbiorcza (rabat rozdzielany jest metodą największej reszty, a promocja "Kup X, otrzymaj Y" przypisuje zniżkę do najtańszych sztuk). line.cost.totalNIE jest pomniejszany o te alokacje.line.cost.totalto nadal cena pozycji przed rabatem kodu; rabat pokazujesz jako osobną linię lub adnotację. Kwotę do zapłaty za cały koszyk odczytasz zcart.cost.total.
// Adnotacja "oszczędzasz X kodem Y" pod pozycją koszyka
{line.discountAllocations.map((alloc) => (
<p key={alloc.discountCode} className="text-sm text-emerald-600">
Oszczędzasz {formatPrice(alloc.amount)} kodem {alloc.discountCode}
</p>
))}
Zamówienie zamraża identyczny podział: OrderLineItem.discountAllocations (parytet z CartLine.discountAllocations) niesie per-pozycyjne alokacje na stronie potwierdzenia i w historii zamówień klienta, a suma alokacji ze wszystkich pozycji odpowiada rabatowi całego zamówienia. Zamówienia złożone przed wdrożeniem per-pozycyjnego snapshotu zwracają puste listy (rozbicie per-kod Order.discountAllocations pozostaje dostępne).
Konfigurator produktu (attributeSelections)
Dla produktów konfigurowalnych (drukarka z finiszerem, podstawą, podajnikiem; sofa z wyborem obicia; urządzenie z 12-miesięcznym serwisem) CartLineInput wspiera typowany konfigurator przez pole attributeSelections. W odróżnieniu od attributes (dowolne pary key/value — brak walidacji, brak wpływu na cenę) attributeSelections jest serwerowo walidowany, zmaterializowany w snapshot, a przy checkout rozgałęzia się w parent + N children OrderItem.
attributes— grawerunek, notatka prezentowa, checkbox "zapakuj na prezent". Bez walidacji, bez ceny, bez snapshot.attributeSelections— wybieralny finiszer, kolor obicia, rozmiar etykiety. Z walidacją, cenowaniem, VAT, rezerwacją magazynu dlalinkedVariantId.
Wcześniej w części sklepów wewnętrzny bezpiecznik platformy po cichu pomijał attributeSelections — mutacja zwracała sukces, a pozycja koszyka nie niosła wyborów klienta, mimo że storefront wysyłał komplet danych. To była wada platformy i została usunięta: selekcje są teraz zawsze walidowane i zapisywane, a każdy niepoprawny input kończy się jawnym userError z kodem (tabela poniżej). Jeśli Twój sklep obchodził ten problem, obejście można bezpiecznie zdjąć.
Typ AttributeSelection (response)
Po dodaniu/odczycie pozycji koszyka backend zwraca pełny enriched shape:
interface AttributeSelection {
attributeDefinitionId: string;
attributeName: string; // snapshot nazwy (bezpieczne do wyświetlenia)
type: AttributeType; // enum: SELECT | RADIO | CHECKBOX | TEXT | TEXTAREA | NUMBER | DATE | COLOR | BOOLEAN | CURRENCY | FILE | IMAGE
fillingMode: AttributeFillingMode; // enum: CUSTOMER | MERCHANT | BOTH
billingMode?: AttributeBillingMode | null; // enum: BUNDLED | SEPARATE_LINE
optionId?: string | null;
optionIds?: string[] | null; // MULTI_SELECT
optionLabel?: string | null; // snapshot etykiety
textValue?: string | null; // TEXT / TEXTAREA / NUMBER / DATE
surchargeAmount: number; // grosze (FIXED) / promille (PERCENT); 0 dla bundle
surchargeType?: AttributeOptionSurchargeType | null; // enum: FIXED | PERCENT
taxClassId?: string | null;
linkedVariantId?: string | null; // ← ustawione = child OrderItem przy checkout
}
Typy enum AttributeType, AttributeFillingMode, AttributeBillingMode, AttributeOptionSurchargeType są eksportowane z @doswiftly/storefront-sdk — możesz importować je bezpośrednio do typowania własnych komponentów konfiguratora.
Dodanie konfiguracji do koszyka
Storefront wysyła tylko attributeDefinitionId + jedno pole wartości (optionId / optionIds / textValue). Backend uzupełnia resztę:
'use client';
import { useCartActions } from '@/hooks/use-cart-actions';
export function ConfigureBundleButton({ printerVariantId, selections }: {
printerVariantId: string;
selections: Array<{ attributeDefinitionId: string; optionId?: string; textValue?: string }>;
}) {
const { addToCart, isLoading } = useCartActions();
return (
<button
disabled={isLoading}
onClick={() => addToCart(printerVariantId, 1, selections)}
>
{isLoading ? 'Dodawanie...' : 'Dodaj skonfigurowaną drukarkę'}
</button>
);
}
Obsługa błędów walidacji
Server-side walidator zwraca userErrors[].code przy niespójnych danych — storefront musi pokazać je użytkownikowi:
| Kod | Kiedy | Sugerowana akcja UI |
|---|---|---|
ATTRIBUTE_DEFINITION_NOT_FOUND | Nieznany attributeDefinitionId | "Konfiguracja wygasła — przeładuj stronę" |
ATTRIBUTE_OPTION_INVALID | optionId nie należy do definicji | Reset pola + toast |
ATTRIBUTE_REQUIRED | Brak wartości dla isRequired=true | Podświetl pole, blokuj submit |
ATTRIBUTE_LINKED_VARIANT_OUT_OF_STOCK | Komponent niedostępny magazynowo | "Niedostępne" + alternatywa |
ATTRIBUTE_CONDITION_NOT_MET | Selekcja pola UKRYTEGO warunkiem widoczności (visibleIf) — np. dziurkacz wysłany przy finiszerze, z którym nie współpracuje | Wyczyść nieaktualną wartość i ponów (patrz „Pola warunkowe" niżej) |
ATTRIBUTE_REQUIRED_MISSING | cartComplete: pole wymagane i WIDOCZNE przy bieżącej konfiguracji nie ma odpowiedzi | Wróć klienta do konfiguratora produktu |
Pola warunkowe — pod-komponenty zależne od wyboru rodzica
Pole konfiguratora może być widoczne TYLKO przy określonym wyborze w innym polu —
tak katalogi sprzętu modelują akcesoria („zestaw dziurkacza instalowany wyłącznie z
finiszerem wewnętrznym"). Reguły przychodzą w ConfiguratorField.visibleIf
(lista reguł łączonych AND; null = pole zawsze widoczne):
interface ConfiguratorFieldCondition {
fieldId: string; // pole-rodzic
operator: 'EQUALS' | 'NOT_EQUALS' | 'ONE_OF';
optionIds?: string[] | null; // KTÓRAKOLWIEK zaznaczona spełnia EQUALS/ONE_OF
value?: string | null; // dla rodziców tekstowych
}
Renderowanie: filtruj pola helperem isConfiguratorFieldVisible z SDK i wcinaj
pod-pole pod jego rodzicem (visibleIf[0].fieldId):
import { isConfiguratorFieldVisible } from '@doswiftly/storefront-sdk';
const visibleFields = fields.filter((field) =>
isConfiguratorFieldVisible(field, { selectedOptionIds: currentSelections }),
);
Dwie zasady, których pilnuje serwer (ewaluacja po stronie klienta jest tylko wygodą renderowania, nigdy granicą bezpieczeństwa):
- Zmiana wyboru rodzica chowa pod-pole → wyczyść jego wartość. Selekcja pola
ukrytego jest odrzucana kodem
ATTRIBUTE_CONDITION_NOT_METprzy każdej mutacji koszyka. - Pole wymagane liczy się tylko, gdy jest widoczne.
cartCompleteodrzuca koszyk bez odpowiedzi w wymaganym widocznym polu kodemATTRIBUTE_REQUIRED_MISSING.
Aktualizacja / czyszczenie konfiguracji
CartLineUpdateInput.attributeSelections ma trójstanową semantykę:
| Wartość | Efekt |
|---|---|
null / pominięte | Zachowuje poprzednie selekcje |
[] pusta tablica | Czyści wszystkie selekcje |
| Niepusta tablica | Zastępuje (nie merge) — zawsze przekaż pełny stan konfiguratora |
// Czyszczenie całej konfiguracji z pozycji koszyka
await cartLinesUpdateMutation.mutateAsync({
cartId,
lines: [{ id: lineId, quantity: 1, attributeSelections: [] }],
});
Renderowanie konfiguracji w koszyku
Pokaż jako sub-linie tylko selekcje z ceną (billingMode === 'SEPARATE_LINE') lub z komponentem (linkedVariantId != null):
{line.attributeSelections.map((sel) => {
const isPriced = sel.billingMode === 'SEPARATE_LINE' || sel.linkedVariantId;
return (
<div key={sel.attributeDefinitionId} className="text-sm text-muted-foreground pl-4">
<span>{sel.attributeName}: {sel.optionLabel ?? sel.textValue}</span>
{isPriced && sel.surchargeAmount > 0 && (
<span className="ml-2">+{formatPrice(sel.surchargeAmount)}</span>
)}
{sel.linkedVariantId && (
<span className="ml-2 text-xs text-emerald-600">Komponent</span>
)}
</div>
);
})}
Przyklady kodu
Przycisk "Dodaj do koszyka"
'use client';
import { useCartActions } from '@/hooks/use-cart-actions';
interface AddToCartButtonProps {
variant: {
id: string;
title: string;
price: { amount: string; currencyCode: string };
available: boolean;
image?: { url: string; altText?: string | null } | null;
};
product: {
id: string;
title: string;
handle: string;
};
}
export function AddToCartButton({ variant, product }: AddToCartButtonProps) {
const { addToCart, isLoading } = useCartActions();
const handleClick = async () => {
await addToCart(variant.id, 1);
};
return (
<button
onClick={handleClick}
disabled={!variant.available || isLoading}
className="w-full bg-primary text-white py-3 rounded-lg disabled:opacity-50"
>
{isLoading
? 'Dodawanie...'
: variant.available
? 'Dodaj do koszyka'
: 'Niedostepny'}
</button>
);
}
Drawer koszyka
'use client';
import { useCartStore } from '@/stores/cart-store';
import { useCart } from '@/lib/graphql/hooks';
import { CartItem } from './cart-item';
import { CartSummary } from './cart-summary';
export function CartDrawer() {
const { cartId, isOpen, closeCart } = useCartStore();
const { data, isLoading } = useCart(cartId);
const cart = data?.cart;
const lines = cart?.lines || [];
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50">
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" onClick={closeCart} />
{/* Panel */}
<div className="absolute right-0 top-0 h-full w-96 bg-white shadow-xl">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">
Koszyk ({cart?.totalQuantity || 0})
</h2>
<button onClick={closeCart}>Zamknij</button>
</div>
{isLoading ? (
<div className="p-4">Ladowanie koszyka...</div>
) : lines.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
Twoj koszyk jest pusty
</div>
) : (
<>
{/* Lista produktow */}
<div className="flex-1 overflow-y-auto p-4">
{lines.map((line) => (
<CartItem key={line.id} line={line} />
))}
</div>
{/* Podsumowanie */}
<CartSummary cost={cart!.cost} />
{/* Przejdz do kasy */}
<div className="p-4 border-t">
<a
href="/checkout"
className="block w-full bg-primary text-white text-center py-3 rounded-lg"
>
Przejdz do kasy
</a>
</div>
</>
)}
</div>
</div>
);
}
Element koszyka z aktualizacja ilosci
'use client';
import { useCartActions } from '@/hooks/use-cart-actions';
import type { CartLine } from '@/generated/graphql';
interface CartItemProps {
line: CartLine;
}
export function CartItem({ line }: CartItemProps) {
const { updateQuantity, removeFromCart } = useCartActions();
const variant = line.variant;
return (
<div className="flex gap-4 py-4 border-b">
{/* Obraz */}
{variant.image && (
<img
src={variant.image.url}
alt={variant.image.altText || ''}
className="w-20 h-20 object-cover rounded"
/>
)}
<div className="flex-1">
{/* Nazwa */}
<h3 className="font-medium">{line.productTitle}</h3>
<p className="text-sm text-muted-foreground">{variant.title}</p>
{/* Cena */}
<p className="font-semibold">
{line.cost.total.amount} {line.cost.total.currencyCode}
</p>
{/* Kontrola ilosci */}
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => updateQuantity(line.id, line.quantity - 1)}
className="w-8 h-8 border rounded"
>
-
</button>
<span className="w-8 text-center">{line.quantity}</span>
<button
onClick={() => updateQuantity(line.id, line.quantity + 1)}
className="w-8 h-8 border rounded"
>
+
</button>
</div>
</div>
{/* Usun */}
<button
onClick={() => removeFromCart(line.id)}
className="text-destructive"
>
Usun
</button>
</div>
);
}
Zastosowanie kodu rabatowego
'use client';
import { useState } from 'react';
import { useCartDiscountCodesUpdate } from '@/lib/graphql/hooks';
import { useCartStore } from '@/stores/cart-store';
import { toast } from 'sonner';
export function DiscountCodeInput() {
const [code, setCode] = useState('');
const { cartId } = useCartStore();
const discountMutation = useCartDiscountCodesUpdate();
const handleApply = async () => {
if (!cartId || !code.trim()) return;
try {
const result = await discountMutation.mutateAsync({
cartId,
discountCodes: [code.trim()],
});
const cart = result.cartDiscountCodesUpdate.cart;
const errors = result.cartDiscountCodesUpdate.userErrors;
if (errors?.length > 0) {
toast.error(errors[0].message);
return;
}
// Sprawdz czy kod zostal zaakceptowany
const appliedCode = cart?.discountCodes?.find(
(dc) => dc.code === code.trim()
);
if (appliedCode?.isApplicable) {
toast.success(`Kod rabatowy "${code}" zastosowany`);
setCode('');
} else {
toast.error('Kod rabatowy jest nieprawidlowy');
}
} catch (error) {
toast.error('Nie udalo sie zastosowac kodu rabatowego');
}
};
return (
<div className="flex gap-2">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Wpisz kod rabatowy"
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={handleApply}
disabled={discountMutation.isPending || !code.trim()}
className="bg-primary text-white px-4 py-2 rounded"
>
{discountMutation.isPending ? 'Stosowanie...' : 'Zastosuj'}
</button>
</div>
);
}
Synchronizacja koszyka
Hook useCart automatycznie pobiera dane koszyka z serwera na podstawie cartId ze store Zustand. Kazda mutacja (useCartAddLines, useCartUpdateLines itd.) po sukcesie invaliduje klucz cache ['Cart'], co powoduje automatyczne odswiezenie danych we wszystkich komponentach konsumujacych useCart.
Uzytkownik klika "Dodaj"
-> useCartActions.addToCart()
-> useCartCreate (jesli brak cartId)
-> useCartAddLines (GraphQL mutation)
-> onSuccess: queryClient.invalidateQueries(['Cart'])
-> useCart automatycznie refetchuje
-> CartDrawer, CartIcon, CartSummary -- wszystkie sie aktualizuja
Ikona koszyka (licznik)
'use client';
import { useCartStore } from '@/stores/cart-store';
import { useCart } from '@/lib/graphql/hooks';
export function CartIcon() {
const { cartId, toggleCart } = useCartStore();
const { data } = useCart(cartId);
const totalQuantity = data?.cart?.totalQuantity || 0;
return (
<button onClick={toggleCart} className="relative">
<ShoppingBagIcon className="w-6 h-6" />
{totalQuantity > 0 && (
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{totalQuantity}
</span>
)}
</button>
);
}
Waluta koszyka
Inicjalizacja waluty
Waluta koszyka jest ustawiana automatycznie przy tworzeniu -- pobierana z konfiguracji sklepu (shop.currency). Storefront developer nie musi jawnie podawac waluty:
// Koszyk automatycznie dziedziczy walute sklepu
// Jesli sklep ma ustawione currency = "PLN", koszyk bedzie w PLN
await addToCart('...', 1);
Niezmiennosc waluty (currency immutability)
Po utworzeniu koszyka waluta nie moze zostac zmieniona. Jest to celowe zabezpieczenie:
- Wszystkie pozycje w koszyku musza byc w tej samej walucie
- Ceny sa przeliczane na walute koszyka w momencie dodawania
- Nie mozna dodac pozycji w innej walucie bez konwersji
CurrencyMismatchException
Gdy wariant produktu ma inna walute niz koszyk, zachowanie zalezy od konfiguracji sklepu:
| Scenariusz | autoConvertPrices = true | autoConvertPrices = false |
|---|---|---|
| Wariant w EUR, koszyk w PLN | Cena automatycznie przeliczona po aktualnym kursie | Blad: Currency mismatch |
| Wariant w PLN, koszyk w PLN | Dodany bez konwersji | Dodany bez konwersji |
// Gdy autoConvertPrices jest wylaczone, proba dodania wariantu w innej walucie
// zwroci blad:
// "Currency mismatch: variant currency EUR does not match cart currency PLN.
// Enable autoConvertPrices on shop to allow automatic conversion."
Gdy autoConvertPrices jest wlaczone, konwersja odbywa sie transparentnie -- storefront developer nie musi obsługiwac tego recznie. Cena w koszyku bedzie juz po konwersji, a currencyCode w obiektach Money zawsze odpowiada walucie koszyka.
Scalanie koszykow a waluta
Przy logowaniu klienta poprzedni koszyk klienta jest scalany do koszyka goscia przez merge(guestCartId). Jesli koszyki maja rozne waluty, scalenie jest zablokowane — metoda rzuca StorefrontError z userErrors[0].code === 'CART_CURRENCY_MISMATCH' (komunikat przetlumaczony przez backend per Accept-Language).
try {
await cartClient.merge(guestCartId);
} catch (err) {
if (err instanceof StorefrontError && err.userErrors[0]?.code === 'CART_CURRENCY_MISMATCH') {
// Koszyk goscia i koszyk klienta maja rozne waluty — scalenie odrzucone.
}
}
Storefront powinien upewnic sie, ze goscie korzystaja z tej samej waluty co sklep, aby uniknac problemow przy logowaniu.
Cart Completion Lifecycle
Od storefront-sdk 9.x cart aggregate pokrywa pelen completion lifecycle (zamiast osobnego Checkout aggregate). Wszystkie operacje wolane sa przez ten sam single surface CartClient — merchandise (get/create/addItems/updateItems/removeItems) + buyer identity + discount codes + fulfillment (adresy + metoda wysylki) + payment + gift cards + completion + przejscia gosc↔klient (merge/downgradeOnLogout/recoveryRedeem, patrz Przejścia gość ↔ klient).
Diagram lifecycle
Lista metod CartClient checkout
| Metoda | GraphQL operation | Returns | Description |
|---|---|---|---|
setShippingAddress({ cartId, address }) | cartSetShippingAddress(input) (mutation) | Cart | Set shipping address. Triggers cart re-pricing (tax recalculation per adres country/region). |
setBillingAddress({ cartId, address }) | cartSetBillingAddress(input) (mutation) | Cart | Set billing address (independent z shipping — pass even when "same as shipping"). |
getAvailableShippingMethods(cartId, address) | cart(id) { availableShippingMethods(address) } (query) | AvailableShippingMethodsPayload | null | Cart-aware shipping discovery przed selectShippingMethod. Payload zawiera methods[] (z deliveryType: HOME | PICKUP_POINT | LOCKER + carrier + price + estimate), freeShippingProgress, userErrors[] z backend-translated message dla biznesowych warunków (DIGITAL_ONLY_NO_SHIPPING, NO_SHIPPING_METHODS, SHIPPING_ERROR). null gdy koszyk nie istnieje. |
selectShippingMethod({ cartId, shippingMethodId }) | cartSelectShippingMethod(input) (mutation) | Cart | shippingMethodId typed ID! (UUID), NIE String. Wybierane z getAvailableShippingMethods(). |
getAvailablePaymentMethods() | availablePaymentMethods (shop-level query) | AvailablePaymentMethods | Zwraca { methods, defaultMethod } — caller pre-selektuje przez result.defaultMethod ?? methods.find(isDefault). |
selectPaymentMethod({ cartId, methodType, preferredProvider?, preferredInstrument? }) | cartSelectPaymentMethod(input) (mutation) | Cart | Method-centric (Intelligent Payment Methods): buyer picks methodType enum (BLIK/CARD/BANK_TRANSFER/WALLET/INSTALLMENT/CASH_ON_DELIVERY) from getAvailablePaymentMethods() response. Backend kieruje do providera wg priorytetów skonfigurowanych przez merchanta. Optional preferredProvider overrides routing when buyer explicitly picks a gateway from PaymentMethod.providersAvailable; optional preferredInstrument deep-linkuje do konkretnego instrumentu (PaymentInstrument.code). No pre-authorization. |
applyGiftCard({ cartId, giftCardCode }) | cartApplyGiftCard(input) (mutation) | Cart | Stackable z discount codes. FIFO consumption. Balance NIE debited do complete. |
removeGiftCard({ cartId, giftCardId }) | cartRemoveGiftCard(input) (mutation) | Cart | Po giftCardId z cart.appliedGiftCards[].id — stable handle (NIE pełny kod karty). Re-calc FIFO amounts dla pozostałych cards. |
updateGiftCardRecipient({ cartId, lineItemId, recipientEmail, recipientName?, message? }) | cartUpdateGiftCardRecipient(input) (mutation) | Cart | Personalised delivery info na gift card line item. Opcjonalne — bez wywołania backend wysyła kartę do order.customerEmail (buyer fallback). Szczegóły w sekcji Karty podarunkowe — dostawa kodu poniżej. |
updateAttributes(cartId, attributes) | cartUpdateAttributes(input) (mutation) | Cart | Replace-all { key, value } pairs na koszyku (max 250 par, max 255 znaków klucza). Użyj dla custom storefront metadata (np. UTM source, A/B variant). |
complete({ cartId, idempotencyKey? }) | cartComplete(input) (mutation) | { order: Order, warnings } | Finalizacja koszyka → zamówienie. order zawsze non-null na sukces. Idempotent on idempotencyKey. Pole cart usunięte z payload (koszyk CONVERTED/locked — zbędny round-trip). NO paymentUrl (D4). |
getOrderByToken(token, email?) | orderByToken($token, $email) (query) | Order | null | Guest order summary po complete — token to opaque order.accessToken z payload cartComplete. Rate-limited 5/min per IP+shop. null gdy token nieprawidłowy lub przeterminowany. |
validateDiscountCode(cartId, code) | cartValidateDiscountCode(cartId, code) (query) | DiscountValidationResult | D3 read-only preview. NIE modifies cart state. Result zawiera isValid + opcjonalny discount lub error z backend-translated message. |
Branżowy wzorzec użycia metod Query
SDK Query metody relayują payload z backendu (industry-standard userErrors envelope) — nigdy nie syntezują wiadomości. Caller branchuje na result.userErrors[].code z backend-translated message:
const result = await cart.getAvailableShippingMethods(cartId, address);
if (!result) {
// Cart nie istnieje — wyczyść local state i utwórz nowy
return renderEmptyCartScreen();
}
if (result.userErrors.length > 0) {
// Backend business condition — wiadomość już przetłumaczona per Accept-Language
// Kody: DIGITAL_ONLY_NO_SHIPPING, NO_SHIPPING_METHODS, SHIPPING_ERROR
return renderShippingError(result.userErrors[0]);
}
// result.methods[] — pokaż picker z opcjami HOME/PICKUP_POINT/LOCKER
// result.freeShippingProgress — pokaż progress bar do darmowej wysyłki
return renderShippingPicker(result.methods, result.freeShippingProgress);
Ten sam wzorzec dla validateDiscountCode (raw DiscountValidationResult z isValid/error/discount slots) i getAvailablePaymentMethods (raw payload z methods + defaultMethod signal).
Pełen checkout w useCartManager (od v17.2.0)
Cała tabela powyżej (metody checkout CartClient) jest dostępna również jako wrappery hook'a useCartManager — z recovery handlerem na stale carcie i auto-cleanup cookie cart-id po sukcesie complete(). Storefront w jednym checkout form nie miesza już dwóch API (read state z useCartManager + raw mutations z new CartClient(client)) — wszystko leci przez hook.
Nowe metody hook'a (wszystkie additive, raw CartClient zostaje dostępny bez zmian):
| Hook method | Wraps | Stale-cart strategia |
|---|---|---|
complete(input?) | cartClient.complete({ cartId, ...input }) | Bail + cart-expired event. Po success: clear cookie + status → idle |
selectShippingMethod(input) | cartClient.selectShippingMethod({ cartId, ...input }) | Bail + event |
selectPaymentMethod(input) | cartClient.selectPaymentMethod({ cartId, ...input }) | Bail + event |
clearPaymentSelection(input?) | cartClient.clearPaymentSelection({ cartId, ...input }) | Bail + event |
applyGiftCard(input) | cartClient.applyGiftCard({ cartId, ...input }) | Bail + event |
removeGiftCard(input) | cartClient.removeGiftCard({ cartId, ...input }) | Bail + event |
updateGiftCardRecipient(input) | cartClient.updateGiftCardRecipient({ cartId, ...input }) | Bail + event |
setBillingAddress(address) | cartClient.setBillingAddress({ cartId, address }) | Bail + event |
updateAttributes(attributes) | cartClient.updateAttributes(cartId, attributes) | Auto-replay przez cartCreate({ attributes }) |
createPayment(input) | cartClient.createPayment(input) | Out of recovery — operuje na orderId |
status.operation rozszerzony o 10 nowych nazw ('complete', 'selectShippingMethod', ...) — jeden <Spinner label={status.operation}/> pokrywa cały checkout. Bail operations triggerują ten sam onExpired(...) listener który już obsługuje updateItem/removeItem — zero dodatkowego wiringu.
Auto-cleanup cart-id cookie po complete() — bez tego buyer wracający na /checkout (back z bramki, deep link, nowa karta) lądował na koszyku CONVERTED zamiast czystego shop-again UX. Od v17.2.0 hook czyści cookie automatycznie i resetuje status do idle; następny addItem(...) tworzy fresh cart.
'use client';
import { useCartManager } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';
export function CheckoutSubmit() {
const router = useRouter();
const { complete, createPayment, status } = useCartManager();
async function onSubmit() {
const { order } = await complete({ idempotencyKey: crypto.randomUUID() });
// cart-id cookie już wyczyszczone — addItem od tego momentu tworzy nowy koszyk.
// Storefront convention: /checkout/success?token=<accessToken>&orderNumber=<orderNumber>
// — token jest opaque kluczem do guest-order lookup (rate-limited 5/min),
// orderNumber to czytelny identyfikator w URL/UI ("ORD-20260527-00016").
const successUrl =
`/checkout/success?token=${order.accessToken}&orderNumber=${order.orderNumber}`;
if (order.canCreatePayment) {
const session = await createPayment({
orderId: order.id,
returnUrl: `${window.location.origin}${successUrl}`,
});
if (session.flow === 'ONLINE_REDIRECT') {
window.location.href = session.redirectUrl!;
return;
}
}
router.push(successUrl);
}
return (
<button onClick={onSubmit} disabled={status.type === 'loading'}>
{status.type === 'loading' ? `Pracuję — ${status.operation}…` : 'Złóż zamówienie'}
</button>
);
}
Manualny clearCart() zostaje dostępny jako escape hatch — complete() auto-cleanup to nadrzędna ścieżka happy-path.
Server-known cart-id (od v17.2.0)
Gdy cart-id przychodzi serwerowo (sample storefront z env seed bo nie ma product listingu, magic-link checkout z /checkout/<token> route handler, embedded iframe z parent postMessage, customer service "view this cart", server-side recovery, multi-cart B2B selector) — przekaż go propsem do hooka przez useCartManager({ initialCartId }). Pattern symetryczny do <StorefrontProvider initialAccessToken> z v17.1.0.
Priorytety: cookie wygrywa → seed → auto-create. Seed jest eagerly promotowany do cookie przy pierwszej operacji, więc cross-tab tabele zbiegają do tej samej wartości i recovery (CART_NOT_FOUND / ALREADY_COMPLETED) działa identycznie jak dla cookie-driven flow.
// app/checkout/[token]/page.tsx — Server Component
import { cookies } from 'next/headers';
import { CART_COOKIE_NAME } from '@doswiftly/storefront-sdk';
import { CheckoutClient } from './CheckoutClient';
import { resolveCartIdFromMagicLinkToken } from '@/lib/magic-link';
export default async function CheckoutPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const cookieJar = await cookies();
// Cookie wygrywa — server-side fallback dopiero gdy cookie pusta.
const initialCartId =
cookieJar.get(CART_COOKIE_NAME)?.value ?? (await resolveCartIdFromMagicLinkToken(token));
return <CheckoutClient initialCartId={initialCartId} />;
}
// app/checkout/[token]/CheckoutClient.tsx
'use client';
import { useCartManager } from '@doswiftly/storefront-sdk/react';
export function CheckoutClient({ initialCartId }: { initialCartId: string | null }) {
// Hook handluje seed → cookie → recovery transparentnie. Zero raw CartClient.
const { complete, selectPaymentMethod, addItem, status } = useCartManager({ initialCartId });
// ... reszta checkoutu identycznie jak dla cookie-driven storefront
}
Stale seed (już nie istnieje na backendzie): hook idzie przez ten sam recovery flow co stale cookie — addItem auto-replays przez cartCreate({ lines }) (cookie nadpisana świeżym cart-id), updateItem/removeItem/checkout state ops bail z cart-expired event. Storefront NIE pisze osobnego try/catch.
Dla obecnych konsumentów: zero migracji — useCartManager() bez argumentu zachowuje identyczne zachowanie. initialCartId to opt-in.
Współdzielona instancja w drzewie — <CartManagerProvider> (od v18.1.0)
useCartManager trzyma stan per-mount (status, koordynator recovery, listenery cart-expired). Wywołany w kilku komponentach tworzy niezależne managery — osobny spinner, osobna kolejka recovery, osobne listenery. Gdy checkout ma być jednym źródłem prawdy (jeden globalny wskaźnik ładowania, jedna kolejka recovery, jedna subskrypcja cart-expired), owiń poddrzewo w <CartManagerProvider> i czytaj wspólną instancję przez useCartManagerContext().
Provider musi być wewnątrz <StorefrontProvider> (korzysta z klienta storefrontu z kontekstu). Storefronty chcące wielu niezależnych instancji (np. multi-cart B2B, admin „podejrzyj ten koszyk") wołają useCartManager() bezpośrednio — provider jest opcjonalny.
Provider przyjmuje też opcjonalne callbacki lifecycle (onMutationStart / onMutationSuccess / onMutationError) — pozwalają zadeklarować cross-cutting side-effecty (toast na błąd, router.refresh() na sukces) w jednym miejscu zamiast owijać każde wywołanie. onMutationError odpala się dla błędów, które można pokazać kupującemu (komunikat pochodzi z backendu, przetłumaczony) — dlatego toast.error(error.message) jest bezpieczne. Wygaśnięcie koszyka i utrata sesji mają osobny kanał onExpired (oraz zdarzenie session-expired) i nie trafiają do onMutationError. Callbacki są wywoływane defensywnie: rzucający callback nie odrzuca mutacji koszyka (błąd jest logowany przez console.warn).
// app/checkout/page.tsx — Server Component rozwiązuje cart-id
import { cookies } from 'next/headers';
import { CART_COOKIE_NAME } from '@doswiftly/storefront-sdk';
export default async function CheckoutPage() {
const cookieJar = await cookies();
const initialCartId = cookieJar.get(CART_COOKIE_NAME)?.value ?? null;
return <CheckoutClient initialCartId={initialCartId} />;
}
// CheckoutClient.tsx — 'use client'
'use client';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
export function CheckoutClient({ initialCartId }: { initialCartId: string | null }) {
const router = useRouter();
return (
<CartManagerProvider
initialCartId={initialCartId}
onMutationSuccess={() => router.refresh()}
onMutationError={(operation, error) => toast.error(error.message)}
>
<CheckoutForm />
</CartManagerProvider>
);
}
function CheckoutForm() {
const { addItem, complete, selectPaymentMethod, status } = useCartManagerContext();
// ... jeden wspólny manager dla całego formularza
}
Dla obecnych konsumentów: zero migracji — <CartManagerProvider> i useCartManagerContext() są additive/opt-in, a useCartManager() działa bez zmian. Provider zastępuje ręczną owijkę React Context, którą storefront pisał dotąd sam.
Karty podarunkowe — dostawa kodu
Storefront-driven recipient mode (common e-commerce convention dla gift card flow): storefront sam decyduje czy zbierać dane odbiorcy karty (e-mail, imię, wiadomość). Backend nie wymaga ani nie waliduje — flow przechodzi przez cartUpdateGiftCardRecipient mutation (opcjonalnie per cart line), a worker pofulfillmentowy wysyła e-mail z kodem do recipient'a jeśli podany, albo do order.customerEmail (buyer) jako fallback.
Per-line semantyka. Każda gift card line w koszyku ma własny recipient slot (key = lineItemId). Quantity > 1 w jednej linii → N kart, ALE wszystkie dla tego samego recipient. Dla różnych odbiorców per sztukę — storefront musi dodać osobne cart lines.
Scenariusze strukturyzowania koszyka:
| Use case | Cart structure | Mutation calls | Wynik fulfillment |
|---|---|---|---|
| 1 karta dla siebie | 1 line, qty=1, brak recipient | 0 | 1 GiftCard, e-mail do buyer |
| 3 karty 50 PLN dla siebie | 1 line, qty=3, brak recipient | 0 | 3 GiftCards (3 osobne kody), 3 e-maile do buyer |
| 1 karta dla Anny | 1 line, qty=1, recipient=anna@x | 1× cartUpdateGiftCardRecipient(lineItemId=L1, recipient=anna) | 1 GiftCard, 1 e-mail do anna |
| 3 karty 50 PLN dla Anny | 1 line, qty=3, recipient=anna | 1× mutation | 3 GiftCards, 3 e-maile do anna |
| 3 karty dla 3 różnych osób | 3 osobne lines (qty=1 każda), różne recipients | 3× cartAddLines + 3× cartUpdateGiftCardRecipient | 3 GiftCards, 3 e-maile (po 1 do każdej osoby) |
| 1 karta 50 PLN dla Anny + 1 karta 100 PLN dla Jana | 2 lines (różne variantId), recipients per line | 2× mutation | 2 GiftCards, 2 e-maile do różnych osób |
Fallback do buyer e-maila. Gwarantowana dostawa — gdy storefront pominie cartUpdateGiftCardRecipient dla danej linii, backend wyśle e-mail z kodem do order.customerEmail (mandatory pole order). Każda zakupiona karta zawsze ląduje w czyjeś skrzynce.
Odczyt zapisanego odbiorcy. Każda CartLine wystawia pole giftCardRecipient: GiftCardLineRecipient (od storefront-sdk 16.1.0). Pole zwraca null gdy odbiorca nie został jeszcze ustawiony albo gdy linia NIE jest typu GIFT_CARD — storefront po cart(id) od razu wie, czy formularz "dane obdarowanego" pokazać pusty (null) czy zainicjalizować wartościami z koszyka. Bez tego pola formularz po reload / SSR / deep link wyglądał na pusty mimo że dane były zapisane (feedback doswiftly-checkout-edge pkt 17).
Pola recipientEmail / recipientName / message są nullable indywidualnie — partial set (np. tylko e-mail, bez imienia ani wiadomości) jest reprezentowalny bezpośrednio. Storefront seeduje formularz przez defaultValues={line.giftCardRecipient ?? { recipientEmail: '', recipientName: '', message: '' }}.
Przykład — formularz "wyślij jako prezent" w PDP / cart drawer:
'use client';
import { useState } from 'react';
import { CartClient } from '@doswiftly/storefront-sdk';
function GiftRecipientForm({ cartId, lineItemId, cartClient }: {
cartId: string;
lineItemId: string;
cartClient: CartClient;
}) {
const [recipientEmail, setRecipientEmail] = useState('');
const [recipientName, setRecipientName] = useState('');
const [message, setMessage] = useState('');
async function handleSave() {
const result = await cartClient.updateGiftCardRecipient({
cartId,
lineItemId,
recipientEmail,
recipientName: recipientName || undefined,
message: message || undefined, // max 500 znaków
});
if (result.userErrors.length > 0) {
console.error(result.userErrors);
return;
}
// Cart updated with recipient — kontynuuj checkout
}
return (
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<input type="email" required value={recipientEmail} onChange={(e) => setRecipientEmail(e.target.value)} placeholder="E-mail odbiorcy" />
<input value={recipientName} onChange={(e) => setRecipientName(e.target.value)} placeholder="Imię odbiorcy (opcjonalne)" />
<textarea value={message} maxLength={500} onChange={(e) => setMessage(e.target.value)} placeholder="Wiadomość (opcjonalna, max 500 znaków)" />
<button type="submit">Zapisz odbiorcę</button>
</form>
);
}
Formularz pokazujesz tylko dla cart lines gdzie product.type === 'GIFT_CARD'. Po cartComplete recipient data jest zapisywana przy zamówieniu i przetwarzana przy realizacji karty podarunkowej.
Pelen przyklad — e2e checkout flow
'use client';
import { useStorefrontClient } from '@doswiftly/storefront-sdk/react';
import { CartClient } from '@doswiftly/storefront-sdk';
function CheckoutFlow({ cartId }: { cartId: string }) {
const client = useStorefrontClient();
const cart = new CartClient(client);
const handleCheckout = async () => {
// 1. Adres dostawy
await cart.setShippingAddress({
cartId,
address: {
firstName: 'Jan', lastName: 'Kowalski',
streetLine1: 'ul. Marszalkowska 1',
buildingNumber: '1', // wymagany dla adresu PL z dostawa pod adres (bez punktu odbioru)
city: 'Warszawa', country: 'PL', postalCode: '00-001',
},
});
// 2. Metoda wysylki (D8 — typed ID!)
await cart.selectShippingMethod({ cartId, shippingMethodId: shippingMethod.id });
// 3. (opcjonalnie) Gift card preview
const giftCard = await cart.validateDiscountCode(cartId, 'WELCOME10');
if (giftCard.isValid) {
// 4. Apply gift card
await cart.applyGiftCard({ cartId, giftCardCode: 'WELCOME10' });
}
// 5. Metoda platnosci — method-centric (Intelligent Payment Methods)
// `paymentMethod.type` to enum kategorii (BLIK / CARD / BANK_TRANSFER / ...) z `getAvailablePaymentMethods()`
// Backend routes do preferowanego provider'a per merchant priority (paymentMethod.preferredProvider)
await cart.selectPaymentMethod({ cartId, methodType: paymentMethod.type });
// Optional: explicit gateway override gdy buyer wybrał konkretny provider z providersAvailable
// preferredProvider to PaymentProvider enum (UPPERCASE) — przekaż paymentMethod.provider bez konwersji
// await cart.selectPaymentMethod({ cartId, methodType: 'BLIK', preferredProvider: 'PAYU' });
// 6. Finalizacja — Order created
// Pole `cart` zostalo usuniete z CartCompleteOutcome — koszyk jest CONVERTED/locked
const { order } = await cart.complete({
cartId,
idempotencyKey: `complete-${cartId}-${Date.now()}`,
});
// 7. Decyzja o flow płatności na podstawie capability signal
// order jest zawsze non-null na sukces (cartComplete gwarantuje order w payload)
if (order.canCreatePayment) {
// Online flow (PayU/Stripe) — wywolaj mutation paymentCreate
const paymentResult = await client.request(PAYMENT_CREATE_MUTATION, {
input: {
orderId: order.id,
returnUrl: `https://moj-sklep.pl/platnosc/wynik`,
cancelUrl: `https://moj-sklep.pl/platnosc/anulowanie`,
},
});
const { payment, userErrors } = paymentResult.paymentCreate;
if (payment?.flow === 'ONLINE_REDIRECT') {
window.location.href = payment.redirectUrl!;
} else if (payment?.flow === 'ONLINE_EMBEDDED') {
initializePaymentWidget(payment.clientSecret!);
} else {
// INSTANT_DIRECT — rozliczone bez UI
router.push(`/zamowienie/${order.id}/potwierdzenie`);
}
} else {
// Offline flow (COD/bank transfer/manual) — pokaz instrukcje
router.push(`/zamowienie/${order.id}/potwierdzenie`);
}
};
return <button onClick={handleCheckout}>Zaplac</button>;
}
Obsluga validateDiscountCode (cache-friendly preview)
validateDiscountCode to Query, NIE Mutation. Returns DiscountValidationResult bez side effects:
const result = await cart.validateDiscountCode(cartId, 'SAVE10');
if (result.isValid && result.discount) {
console.log(`Rabat ${result.discount.value}% — oszczedzasz ${result.discount.discountAmount?.amount}`);
} else if (result.error) {
// DiscountErrorCode: NOT_FOUND, INACTIVE, EXPIRED, USAGE_LIMIT_REACHED,
// CUSTOMER_USAGE_LIMIT_REACHED, MINIMUM_ORDER_NOT_MET, etc.
toast.error(result.error.message);
}
Caching guidance: discount eligibility moze zalezec od minimum order amount. Storefront powinien uzywac fetchPolicy: 'network-only' lub cache key zawierajacy cart.cost.subtotal.amount zeby preview odzwierciedlal aktualny cart state.
Order.canCreatePayment + paymentMethodType (capability signals)
Po cart.complete() returnowany Order jest zawsze non-null na sukces — storefront nie musi wykonywac dodatkowego zapytania order(id) ani customerOrder(orderId) po finalizacji koszyka. Order zawiera 2 capability-aware ResolveFields:
canCreatePayment: Boolean—truegdy storefront moze zainicjowac online platnosc.falsedla:order.status === CANCELLEDluborder.paymentStatus === PAID(nothing to do)OFFLINE_MANUALpayment provider (cash on delivery, bank transfer, manual payment — pokaz instrukcje, NIE button "Zaplac")
paymentMethodType: PaymentMethodType— typed enum, kategoria zmapowana z provider code:CARD,BLIK,BANK_TRANSFER,CASH_ON_DELIVERY,WALLET,INSTALLMENT,OTHER. Storefront uzywa do UI iconography.
Retry-ready: canCreatePayment returns true dla statusow UNPAID/PENDING/FAILED gdy provider supports online init — klient moze ponowic platnosc bez tworzenia nowego Order.
Gdy canCreatePayment === true, nastepnym krokiem jest mutacja paymentCreate — zwraca PaymentSession z polem flow (ONLINE_REDIRECT / ONLINE_EMBEDDED / INSTANT_DIRECT). Storefront robi switch(session.flow) i przekierowuje lub renderuje widget. Pelna referencja (input, kody bledow, rozgalezienie): Mutacje API — paymentCreate.
Migracja z poprzedniego API (pre-9.x)
| Stary kod | Nowy kod |
|---|---|
mutation CheckoutCreate(...) { checkoutCreate(input) } | mutation CartCreate(...) { cartCreate(input: { lines, email?, shippingAddress? }) } (initial fulfillment context w jednym round-trip) |
mutation CheckoutUpdateShippingAddress(...) | mutation CartSetShippingAddress($input: CartSetShippingAddressInput!) { cartSetShippingAddress(input) } |
mutation CheckoutSelectShippingRate($rateId: String!) | mutation CartSelectShippingMethod($input: CartSelectShippingMethodInput!) { cartSelectShippingMethod(input) } (D8 — shippingMethodId: ID!, NIE String!) |
mutation CheckoutComplete(...) { checkoutComplete { order paymentUrl } } | mutation CartComplete($input: CartCompleteInput!) { cartComplete(input) { order { canCreatePayment paymentMethodType } userErrors { ... } warnings { ... } } } (NO cart w payload — koszyk CONVERTED/locked; NO paymentUrl per D4 — uzywaj order.canCreatePayment signal) |
mutation CartApplyDiscountCodes(...) { cartApplyDiscountCodes(...) } | mutation CartDiscountCodesUpdate(...) { cartDiscountCodesUpdate(...) } (D7 — replace-all semantyka) |
SDK migration: po update do @doswiftly/storefront-sdk@9.x, CheckoutClient NIE istnieje — wszystkie operacje w CartClient. Regenerate typed-document codegen w storefront template po update.
Instrument-level pre-selekcja
Pre-selekcja konkretnego instrumentu płatności (BLIK kod / mBank / Apple Pay) → klient ląduje bezpośrednio na ekranie wybranego instrumentu na hosted gateway page zamiast default landing. Distinct error code + warnings array dla context-aware retry handling. Explicit deselect mutation dla accordion "wróć do wyboru metody" UI.
Pełen przewodnik z code examples + error handling + displayHint dispatching pattern: Pre-selekcja instrumentów płatności.