Przedsprzedaż, „na zamówienie" i czas realizacji
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
Blok dostępności na karcie produktu, który mówi klientowi czy i kiedy: „Dostępny od ręki · wysyłka w 1-2 dni robocze", „Przedsprzedaż · premiera: III-IV kwartał 2027", „Sprowadzamy na zamówienie · wysyłka w 60-90 dni roboczych (orientacyjnie do 12.01.2027)". Do tego fragment JSON-LD z Offer.availability (PreOrder / BackOrder / InStock) i deliveryTime, którego Google wymaga w Zakupach i wynikach z produktami.
Wszystko z jednego pola:
const { availability } = data.product.variants.nodes[0]; // VariantAvailability
availability.status; // 'IN_STOCK' | 'PREORDER' | 'BACKORDER' | 'OUT_OF_STOCK' | 'UNTRACKED'
availability.release?.label; // „III-IV kwartał 2027" - etykieta w języku żądania
availability.expectedAvailability; // { from, to, precision } - kiedy towar ma być
availability.handlingTime; // { minDays, maxDays } - dni robocze do nadania paczki
availability.purchasableQuantity; // ile sztuk da się jeszcze kupić; null = bez limitu
Wymagania
- Skonfigurowany SDK i provider — Konfiguracja Next.js.
- Strona produktu z wyborem wariantu — Strona produktu z wariantami.
Krok 1 — Skąd biorą się status i terminy
Merchant ustawia w panelu fakty: termin premiery (dokładna data, miesiąc albo kwartał), czas realizacji na stanie i czas realizacji bez towaru. Status i daty wyprowadza platforma — tą samą regułą, którą widzi merchant w podglądzie „Klient zobaczy" i którą dostaje feed Google Merchant. Sklep nie liczy niczego sam, więc trzy powierzchnie nigdy nie odpowiedzą różnie o ten sam produkt.
status | Co znaczy | release | expectedAvailability | handlingTime |
|---|---|---|---|---|
IN_STOCK / UNTRACKED | do kupienia od ręki | null | null | czas na stanie |
PREORDER | premiera jeszcze trwa, merchant przyjmuje zamówienia | okno premiery | = okno premiery | czas na stanie (pakowanie po premierze) |
BACKORDER | brak towaru, merchant sprowadza na zamówienie | null | dziś + czas bez towaru (dni robocze) albo null, gdy merchant nie podał czasu | czas bez towaru |
OUT_OF_STOCK | nie da się kupić | null | null | null |
isAvailable na wariancie znaczy teraz status !== OUT_OF_STOCK — przedsprzedaż i „na zamówienie" są kupowalne. Jeśli sklep pokazuje „Niedostępny" po !isAvailable, nic się nie zmienia; ten przepis dokłada dlaczego i kiedy.
Limit sztuk w przedsprzedaży i „na zamówienie"
Merchant może sprzedawać bez towaru do konkretnej liczby sztuk — tyle, ile zaklepał mu dostawca (np. 6 sztuk premiery). availability.purchasableQuantity mówi, ile sztuk klient może jeszcze kupić: przy towarze na półce to dostępna ilość, w przedsprzedaży i „na zamówienie" z limitem — pozostałe sztuki z przydziału. null znaczy „bez limitu" (stan nieśledzony albo sprzedaż bez towaru bez limitu). Użyj tej liczby jako górnej granicy wyboru ilości na karcie produktu, zamiast availableStock: availableStock liczy wyłącznie towar na półce, więc w przedsprzedaży wynosi 0, choć produkt da się kupić.
Koszyk pilnuje tego samego limitu po stronie serwera — próba dodania większej ilości kończy się błędem z liczbą sztuk możliwych do kupienia, więc ogranicznik w UI to wygoda, nie zabezpieczenie.
release.precision === 'DATE' oznacza embargo: paczka nie wyjdzie przed tym dniem, nawet gdy towar leży w magazynie. Miesiąc i kwartał to termin orientacyjny — gdy towar dotrze wcześniej, status sam przechodzi w IN_STOCK. Pokaż klientowi tę różnicę w kopii („po premierze" vs „po dotarciu towaru").
Krok 2 — Etykieta okna przychodzi gotowa
release.label i expectedAvailability.label to etykieta okna w języku żądania (Accept-Language): „III-IV kwartał 2027", „wrzesień 2027", „15.11.2026". Nie formatuj kwartałów sam — kolejny sklep zrobi to inaczej, a Google i panel mówią jednym głosem. Datę dla Google bierz z expectedAvailability.to (koniec okna; Google przyjmuje jedną datę, nie zakres).
Krok 3 — Komponent i JSON-LD (React)
Kod jest weryfikowany typami przeciw @doswiftly/storefront-sdk — literówka w nazwie pola albo zły status nie przejdą kompilacji:
import type { DeliveryEstimate, ProductVariant, VariantAvailability } from '@doswiftly/storefront-sdk';
// Dostępność wariantu na karcie produktu: plakietka i zdanie o terminie liczone z
// `variant.availability` (status, okno premiery, szacowana dostępność, czas realizacji)
// oraz dane strukturalne dla Google (`Offer.availability` + `deliveryTime`).
//
// Sklep NIE liczy statusu sam - dostaje go gotowy z API tą samą regułą, którą panel
// pokazuje merchantowi i którą feed Google Merchant raportuje. Etykieta okna (`label`)
// też przychodzi z API w języku żądania, więc kwartałów nie formatujesz ręcznie.
const SCHEMA_AVAILABILITY = {
UNTRACKED: 'https://schema.org/InStock',
IN_STOCK: 'https://schema.org/InStock',
PREORDER: 'https://schema.org/PreOrder',
BACKORDER: 'https://schema.org/BackOrder',
OUT_OF_STOCK: 'https://schema.org/OutOfStock',
} as const;
function days(range: { minDays: number; maxDays: number }): string {
return `${range.minDays}-${range.maxDays} dni roboczych`;
}
/** Przewóz kurierem ma sens tylko z obiema liczbami - metoda bez szacunku nie dokłada nic. */
function transitDays(delivery?: DeliveryEstimate | null): { minDays: number; maxDays: number } | null {
return delivery?.minDays != null && delivery.maxDays != null
? { minDays: delivery.minDays, maxDays: delivery.maxDays }
: null;
}
/** Zdanie pod ceną - jedna obietnica, którą powtarzasz w koszyku, kasie i na koncie klienta. */
export function availabilityCopy(availability: VariantAvailability, delivery?: DeliveryEstimate | null): string {
const courier = transitDays(delivery);
const transit = courier ? ` · dostawa ${courier.minDays}-${courier.maxDays} dni` : '';
const handling = availability.handlingTime ? days(availability.handlingTime) : null;
switch (availability.status) {
case 'IN_STOCK':
case 'UNTRACKED':
return `Dostępny od ręki${handling ? ` · wysyłka w ${handling}` : ''}${transit}`;
case 'PREORDER': {
// `release.label` - gotowa etykieta okna („III-IV kwartał 2027", „15.11.2026").
const when = availability.release?.label ?? '';
const after = availability.release?.precision === 'DATE' ? 'po premierze' : 'po dotarciu towaru';
return `Przedsprzedaż · premiera: ${when}${handling ? ` · wysyłka w ${handling} ${after}` : ''}${transit}`;
}
case 'BACKORDER':
// Bez zadeklarowanego czasu nie ma daty - nie wymyślaj terminu, którego merchant nie obiecał.
return availability.expectedAvailability && handling
? `Sprowadzamy na zamówienie · wysyłka w ${handling} (orientacyjnie do ${availability.expectedAvailability.to})${transit}`
: 'Sprowadzamy na zamówienie';
case 'OUT_OF_STOCK':
return 'Chwilowo niedostępny';
}
}
/** Fragment `Offer` dla JSON-LD produktu: status i czas dostawy, których Google wymaga w Zakupach. */
export function offerAvailabilityJsonLd(availability: VariantAvailability, delivery?: DeliveryEstimate | null) {
const courier = transitDays(delivery);
return {
availability: SCHEMA_AVAILABILITY[availability.status],
// Google przyjmuje JEDNĄ datę: koniec okna (dla zakresu kwartałów - koniec ostatniego).
...(availability.expectedAvailability ? { availabilityStarts: availability.expectedAvailability.to } : {}),
...(availability.handlingTime
? {
shippingDetails: {
'@type': 'OfferShippingDetails',
deliveryTime: {
'@type': 'ShippingDeliveryTime',
handlingTime: {
'@type': 'QuantitativeValue',
minValue: availability.handlingTime.minDays,
maxValue: availability.handlingTime.maxDays,
unitCode: 'DAY',
},
...(courier
? {
transitTime: {
'@type': 'QuantitativeValue',
minValue: courier.minDays,
maxValue: courier.maxDays,
unitCode: 'DAY',
},
}
: {}),
},
},
}
: {}),
};
}
export function AvailabilityNotice({
variant,
delivery,
}: {
variant: ProductVariant;
delivery?: DeliveryEstimate | null;
}) {
// `availability` jest null wyłącznie na lekkim wariancie z linii koszyka - na karcie produktu
// zawsze jest, ale typ tego nie wie, więc zostaje zachowanie sprzed pola.
if (!variant.availability) return <p>{variant.isAvailable ? 'Dostępny' : 'Niedostępny'}</p>;
const { status } = variant.availability;
const badge = status === 'PREORDER' ? 'Przedsprzedaż' : status === 'BACKORDER' ? 'Na zamówienie' : null;
return (
<div>
{badge ? <span>{badge}</span> : null}
<p>{availabilityCopy(variant.availability, delivery)}</p>
{status === 'PREORDER' ? <p>Płatność przy zamówieniu. Możesz anulować do wysyłki.</p> : null}
<button disabled={!variant.isAvailable}>{status === 'PREORDER' ? 'Zamów w przedsprzedaży' : 'Do koszyka'}</button>
</div>
);
}
offerAvailabilityJsonLd dokładasz do Offer w JSON-LD produktu obok ceny; transitTime bierze się z Shop.deliveryEstimate(countryCode) — min/max dni przewozu z metod wysyłki merchanta dla kraju sklepu (pobierasz raz przy ładowaniu sklepu, nie per produkt).
Krok 4 — Ta sama obietnica w koszyku, kasie i na koncie
- Koszyk i kasa: pod pozycją to samo zdanie co na karcie (linia koszyka niesie lekki wariant bez
availability— trzymaj status z karty w stanie koszyka albo dociągnijProduct). Przy mieszanym koszyku jedno zdanie nad metodami dostawy: „Część pozycji wyślemy później — zobacz terminy przy produktach". - Wykluczenia przewoźników (np. usługa tylko dla towaru od ręki) rób po
availability.status, nie po kategorii. - Podsumowanie zamówienia i konto klienta: po złożeniu zamówienia obowiązuje termin zapisany na pozycji (
OrderItem.expectedShipWindowzavailabilitySnapshot) — merchant może później przesunąć premierę produktu, ale obietnica wobec klienta zostaje. Mail potwierdzenia od platformy już go pokazuje.
Krok 5 — Listing „Przedsprzedaż"
Bez kategorii-proxy: products(filters: [{ availability: [PREORDER] }]). Ten sam filtr przyjmuje kilka stanów naraz ([IN_STOCK, PREORDER] = wszystko, co da się dziś kupić).
Typy
Renderowane ze schematu GraphQL — nigdy nie rozjeżdżają się z API:
VariantAvailability
Availability of a variant resolved by the platform: status, release window (pre-order), expected availability and handling time. Build product-page copy, cart notes and structured data (`Offer.availability`, `deliveryTime.handlingTime`) from these fields.
| Pole | Typ | Opis |
|---|---|---|
expectedAvailability | AvailabilityWindow | When the item is expected to become available: the release window for PREORDER, an estimated date range for BACKORDER (today plus the merchant handling time without stock). Null for items available now or not purchasable. Use `to` as the single estimated date for Google (`availability_date`). |
handlingTime | BusinessDayRange | Handling time in business days from order to carrier hand-off for the current status (in stock or after release: the regular handling time; backorder: the handling time without stock). Null when the item cannot be purchased or the merchant declared no time. Combine with the shipping method transit time for `deliveryTime` structured data. |
purchasableQuantity | Int | How many units a customer can still buy: units in stock plus units the merchant can still sell before stock arrives (pre-order or on-order limit). Null when there is no limit (untracked item or unlimited sale beyond stock). Use it as the maximum of the quantity selector. |
release | AvailabilityWindow | Release window set by the merchant (pre-order). Present while the release is pending; null once released or when the product was never a pre-order. DATE precision = hard release day: the parcel cannot leave before it. |
status | StockAvailability! | Resolved availability state (see enum values). |
AvailabilityWindow
A calendar date window with the precision the merchant knows it in. Render `label` as-is (localised for the request language) instead of formatting the dates yourself.
| Pole | Typ | Opis |
|---|---|---|
from | String! | First day of the window, calendar date YYYY-MM-DD. |
label | String! | Human-readable label, already localised for the request language (English: e.g. "Q3 2027", "September-October 2027", "11/15/2026"). Render as-is. |
precision | ReleasePrecision! | Precision of the window: exact day, month or quarter. |
to | String! | Last day of the window, calendar date YYYY-MM-DD. For DATE precision equal to `from`. |
DeliveryEstimate
Estimated delivery window for a shipping method, in business days.
| Pole | Typ | Opis |
|---|---|---|
description | String | Pre-formatted human-readable estimate (e.g. "1-2 business days"). Localised; render as-is. |
maxDays | Int | Maximum expected delivery time in business days. |
minDays | Int | Minimum expected delivery time in business days. |
Powiązane
- Strona produktu, wybór wariantu, koszyk — Strona produktu z wariantami.
- Hook
useProduct, lista i filtry — Produkty. - Kontrakt operacji
ProductiShop— Referencja GraphQL API.