Pre-selekcja instrumentów płatności
Klient wybiera konkretny instrument płatności na storefront (BLIK kod, mBank, Apple Pay) i ląduje BEZPOŚREDNIO na ekranie wybranego instrumentu na hosted gateway page — zamiast default landing z listą wszystkich metod. Branżowy standard skraca funnel o 5-15% na single-instrument checkout flows.
Use case
Storefront ma w panelu wyboru metody płatności tile per instrument:
┌──────────────────────────────────────┐
│ BLIK │
│ ┌────────────┐ ┌────────────┐ │
│ │ 🟦 BLIK kod│ │ 📱 BLIK │ │
│ │ │ │ Mobile │ │
│ └────────────┘ └────────────┘ │
│ │
│ Karty │
│ ┌────────────┐ ┌────────────┐ │
│ │ 💳 Karta │ │ 🍎 Apple │ │
│ │ │ │ Pay │ │
│ └────────────┘ └────────────┘ │
│ │
│ Banki │
│ ┌────────────┐ ┌────────────┐ │
│ │ 🟦 mBank │ │ 🟠 ING │ │
│ │ │ │ │ │
│ └────────────┘ └────────────┘ │
└──────────────────────────────────────┘
Klient klika "BLIK kod" → wybrany instrument zostaje utrwalony na koszyku (Cart.selectedPaymentInstrument = 'blik') i przeniesiony na zamówienie przy cartComplete → paymentCreate → bramka przekierowuje bezpośrednio na ekran wpisywania kodu BLIK (bez listy 6 metod do wyboru). Conversion improvement mierzalny w analytics.
Storefront API NIE wystawia queryowalnego pola Order.paymentInstrumentCode. Wybór instrumentu jest intencją przedpłatniczą utrwaloną na koszyku w polu Cart.selectedPaymentInstrument (String). Backend kopiuje ją na zamówienie przy cartComplete, a paymentCreate używa jej do deep-linku bramki. Z perspektywy storefrontu jedyne queryowalne pole to Cart.selectedPaymentInstrument.
SDK API
Renderowanie instrument tiles
Listę metod płatności (z zagnieżdżonymi instrumentami) pobierasz przez CartClient.getAvailablePaymentMethods(). W warstwie React dostęp do klienta dajesz przez useStorefrontClient():
import { useQuery } from '@tanstack/react-query';
import { CartClient } from '@doswiftly/storefront-sdk';
import { useStorefrontClient } from '@doswiftly/storefront-sdk/react';
const client = useStorefrontClient();
const { data } = useQuery({
queryKey: ['availablePaymentMethods'],
queryFn: () => new CartClient(client).getAvailablePaymentMethods(),
});
// data = { methods, defaultMethod } (raw payload z backendu)
const blik = data?.methods.find((m) => m.type === 'BLIK');
// blik.instruments = [{ provider, code, displayName, type, displayHint, brandImage, enabled }, ...]
Kształt operacji availablePaymentMethods:
AvailablePaymentMethodsqueryPayment Methodsquery AvailablePaymentMethods
Returns the active payment methods for the shop, sorted by the merchant-configured display position. Shop-level — does NOT vary by cart amount or currency. Each method exposes `type` (`CARD`, `BANK_TRANSFER`, `BLIK`, `PAYPAL`, `APPLE_PAY`, `GOOGLE_PAY`, `CASH_ON_DELIVERY`, `OTHER`), provider, icon, description, and supported currencies. Use to render the payment step of checkout.
GraphQL operation
query AvailablePaymentMethods {
availablePaymentMethods {
...AvailablePaymentMethods
}
}
AvailablePaymentMethodsPaymentMethod.instruments field jest nullable — null gdy żaden provider nie expose'uje granular data dla tej method, [] gdy live data ale post-filter empty (cross-provider leak prevention), array gdy gateway expose'uje instrumenty.
Każdy PaymentInstrument carries:
| Field | Type | Semantyka |
|---|---|---|
provider | PaymentProvider (enum) | PAYU / PRZELEWY24 / STRIPE / etc. (UPPERCASE) |
code | String! (opaque) | Gateway-specific identyfikator ('blik', 'mb', '154') |
displayName | String! | User-facing name (z gateway response lub backend enriched per known instruments) |
type | PaymentInstrumentType (enum) | Sub-kategoria — BANK / BLIK_CODE / CARD_BRAND / OTHER / WALLET |
displayHint | PaymentInstrumentDisplayHint (enum) | UX hint — BRANDED_TILE / DROPDOWN_OPTION / PROMINENT_BUTTON / RADIO_OPTION |
brandImage | Image (nullable) | Opcjonalny obrazek marki — selekcja brandImage { url(transform: { maxWidth: 64 }) altText }. null gdy bramka nie wystawia logotypu (np. kod BLIK) |
enabled | Boolean! | Gateway-side disable flag — gray-out tile, NIE hide |
Pre-selekcja instrumentu
import { useCartManager } from '@doswiftly/storefront-sdk/react';
const { selectPaymentMethod } = useCartManager();
// Klient kliknął BLIK kod tile (cartId dorzuca hook — nie podajesz go sam)
await selectPaymentMethod({
methodType: 'BLIK',
preferredProvider: 'PAYU', // PaymentProvider enum (UPPERCASE) — wymagany gdy preferredInstrument set
preferredInstrument: 'blik', // gateway-specific code (PaymentInstrument.code)
});
// Backend utrwala wybór na koszyku — pole Cart.selectedPaymentInstrument
// round-trip w cart query.
Kształt mutacji cartSelectPaymentMethod:
CartSelectPaymentMethodmutationCart Completion Mutationsmutation CartSelectPaymentMethod($input: CartSelectPaymentMethodInput!)
Selects a payment method on the cart by category (`methodType` — BLIK, CARD, BANK_TRANSFER, ...). Optional `preferredProvider` overrides the merchant priority when the buyer explicitly picks a gateway from `PaymentMethod.providersAvailable`. The backend resolves the gateway routing from `MerchantPaymentConfig` at `cartComplete`; the selection persisted here is the category. Errors: `PAYMENT_METHOD_REQUIRED`, `CART_NOT_FOUND`.
Variables
| Name | Type | Default | Required |
|---|---|---|---|
$input | CartSelectPaymentMethodInput! | — | Yes |
GraphQL operation
mutation CartSelectPaymentMethod($input: CartSelectPaymentMethodInput!) {
cartSelectPaymentMethod(input: $input) {
cart {
...Cart
}
userErrors {
...UserError
}
warnings {
...CartWarning
}
}
}
Backend cross-checkuje preferredInstrument przeciw live capabilities PRZED utrwaleniem — jeśli instrument NIE jest w aktualnych instrumentach providera (gateway disable, merchant config update), zwracany jest userError, a stan koszyka NIE jest zmieniany.
Inicjacja płatności + deep-link redirect
import { useCartManager } from '@doswiftly/storefront-sdk/react';
const { createPayment } = useCartManager();
try {
// createPayment przyjmuje OBIEKT PaymentCreateInput, nie goły orderId
const session = await createPayment({
orderId, // z complete().order.id
returnUrl: 'https://sklep.pl/checkout/confirm',
cancelUrl: 'https://sklep.pl/checkout/payment',
});
// Rozgałęź na session.flow — patrz koszyk → "Inicjacja płatności"
if (session.flow === 'ONLINE_REDIRECT' && session.redirectUrl) {
window.location.href = session.redirectUrl; // bezpośrednio na ekran BLIK
}
} catch (err) {
// Patrz error handling poniżej
}
Kształt mutacji paymentCreate:
PaymentCreatemutationCart Completion Mutationsmutation PaymentCreate($input: PaymentCreateInput!)
Initiates a payment session for an order created by `cartComplete` — call this when `order.canCreatePayment` is `true` (orders with an offline payment method like cash-on-delivery skip this step). The session charges the order's **outstanding balance** (total minus settled captures plus settled refunds), not necessarily the full total — a gift-card-covered part or an earlier partial payment is never charged twice; pending (unfinished) sessions do not reduce the balance. `orderId` is required; `returnUrl` / `cancelUrl` are optional and, when supplied, must point to a verified domain of the shop (open-redirect protected). Branch on the returned `payment.flow`: `ONLINE_REDIRECT` → redirect to `payment.redirectUrl`, `ONLINE_EMBEDDED` → render a widget with `payment.clientSecret`, `INSTANT_DIRECT` → already settled, read `payment.status`. Public, but ownership-checked — an authenticated customer cannot pay for another customer's order. **Idempotent** — calling it again for the same order returns the existing still-valid session **as long as its amount still equals the outstanding balance**; when the balance changed in the meantime (e.g. the shop edited the order items), a fresh session on the current balance is created instead (safe to retry either way). Rate limit: 5 requests/minute. `userErrors[].code`: `ORDER_NOT_FOUND`, `ORDER_ALREADY_PAID`, `ORDER_NOT_PAYABLE`, `PAYMENT_PROVIDER_NOT_CONFIGURED`, `RETURN_URL_INVALID`, `INVALID_ID_FORMAT`, `PAYMENT_FAILED`, `INSTRUMENT_PRESELECTION_FAILED`. `warnings[]` array zawiera `INSTRUMENT_CLEARED_FOR_RETRY` post-auto-clear instrument-specific failure (storefront retry hint: next attempt uses gateway default landing).
Variables
| Name | Type | Default | Required |
|---|---|---|---|
$input | PaymentCreateInput! | — | Yes |
GraphQL operation
mutation PaymentCreate($input: PaymentCreateInput!) {
paymentCreate(input: $input) {
payment {
...PaymentSession
}
userErrors {
...UserError
}
warnings {
...PaymentWarning
}
}
}
Error handling — distinct retry semantics
Backend rozróżnia 2 ścieżki failure:
const paymentInput = { orderId, returnUrl, cancelUrl };
try {
const session = await createPayment(paymentInput);
if (session.flow === 'ONLINE_REDIRECT' && session.redirectUrl) {
window.location.href = session.redirectUrl;
}
} catch (err) {
if (err.userErrors?.[0]?.code === 'INSTRUMENT_PRESELECTION_FAILED') {
// Instrument-specific issue — backend auto-wyczyścił wybrany instrument na zamówieniu.
// Kolejny paymentCreate trafi na gateway default landing.
// err.warnings[] zawiera entry { code, message, retryHint }
showRetryUI({
title: 'Instrument płatności niedostępny',
message: err.warnings?.[0]?.message ?? err.message,
onRetry: () => createPayment(paymentInput), // instrument już wyczyszczony, default landing
});
} else if (err.userErrors?.[0]?.code === 'PAYMENT_FAILED') {
// Generic gateway outage (credentials, network, 5xx, circuit breaker)
// Wybrany instrument POZOSTAJE — retry z tym samym instrumentem
showOutageUI({
message: err.message,
onRetry: () => createPayment(paymentInput), // retry same instrument
});
} else if (err.userErrors?.[0]?.code === 'ORDER_ALREADY_PAID') {
// Idempotency edge — order opłacony w międzyczasie (webhook race)
redirectToOrderConfirmation();
}
}
warnings[] array w PaymentCreatePayload jest non-blocking sygnałem (split z userErrors[] per common e-commerce convention). Backend emit'uje warning entry post-auto-clear żeby storefront mógł:
- Wyświetlić context-aware UI message ("instrument zostal wyczyszczony, kolejna próba bramki pokaże wszystkie dostępne metody").
- Dispatchować analytics event distinct od generic gateway failure.
- Decydować retry button placement / progress bar / accordion reset.
displayHint enum dispatching
Każdy instrument carries displayHint semantic UX hint — storefront-dev branchuje rendering per hint:
function InstrumentTile({ instrument, selected, onClick }: InstrumentTileProps) {
return (
<button
onClick={onClick}
aria-pressed={selected}
aria-label={instrument.displayName}
className={cn('instrument-tile', selected && 'selected')}
>
{/* Per-displayHint rendering — storefront decides UX */}
{instrument.displayHint === 'PROMINENT_BUTTON' && (
<span>{instrument.displayName}<br/><small>Duże CTA</small></span>
)}
{instrument.displayHint === 'BRANDED_TILE' && (
<span>{instrument.displayName}<br/><small>Kafelek z logo marki</small></span>
)}
{instrument.displayHint === 'RADIO_OPTION' && (
<span>{instrument.displayName}<br/><small>Wiersz radio</small></span>
)}
{instrument.displayHint === 'DROPDOWN_OPTION' && (
<span>{instrument.displayName}</span>
)}
</button>
);
}
Wartości PaymentInstrumentDisplayHint: BRANDED_TILE | DROPDOWN_OPTION | PROMINENT_BUTTON | RADIO_OPTION.
Pre-built headless components
SDK eksportuje gotowe headless React components — eliminacja boilerplate'a + accessibility-by-default. Components nie mają własnego stylingu — pass className per part (button, icon, label).
<PaymentInstrumentTile>
Single instrument button z ARIA role="radio", aria-checked, data-instrument-code, data-display-hint. PROMINENT_BUTTON displayHint ukrywa brand image (BLIK code entry). Props instrumentu to Pick z PaymentInstrument: code, displayName, displayHint, enabled + opcjonalny brandImage.
import { PaymentInstrumentTile } from '@doswiftly/storefront-sdk/react';
<PaymentInstrumentTile
instrument={{
code: 'blik',
displayName: 'BLIK',
displayHint: 'PROMINENT_BUTTON',
enabled: true,
}}
selected={selectedCode === 'blik'}
onSelect={() => setSelectedCode('blik')}
className="rounded border p-3 data-[selected=true]:border-blue-500"
labelClassName="font-semibold"
/>
displayHint jest emitowany jako data-display-hint attribute — stylizuj przez CSS attribute selectors:
button[data-display-hint='PROMINENT_BUTTON'] { /* duże CTA */ }
button[data-display-hint='BRANDED_TILE'] { /* brand-led, image + label */ }
button[data-display-hint='RADIO_OPTION'] { /* radio-style row */ }
button[data-display-hint='DROPDOWN_OPTION'] { /* text-only */ }
<PaymentInstrumentSection>
Radio-group container z keyboard nav (ArrowDown/Right/Up/Left wrap, Home/End jump) per WAI-ARIA radiogroup pattern. Renderuje jeden tile per instrument w order otrzymanym z backendu (zero own resort — backend zwraca instrumenty w ustalonym porządku: BLIK + portfele, potem banki alfabetycznie, potem reszta).
import { PaymentInstrumentSection, useCartManager } from '@doswiftly/storefront-sdk/react';
import { useState } from 'react';
function CheckoutPaymentStep({ method }: { method: PaymentMethod }) {
const [instrumentCode, setInstrumentCode] = useState<string | undefined>(undefined);
const { selectPaymentMethod } = useCartManager();
return (
<PaymentInstrumentSection
method={method}
selectedInstrumentCode={instrumentCode}
onSelectInstrument={(code) => {
setInstrumentCode(code);
selectPaymentMethod({
methodType: method.type,
preferredProvider: method.preferredProvider ?? undefined,
preferredInstrument: code,
});
}}
sectionClassName="grid grid-cols-2 gap-2"
tileClassName="rounded border p-3 hover:bg-gray-50 data-[selected=true]:border-blue-500"
labelClassName="font-semibold"
ariaLabel="Wybierz instrument płatności"
/>
);
}
Komponent zwraca null gdy instruments[] puste / missing — bezpieczne na method bez instrument-level data (soft providery jak cash on delivery).
Cart re-validation — PAYMENT_SELECTION_STALE
Backend re-walidaje selekcję płatności przy KAŻDYM Cart query względem live gateway capabilities (PayU /paymethods, P24 /payment/methods). Gdy method lub instrument znikło z gateway pomiędzy cartSelectPaymentMethod a query, SDK dostaje signal w Cart.warnings[]:
const { cart } = useCart(cartId);
const staleWarning = cart?.warnings?.find((w) => w.code === 'PAYMENT_SELECTION_STALE');
if (staleWarning) {
// Backend signaluje że klient wybrał metodę która znikła z gateway —
// pokaż dialog "wybierz inną metodę" pre-checkout (zamiast czekać na rejection
// przy paymentCreate). `target` (field path) rozróżnia czy method czy instrument:
if (staleWarning.target === 'selectedPaymentMethod') {
// cart.selectedPaymentMethod === null (downgrade do null)
// cart.selectedPaymentInstrument === null
showRePromptDialog({
title: 'Metoda płatności niedostępna',
message: staleWarning.message,
onSelectAgain: () => router.push('/checkout/payment'),
});
} else if (staleWarning.target === 'selectedPaymentInstrument') {
// cart.selectedPaymentMethod NADAL set (method-level OK)
// cart.selectedPaymentInstrument === null (instrument-level downgrade)
showRePromptDialog({
title: 'Wybrany instrument niedostępny',
message: staleWarning.message,
onSelectAgain: () => /* re-show instrument tiles for current method */,
});
}
}
CartWarning.target to ścieżka pola (String!) — wartość 'selectedPaymentMethod' lub 'selectedPaymentInstrument'. CartWarning.code to stabilny enum (branch na nim, NIE na message — przetłumaczony, locale-dependent).
Read-only signal — backend NIE clear'uje persistencji automatycznie. Storefront wywołuje cartClearPaymentSelection lub świeży cartSelectPaymentMethod(...) żeby zapisać re-selekcję.
Graceful degradation — gdy gateway live capability call zawiedzie (timeout, network outage), Cart zwraca existing selection bez warning. UX continuity nad freshness — cartComplete time-of-payment validation jest finalnym safety net (auto-clear + INSTRUMENT_PRESELECTION_FAILED).
Browser data dla 3DS flows
getBrowserDataForPayment() helper — collects PSD2/3DS2 browser context (userAgent, language, screen dims, color depth, timezone, javaEnabled fallback) w shape matchującym EMVCo 3DS2 BrowserData specification. Forward-looking utility — PaymentCreateInput na razie nie konsumuje browser data (przyjmuje tylko orderId + returnUrl? + cancelUrl?). Helper służy do wcześniejszego zbierania kontekstu po stronie przeglądarki; stanie się mandatory dopiero w przyszłych przepływach PSD2/SCA (card-on-file challenge, BLIK confirmation z risk scoring).
import { getBrowserDataForPayment, BrowserDataNotAvailableError } from '@doswiftly/storefront-sdk/react';
function handleCheckoutSubmit() {
try {
const browserData = getBrowserDataForPayment();
// browserData zebrane z przeglądarki — przekażesz je do bramki gdy
// PaymentCreateInput zacznie je przyjmować (przyszłe przepływy 3DS2).
await createPayment({ orderId, returnUrl, cancelUrl });
} catch (err) {
if (err instanceof BrowserDataNotAvailableError) {
// SSR / no DOM — pomiń zbieranie browser data
await createPayment({ orderId, returnUrl, cancelUrl });
return;
}
throw err;
}
}
Browser-only — throw BrowserDataNotAvailableError w SSR (Server Component, Route Handler). Wywołuj w event handler lub useEffect.
Explicit deselect — cartClearPaymentSelection
Accordion UI "wróć do wyboru metody" — atomic NULL na wszystkich payment selection fields w jednym round-trip:
const { clearPaymentSelection } = useCartManager();
// Klient kliknął "wróć do wyboru metody" — cartId dorzuca hook, nie podajesz go sam
await clearPaymentSelection();
// Cart.selectedPaymentMethod === null
// Cart.selectedPaymentInstrument === null
Kształt mutacji cartClearPaymentSelection:
CartClearPaymentSelectionmutationCart Completion Mutationsmutation CartClearPaymentSelection($input: CartClearPaymentSelectionInput!)
Clears all payment selection state on the cart in a single atomic operation. Use for accordion "back to method picker" flows w storefront UI. Idempotent — calling twice yields the same end state. Cart MUSI być `ACTIVE` (CONVERTED carts reject z `ALREADY_COMPLETED`). Rate limit: 30 requests/minute per IP+shop.
Variables
| Name | Type | Default | Required |
|---|---|---|---|
$input | CartClearPaymentSelectionInput! | — | Yes |
GraphQL operation
mutation CartClearPaymentSelection($input: CartClearPaymentSelectionInput!) {
cartClearPaymentSelection(input: $input) {
cart {
...Cart
}
userErrors {
...UserError
}
warnings {
...CartWarning
}
}
}
Idempotent — wielokrotne wywołanie = same final state. Cart MUSI być ACTIVE — CONVERTED carts reject z userErrors[0].code === 'ALREADY_COMPLETED'. Operacja nie ma dedykowanego limitu per operację — objęta jest wyłącznie ogólnym budżetem zapytań koszyka.
Migracja z wcześniejszego API
Wcześniejsze integracje storefrontu używały innego nazewnictwa płatności (3-warstwowy refaktor: provider / method / instrument):
cartSelectPaymentMethod({ preferredProviderCode })→ renamed dopreferredProvider(clean break, NIE backward-compat alias).cartSelectPaymentMethod({ preferredInstrumentCode })→ renamed dopreferredInstrument.PaymentMethod.provider === 'payu'string compare → typed enumPaymentProvider.PAYU(UPPERCASE). EnumProviderCodeprzemianowany naPaymentProvider.- Typ
PaymentMethodInstrumentprzemianowany naPaymentInstrument; polainstrumentCode→code,providerCode→provider,brandImageUrl(String) →brandImage(Image— selekcjabrandImage { url(transform: { maxWidth: 64 }) altText }). Cart.selectedPaymentInstrumentCode→ renamed doCart.selectedPaymentInstrument(String).
Wartości enumów (po refaktorze):
PaymentProvider:PAYU|PRZELEWY24|STRIPE|CASH_ON_DELIVERY|BANK_TRANSFER|MANUAL_PAYMENT|GIFT_CARD|TEST_GATEWAY.PaymentMethodType:BANK_TRANSFER|BLIK|CARD|CASH_ON_DELIVERY|INSTALLMENT|OTHER|WALLET.PaymentInstrumentType:BANK|BLIK_CODE|CARD_BRAND|OTHER|WALLET.PaymentInstrumentDisplayHint:BRANDED_TILE|DROPDOWN_OPTION|PROMINENT_BUTTON|RADIO_OPTION.
Pełen migration guide w CHANGELOG @doswiftly/storefront-sdk.
Pozostałe elementy systemu pre-selekcji:
PaymentErrorCode.INSTRUMENT_PRESELECTION_FAILEDdistinct value.PaymentCreatePayload.warnings: [PaymentWarning!]!field.cartClearPaymentSelectionmutation.Cart.warnings: [CartWarning!]!field z runtime stale check (PAYMENT_SELECTION_STALEcode,target='selectedPaymentMethod'lub'selectedPaymentInstrument').Cart.selectedPaymentMethodre-walidaje przy każdym query —nullgdy method stale.Cart.selectedPaymentInstrumentre-walidaje przy każdym query —nullgdy instrument stale (method preserved).CartWarningCode.PAYMENT_SELECTION_STALEenum value.<PaymentInstrumentTile>+<PaymentInstrumentSection>pre-built headless React components.getBrowserDataForPayment()helper +BrowserDataNotAvailableError+PaymentBrowserDatatype dla PSD2/3DS2 flows.
Migration checklist:
- Zmień
preferredProviderCode→preferredProvideripreferredInstrumentCode→preferredInstrumentw wywołaniachcartSelectPaymentMethod. - Zmień selekcję
brandImageUrl→brandImage { url(transform: { maxWidth: 64 }) altText }(jeśli używasz custom GraphQL). - Zmień
Cart.selectedPaymentInstrumentCode→Cart.selectedPaymentInstrumentw fragmentach koszyka. - Zmień string compare
provider === 'payu'na typed enumPaymentProvider.PAYU(UPPERCASE). - Add
warnings[]selection wpaymentCreatequery (jeśli używasz custom GraphQL bez SDK helpers). - Add
warnings { message code target }selection w cart query (jeśli custom GraphQL). - Branch UI dispatch na
userErrors[0].code === 'INSTRUMENT_PRESELECTION_FAILED'distinct odPAYMENT_FAILED. - Branch UI na
cart.warnings[].code === 'PAYMENT_SELECTION_STALE'żeby pokazać pre-checkout re-prompt. - (Optional) Replace accordion-reset hack z
cartClearPaymentSelection()— clearer API, idempotent. - (Optional) Replace custom instrument picker z
<PaymentInstrumentSection>— saves boilerplate, full ARIA + keyboard nav. - (Forward-looking) Adopt
getBrowserDataForPayment()w checkout submit handlers gdy 3DS flow staje się relevant.
Powiązane
- Koszyk — pełen lifecycle Cart → Order
- Konto klienta — auth + customer flow
- Wprowadzenie do SDK — instalacja + configure