Przejdź do głównej zawartości

Odtworzenie zaznaczeń kasy po odświeżeniu

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

Kasę, która pamięta wybory kupującego: po odświeżeniu strony, powrocie z innej karty albo wejściu w link „wróć do kasy" z e-maila podsumowanie dostawy i płatności wygląda dokładnie tak, jak przed wyjściem — zamiast pustej kasy zaczynającej od zera.

Sedno przepisu to jedna zasada:

Koszyk jest jedynym źródłem prawdy o zaznaczeniach. Nie trzymaj kroku kasy ani wybranych opcji w stanie komponentu — po odświeżeniu strony stan lokalny zniknie, a koszyk pamięta wszystko. Interfejs się odtwarza z koszyka, a gdy trzeba, zaznaczenia wracają do interfejsu tymi samymi mutacjami, którymi powstają.

Wymagania

Krok 1 — Odczytaj zaznaczenia z koszyka

'use client';
import { useCart } from '@/lib/graphql/hooks';

const { data, isLoading, error } = useCart({ id: '…' });

Trzy pola odpowiadają za odtworzenie:

  • selectedShippingMethod — wybrana dostawa: tytuł („DPD Standard, 2 dni"), cena i handle;
  • selectedPaymentMethod — wybrany typ płatności (CARD, BLIK, BANK_TRANSFER…);
  • selectedPaymentProvider i selectedPaymentInstrument — dostawca i konkretna pozycja wewnątrz metody (to one odróżniają kafelek „mBank" od kafelka „Santander" w ramach tej samej metody).

Z tej trójki wynika też aktualny krok kasy: brak zaznaczonej dostawy = krok dostawy, brak zaznaczonej płatności = krok płatności, oba zaznaczone = podsumowanie. Liczenie kroku z koszyka, a nie ze stanu, sprawia że kasa otwiera się zawsze w tym miejscu, w którym kupujący skończył.

Krok 2 — Złóż kasę odczytującą zaznaczenia

zaznaczenia-kasy.tsx
'use client';

import { useCallback, useState } from 'react';
import { useCartManager, useFormatPrice } from '@doswiftly/storefront-sdk/react';
import { CartWarningCode } from '@doswiftly/storefront-sdk';
import type { Cart } from '@doswiftly/storefront-sdk';

// Koszyk jest jedynym źródłem prawdy o tym, gdzie kupujący jest w kasie:
// wybór dostawy wisi na `cart.selectedShippingMethod`, wybór płatności na
// `cart.selectedPaymentMethod`. Nie trzymaj tego w stanie komponentu — po
// odświeżeniu strony stan lokalny zniknie, a koszyk pamięta wszystko.
export function checkoutStepFromCart(cart: Cart): 'shipping' | 'payment' | 'summary' {
if (cart.selectedShippingMethod == null) return 'shipping';
if (cart.selectedPaymentMethod == null) return 'payment';
return 'summary';
}

// Zestawienie zaznaczonych opcji — po odświeżeniu strony kasa odczytuje je z
// koszyka i pokazuje dokładnie to, co kupujący widział przed wyjściem.
export function SelectionSummary({ cart }: { cart: Cart }) {
const formatPrice = useFormatPrice();

return (
<dl>
<dt>Dostawa</dt>
<dd>
{cart.selectedShippingMethod == null ? (
'niewybrana'
) : (
<>
{cart.selectedShippingMethod.title}{' '}
{formatPrice(cart.selectedShippingMethod.price)}
</>
)}
</dd>

<dt>Płatność</dt>
<dd>
{cart.selectedPaymentMethod == null ? (
'niewybrana'
) : (
cart.selectedPaymentMethod.name
)}
</dd>
</dl>
);
}

// Odtworzenie zaznaczeń po tym, jak stan interfejsu się zgubił (przeładowanie
// strony, powrót z innej karty, nowo zamontowany komponent kasy). Koszyk
// pamięta wybory, więc odtwarzanie to po prostu TE SAME mutacje co zwykły
// wybór — z wartościami zapisanymi na koszyku, nie z lokalnego stanu.
export function useCheckoutSelectionRestore(cart: Cart) {
const { selectShippingMethod, selectPaymentMethod } = useCartManager();
const [warning, setWarning] = useState<string | null>(null);

const restore = useCallback(async () => {
setWarning(null);

const shipping = cart.selectedShippingMethod;
if (shipping != null) {
// `handle` odtworzonej dostawy odpowiada `id` metody z listy dostępnych.
await selectShippingMethod({ shippingMethodId: shipping.handle });
}

const payment = cart.selectedPaymentMethod;
if (payment != null) {
const outcome = await selectPaymentMethod({
methodType: payment.type,
// Dostawca i konkretna pozycja przychodzą z powrotem jako `preferred*`,
// żeby po odtworzeniu zaznaczony był dokładnie ten sam kafelek — sam
// typ metody nie odróżnia np. dwóch przelewów u różnych dostawców.
preferredProvider: cart.selectedPaymentProvider ?? undefined,
preferredInstrument: cart.selectedPaymentInstrument ?? undefined,
});

// Ostrzeżenie zamiast błędu: odtworzenie się udało, ale zapisany wybór
// przestał być ważny (np. metoda została wyłączona, odkąd kupujący był
// na stronie). Rozpoznaj go po kodzie, nie po treści — komunikat jest
// przetłumaczony, ale kod jest stały. Kupujący wybiera formę na nowo;
// nie przeskakuj tego kroku za niego.
if (outcome.warnings.some((w) => w.code === CartWarningCode.PaymentSelectionStale)) {
setWarning('Zapisana forma płatności jest już niedostępna — wybierz inną.');
}
}
}, [cart, selectShippingMethod, selectPaymentMethod]);

return { restore, warning };
}

Trzy rzeczy warte uwagi w tym kodzie:

  • Krok kasy liczony jest z koszyka (checkoutStepFromCart) — to funkcja od stanu serwera, nie od tego, co komponent zdążył zapamiętać.
  • Odtworzenie używa tych samych mutacji co zwykły wybór. Nie ma osobnej ścieżki „przywróć" — wartości pochodzą z koszyka, więc dokładnie jeden kod obsługuje wybór i jego odtworzenie.
  • Dostawca i pozycja wracają jako preferred*, żeby po odświeżeniu zaznaczony był dokładnie ten sam kafelek. Sam typ metody nie odróżnia dwóch przelewów u różnych dostawców.

Krok 3 — Gdy zapisany wybór przestał być ważny

Odtworzenie może spotkać się z odmową pośrednią: mutacja przechodzi, ale w odpowiedzi przychodzi ostrzeżenie z kodem PAYMENT_SELECTION_STALE — zapisana forma płatności jest już niedostępna (sklep ją wyłączył, zmieniła się waluta, wygasła sesja bramki). Kod jest stały, rozpoznawaj go po nim, nie po treści komunikatu.

W takim wypadku wróć do kroku płatności i pozostaw wybór kupującemu. Nie wybieraj za niego pierwszej dostępnej metody: koszt ma w tym momencie znaczenie realne (np. dopłata za pobranie — patrz Koszt formy płatności na kafelku).

Do jawnego wycofania zaznaczenia (kupujący cofnął wybór w interfejsie) służy osobna mutacja:

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 = `mutation CartClearPaymentSelection($input: CartClearPaymentSelectionInput!) {
cartClearPaymentSelection(input: $input) {
cart {
...Cart
}
userErrors {
...UserError
}
warnings {
...CartWarning
}
}
}

fragment Cart on Cart {
id
checkoutUrl
totalQuantity
cost {
...CartCost
}
lines(first: 100) {
edges {
cursor
node {
... on CartLine {
...CartLine
}
}
}
nodes {
... on CartLine {
...CartLine
}
}
pageInfo {
...PageInfo
}
totalCount
}
buyerIdentity {
...CartBuyerIdentity
}
discountCodes {
...CartDiscountCode
}
discountAllocations {
...CartDiscountAllocation
}
note
attributes {
key
value
}
email
phone
shippingAddress {
...MailingAddress
}
billingAddress {
...MailingAddress
}
selectedShippingMethod {
...CartShippingMethod
}
selectedPaymentMethod {
...CartSelectedPaymentMethod
}
selectedPaymentProvider
selectedPaymentInstrument
appliedGiftCards {
...CartAppliedGiftCard
}
requiresShipping
createdAt
updatedAt
status
completedOrder {
id
orderNumber
accessToken
status
paymentStatus
fulfillmentStatus
}
}

fragment CartAppliedGiftCard on CartAppliedGiftCard {
id
maskedCode
lastCharacters
appliedAmount {
...Money
}
remainingBalance {
...Money
}
}

fragment Money on Money {
amount
currencyCode
}

fragment CartBuyerIdentity on CartBuyerIdentity {
email
phone
countryCode
}

fragment CartCost on CartCost {
total {
...Money
}
subtotal {
...Money
}
totalTax {
...Money
}
feeTotal {
...Money
}
feeAllocations {
label
amount {
...Money
}
}
pricesIncludeTax
totalDuty {
...Money
}
checkoutCharge {
...Money
}
totalDiscount {
...Money
}
totalShipping {
...Money
}
}

fragment CartDiscountAllocation on CartDiscountAllocation {
discountCode
amount {
...Money
}
}

fragment CartDiscountCode on CartDiscountCode {
code
isApplicable
}

fragment CartLine on CartLine {
id
quantity
variant {
...ProductVariant
}
cost {
...CartLineCost
}
discountAllocations {
discountCode
amount {
...Money
}
}
attributes {
key
value
}
attributeSelections {
...AttributeSelection
}
productId
productTitle
productHandle
productType
requiresShipping
giftCardRecipient {
recipientEmail
recipientName
message
}
}

fragment AttributeSelection on AttributeSelection {
attributeDefinitionId
attributeName
type
fillingMode
billingMode
optionId
optionLabel
optionIds
textValue
surchargeAmount
surchargeType
taxClassId
linkedVariantId
}

fragment CartLineCost on CartLineCost {
pricePerUnit {
...Money
}
subtotal {
...Money
}
total {
...Money
}
compareAtPricePerUnit {
...Money
}
}

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
}

fragment CartSelectedPaymentMethod on PaymentMethod {
id
name
provider
type
icon {
...ImageThumbnail
}
description
isDefault
supportedCurrencies
position
}

fragment CartShippingMethod on CartShippingMethod {
handle
title
price {
...Money
}
}

fragment MailingAddress on MailingAddress {
id
streetLine1
streetLine2
buildingNumber
flatNumber
city
company
country
countryCode
firstName
lastName
name
phone
state
stateCode
postalCode
isDefault
taxId
vatNumber
regon
pickupPoint {
...PickupPoint
}
}

fragment PickupPoint on PickupPoint {
provider
pointId
name
address
paymentAvailable
}

fragment PageInfo on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}

fragment CartWarning on CartWarning {
message
code
target
}

fragment UserError on UserError {
message
code
field
}`;

const res = await fetch(`${apiUrl}/storefront/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: QUERY, variables: { input: /* … */ }, }),
});
const { data } = await res.json();

Typy

CartShippingMethodobjectPełna referencja →

The shipping method currently selected on a cart, with its display label and cost.

PoleTypOpis
handleID!Handle of the selected shipping method — matches the `id` of an `AvailableShippingMethod`.
priceMoney!Cost of the selected shipping method.
titleString!Display title for the checkout summary (e.g. "DPD Standard, 2 days").
CartWarningobjectPełna referencja →

Cart warning (non-fatal advisory)

PoleTypOpis
codeCartWarningCode!Stable machine-readable warning code. Branch on this for UI behaviour — never on `message` (translated, locale-dependent).
messageString!Human-readable warning message — informational, the mutation itself succeeded.
targetString!What the warning is about — a cart line ID when the warning refers to a specific item, or a field path otherwise.
PaymentMethodobjectPełna referencja →

A payment method offered to the buyer at checkout — what to render in the payment picker and pass to `cartSelectPaymentMethod`.

PoleTypOpis
acknowledgements[PaymentAcknowledgement!]!Consents the buyer can affirm before paying with this method (e.g. the Przelewy24 regulation declaration). Render each as a checkbox using `statement` + `documents`, then echo accepted `code`s back in `PaymentCreateInput.acknowledgements`. Empty when the method carries no acknowledgements.
availableBoolean!True when the buyer can actually pick this method right now. False when the resolving gateway is temporarily unavailable (incident/maintenance) or reported the method as disabled. Storefront UI should gray-out the tile when false instead of hiding it — gives merchants observability into routing failures.
descriptionStringOptional buyer-facing description shown under the name (e.g. "Pay with your bank app").
feePaymentMethodFeeSurcharge for picking this method, ready to render on its tile (e.g. "Cash on delivery +5 zł"). Present only when the whole method maps to one fee identity: cash on delivery, or a method whose every instrument shares it. The amount follows the provider that handles the payment by default (the preferred one) — when several providers back the method with different fee setups, per-provider amounts live on `instruments[].fee`. Null when identities differ, when no fee applies, or on the shop-level query — only `Cart.availablePaymentMethods` carries amounts.
iconImageIcon image for the method tile in the payment picker. When the merchant uploaded custom artwork, `url` is absolute and ready to render. When they did not, the platform emits a RELATIVE fallback path following the `/icons/payment/{provider}.svg` convention (`provider` = lowercase provider code, e.g. `payu`, `przelewy24`, `bank_transfer`) — such a path is NOT served by the API: either ship matching files with the storefront or ignore relative URLs and derive artwork from `type`. Never feed a relative `url` straight into an img tag.
idID!Stable ID of the payment method. Pass to `cartSelectPaymentMethod` to select it.
instruments[PaymentInstrument!]Concrete instruments exposed by gateway providers within this method (BLIK code, branded banks, wallets, card brands). Null when no provider exposes granular data for this method. Empty array when a gateway exposes them but all instruments are disabled or removed by post-filtering (cross-provider leak prevention). Render the list and pass `code` as `preferredInstrument` (together with `preferredProvider`) in `cartSelectPaymentMethod` to deep-link the gateway to this screen. Key list items and selection state by the (provider, code) PAIR — `code` alone is not unique in this list (two providers can expose the same code for one method). An instrument with no `brandImage` whose `displayName` merely repeats the method category or its own code adds nothing over the method tile — consider hiding such entries and rendering the picker only when two or more presentable instruments remain.
isDefaultBoolean!True when the merchant has marked this method as the default. Pre-select it in the picker.
nameString!Display name configured by the merchant (e.g. "BLIK", "Credit card", "Cash on delivery").
positionFloat!Merchant-configured display position — lower values come first in the picker.
preferredProviderPaymentProviderPreferred provider (UPPERCASE enum) that the backend will route to when the buyer picks this method type and does not specify `preferredProvider`. Populated only when at least one provider supports the type.
providerPaymentProvider!Provider (e.g. `PAYU`, `STRIPE`, `PRZELEWY24`, `CASH_ON_DELIVERY`). Identifies the integration behind the method; do not branch UI on it — use `type` instead.
providersAvailable[PaymentProvider!]Providers (UPPERCASE enum: `PAYU`, `PRZELEWY24`, ...) that can fulfil this method type for the current shop, ordered by merchant priority. Pre-select `preferredProvider`; expose the rest only when the buyer wants to choose explicitly. Single-element array when only one provider supports the type.
supportedCurrencies[String!]ISO 4217 currency codes the method accepts. Null when the method accepts the shop currency without restriction.
typePaymentMethodType!Category of the method (CARD, BLIK, BANK_TRANSFER, INSTALLMENT, WALLET, CASH_ON_DELIVERY, OTHER). Drives iconography and copy.
unavailableReasonPaymentMethodUnavailableReasonWhen `available` is false, this enum carries the diagnostic reason (GATEWAY_DOWN, GATEWAY_DISABLED, NO_INSTRUMENTS, CREDENTIALS_INVALID). Null when `available` is true. UI can render context-aware copy ("PayU is temporarily down" vs "Method not configured").

Powiązane