Koszt formy płatności na kafelku
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
Wybór formy płatności, przy którym kupujący od razu widzi ile realnie zapłaci: kafelek „Pobranie +5,00 zł" zamiast samego „Pobranie", a w podsumowaniu po wyborze — wiersz dopłaty z kwotą. To realna różnica w konwersji: dopłata ujawniona dopiero w podsumowaniu wygląda jak ukryty koszt, ujawniona na kafelku jest informacją.
Sedno przepisu to jedna zasada:
Kwotę dopłaty zawsze bierzesz z API, nigdy nie liczysz jej sam. Dopłata może być procentem od wartości zamówienia, więc dokładna kwota istnieje dopiero wtedy, gdy istnieje koszyk. „Zakodowany od boku" przelicznik procentu przestanie się zgadzać, gdy sklep zmieni stawkę albo kupujący użyje kodu rabatowego.
Wymagania
- Skonfigurowany SDK — Konfiguracja Next.js.
- Koszyk utworzony przez
cartCreate— kwoty dopłat są policzone dla konkretnego koszyka, więc ten przepis zaczyna się po jego utworzeniu. - Sklep z włączoną opłatą za wybraną formę płatności — bez opłaty wszystko poniżej po prostu się nie pokazuje. Sama konfiguracja opłat to praca sprzedawcy w panelu; storefront nic o niej nie musi wiedzieć.
Krok 1 — Pobierz metody płatności z kwotami dopłat
- Raw (dowolny framework)
Brak gotowego helpera SDK dla tej operacji — użyj raw operation (działa w każdym frameworku).
// Działa w dowolnym frameworku (Vue, Svelte, vanilla JS, Node, Edge)
const QUERY = `query CartAvailablePaymentMethods($cartId: ID!) {
cart(id: $cartId) {
id
availablePaymentMethods {
...PaymentMethod
}
}
}
fragment PaymentMethod on PaymentMethod {
id
name
provider
type
icon {
...ImageThumbnail
}
description
isDefault
supportedCurrencies
position
providersAvailable
preferredProvider
available
unavailableReason
instruments {
...PaymentInstrument
}
acknowledgements {
code
enforcement
statement
documents {
token
kind
url
}
}
fee {
amount {
...Money
}
label
ratePercent
}
}
fragment ImageThumbnail on Image {
id
url(transform: {maxWidth: 300})
altText
width
height
thumbhash
}
fragment Money on Money {
amount
currencyCode
}
fragment PaymentInstrument on PaymentInstrument {
provider
code
type
displayName
displayHint
brandImage {
...ImageThumbnail
}
enabled
fee {
amount {
...Money
}
label
ratePercent
}
}`;
const res = await fetch(`${apiUrl}/storefront/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: QUERY, variables: { cartId: '…' }, }),
});
const { data } = await res.json();
Każda metoda niesie pole fee z kwotą dopłaty dla tego koszyka: +5,00 zł przy ryczałcie, +2,5% (7,18 zł) przy opłacie procentowej (ratePercent jest tylko informacją pomocniczą — wiążąca jest fee.amount). Kafelek i podsumowanie korzystają z tego samego pola, więc nigdy się nie rozjadą.
fee jest też rozpisane per opcja, gdy jedna forma kryje kilka pozycji o różnych opłatach (np. poszczególne marki kart): wtedy fee samej metody jest null, a opłata wisi na każdej z instruments[].fee.
Krok 2 — Złóż kafelek metody
'use client';
import { useFormatPrice } from '@doswiftly/storefront-sdk/react';
import type { CartCost, PaymentMethod } from '@doswiftly/storefront-sdk';
// Dopłata za formę płatności (np. obsługę pobrania), dokładnie taką, jaką ją
// oddaje API. Kwota przychodzi już policzona DLA KONKRETNEGO koszyka —
// dopłata może być procentem od wartości zamówienia, więc przelicznika „z
// boku" nie da się odtworzyć wiernie w przeglądarce. Kwota z `fee.amount` jest
// tą samą, która po wyborze pokazuje się w podsumowaniu — kafelek i
// podsumowanie nie mogą się nigdy rozjechać.
type MethodFee = NonNullable<PaymentMethod['fee']>;
// Napis na kafelku: „+2,5% (7,18 zł)" przy opłacie procentowej, „+7,18 zł"
// przy ryczałcie. Procent (`ratePercent`) jest wyłącznie informacją
// pomocniczą przy kwocie — pokazanie go to kwestia prezentacji, nie wyliczenia.
function FeeBadge({ fee }: { fee: MethodFee }) {
const formatPrice = useFormatPrice();
if (fee.ratePercent != null) {
return <em>+{fee.ratePercent}% ({formatPrice(fee.amount)})</em>;
}
return <em>+{formatPrice(fee.amount)}</em>;
}
// Kafelek jednej formy płatności z kwotą dopłaty widoczną PRZED wyborem.
// `fee` null znaczy „ta forma nie ma dopłaty" — nie renderuj wtedy niczego.
// Nie pokazuj też „0 zł" ani „darmowe": brak dopłaty i zero złotych to dwie
// różne rzeczy w momencie, gdy klient porównuje formy płatności.
export function PaymentMethodTile({
method,
selected,
onSelect,
}: {
method: PaymentMethod;
selected?: boolean;
onSelect: () => void;
}) {
// Gdy cała forma ma jedną wspólną opłatę (np. obsługa pobrania), jest ona
// na kafelku wprost. Gdy nie — opłata może różnić się per pozycja
// (np. marka karty), wtedy każda pozycja niesie własną.
const methodFee = method.fee;
const instruments = methodFee == null ? (method.instruments ?? []) : [];
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected ?? false}
disabled={method.available === false}
>
<span>{method.name}</span>
{methodFee != null && <FeeBadge fee={methodFee} />}
{methodFee == null && instruments.some((instrument) => instrument.fee != null) && (
<ul>
{instruments.map((instrument) =>
instrument.fee == null ? null : (
<li key={instrument.code}>
{instrument.displayName}: <FeeBadge fee={instrument.fee} />
</li>
),
)}
</ul>
)}
</button>
);
}
// Pasek potwierdzenia po wyborze formy. Wiersze przychodzą z
// `cost.feeAllocations` z gotową, przetłumaczoną etykietą (tę samą pokaże
// faktura — nie twórz własnego opisu tej pozycji), a suma wszystkich dopłat
// jako `cost.feeTotal`. Kwota jest już wliczona w `cost.total`, więc pasek
// ją nazywa, NIE dodaje do niczego.
export function FeeSummaryBar({ cost }: { cost: CartCost }) {
const formatPrice = useFormatPrice();
// Brak dopłaty = brak paska. Pusty stan zamiast pokaźnego „0,00 zł".
if (cost.feeAllocations.length === 0) return null;
return (
<div role="status">
<ul>
{cost.feeAllocations.map((fee) => (
<li key={fee.label}>
{fee.label}: {formatPrice(fee.amount)}
</li>
))}
</ul>
<p>
<strong>Dopłata razem: {formatPrice(cost.feeTotal)}</strong>
</p>
</div>
);
}
Trzy rzeczy warte uwagi w tym kodzie:
- Brak dopłaty to brak elementu, nie „0 zł".
fee: nulloznacza „ta forma nie ma dopłaty" — kafelek renderuje się bez napisu. Wpisywanie zamiast tego „darmowe" lub „0,00 zł" myli klienta, który porównuje formy. - Kwota przychodzi gotowa, także „co to znaczy dla klienta". Etykiety wierszy w podsumowaniu (
feeAllocations[].label) są przetłumaczone i spójne z fakturą — nie twórz własnego opisu dopłaty. - Procent obok kwoty to ozdoba, nie podstawa. Format „+2,5% (7,18 zł)" czyta
ratePercenttylko dla wyświetlenia; liczby wiążąca jestfee.amount.
Krok 3 — Potwierdzenie po wyborze
Kwota dopłaty wchodzi na koszyk w momencie wyboru formy — odczytasz ją z pola cost bez żadnego dodatkowego zapytania do metod:
- Client Component
- Raw (dowolny framework)
'use client';
import { useCart } from '@/lib/graphql/hooks';
const { data, isLoading, error } = useCart({ id: '…' });
// Działa w dowolnym frameworku (Vue, Svelte, vanilla JS, Node, Edge)
const QUERY = `query Cart($id: ID!) {
cart(id: $id) {
...Cart
}
}
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
}`;
const res = await fetch(`${apiUrl}/storefront/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: QUERY, variables: { id: '…' }, }),
});
const { data } = await res.json();
Pasek FeeSummaryBar z kroku 2 czyta cost.feeAllocations (wiersze z etykietami) i cost.feeTotal (suma). Kupujący widzi wtedy dokładnie to, co zobaczy w zamówieniu i na fakturze.
Jeśli do podglądu metod płatności używasz zapytania poziomu sklepu (availablePaymentMethods — np. siatka form płatności na stronie produktu) — każda metoda i jej opcje mają tam fee: null w całości, bo kwota nie istnieje bez koszyka. To nie jest usterka.
Na kafelkach poza koszykiem nie renderuj wtedy żadnej dopłaty — nie odtwarzaj jej z ratePercent ani z własnej stałej: stawka może być procentowa, a kwota bez koszyka jest po prostu nieznana.
Typy
Surcharge preview for choosing this payment option — render it on the picker tile (e.g. "+5 zł") BEFORE the buyer selects anything. Populated only on `Cart.availablePaymentMethods` (a percentage fee is computed from the value of this cart); always null on the shop-level `Query.availablePaymentMethods`. The amount and label match the `CartCost.feeAllocations` row that appears after selection, so the tile and the summary can never disagree.
| Pole | Typ | Opis |
|---|---|---|
amount | Money! | Fee amount the buyer will pay for choosing this option, in the cart currency. Tax-inclusive in a shop with tax-inclusive prices; tax is added on top in a tax-exclusive shop — the same convention as shipping. |
label | String! | Localized label for the fee row — identical to the `CartCost.feeAllocations[].label` shown after selection. |
ratePercent | Float | Percentage rate behind the fee (e.g. 2.5 = 2.5% of the cart value), so the tile can read "+2.5% (2.71 zł)". Null for a flat fee. Informational: the binding number is `amount`, already computed from this cart — never recompute it client-side. |
A payment method offered to the buyer at checkout — what to render in the payment picker and pass to `cartSelectPaymentMethod`.
| Pole | Typ | Opis |
|---|---|---|
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. |
available | Boolean! | 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. |
description | String | Optional buyer-facing description shown under the name (e.g. "Pay with your bank app"). |
fee | PaymentMethodFee | Surcharge 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. |
icon | Image | Icon 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. |
id | ID! | 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. |
isDefault | Boolean! | True when the merchant has marked this method as the default. Pre-select it in the picker. |
name | String! | Display name configured by the merchant (e.g. "BLIK", "Credit card", "Cash on delivery"). |
position | Float! | Merchant-configured display position — lower values come first in the picker. |
preferredProvider | PaymentProvider | Preferred 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. |
provider | PaymentProvider! | 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. |
type | PaymentMethodType! | Category of the method (CARD, BLIK, BANK_TRANSFER, INSTALLMENT, WALLET, CASH_ON_DELIVERY, OTHER). Drives iconography and copy. |
unavailableReason | PaymentMethodUnavailableReason | When `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"). |
A single concrete instrument exposed by a gateway provider (e.g. BLIK code, mBank Pay-By-Link, Apple Pay) within a broader PaymentMethod. Pass `code` as `preferredInstrument` in `cartSelectPaymentMethod` to deep-link the gateway straight to this screen.
| Pole | Typ | Opis |
|---|---|---|
brandImage | Image | Optional brand image (bank logo, wallet icon). Use as tile artwork via `brandImage { url(transform: { maxWidth: 64 }) altText }`. Null when the gateway does not expose one or the instrument has no brand visual (BLIK code). |
code | String! | Gateway-specific instrument identifier (PayU: `"blik"`/`"mb"`/`"c"`, P24: numeric ID string `"154"`). Pass as `preferredInstrument` in `cartSelectPaymentMethod`, ALWAYS together with `preferredProvider` — codes are scoped to their gateway and are NOT unique across providers within one method (dedupe and select by the (provider, code) pair). Stable per provider — the gateway does not renumber. |
displayHint | PaymentInstrumentDisplayHint! | UX rendering hint — how the storefront should render this instrument (prominent button vs branded tile vs dropdown vs radio). Backend-agnostic mapping to visual treatment. |
displayName | String! | Buyer-facing display name (e.g. "BLIK", "mBank", "ING Bank Śląski", "Apple Pay"). |
enabled | Boolean! | True when the instrument is currently enabled in the gateway live capabilities. The storefront can gray-out the tile when false instead of hiding it (observability for the merchant). |
fee | PaymentMethodFee | Surcharge for picking this instrument, ready to render on its tile. Null when no fee applies, when the fee cannot be determined, or on the shop-level query (`Query.availablePaymentMethods`) — only `Cart.availablePaymentMethods` carries amounts. |
provider | PaymentProvider! | Provider that handles this instrument (UPPERCASE enum). Required for cross-provider dedupe (e.g. a BLIK code offered by both PayU and P24 — distinct instruments despite the same method type). |
type | PaymentInstrumentType! | Semantic type classifying the instrument within the method (BLIK code vs bank vs wallet vs card brand). Storefront-facing dispatch for per-instrument UI components. |
A single fee charged on the cart, with the reason it applies. Sum these for the aggregate — or read `CartCost.feeTotal`.
| Pole | Typ | Opis |
|---|---|---|
amount | Money! | Amount of this fee, in the cart currency. |
label | String! | Buyer-facing name of the charge, already translated to the shop language — render it as the summary row label. Never build your own copy for this row: what the fee is called is the merchant's decision and it also has to match the invoice. |
Powiązane
- Instrumenty płatności — wybór konkretnego instrumentu (kod BLIK, oznaczony bank, portfel) i przekazywanie go API.
- Kasa — pełny przebieg kroku dostawy i płatności.
- Koszyk z kodami rabatowymi — podsumowanie kosztów koszyka, do którego pasuje pasek dopłat.
- Przelew tradycyjny — kolejny scenariusz kasy: metoda płatności z samym przelewem, zwykle bez dopłaty.
- Wybór punktu odbioru w kasie — krok dostawy, który zwykle poprzedza płatność.