Strona główna

Dokumentacja integracji

Jak pokazać Kursy, Pakiety i Wydarzenia z Seatbee na własnej stronie

Publiczne dane Seatbee dla strony Organizatora: ceny, raty, dostęp, Terminy i wolne miejsca w Twoim wyglądzie. Pełny kontrakt pól, gotowy kod dla sześciu scenariuszy, zasady widoczności, schema.org i odświeżanie.

Wersja Markdown dla AI

Ta instrukcja jest dla Organizatora, osoby, która robi jego stronę, i dla asystenta AI (ChatGPT, Claude, Cursor). Jest napisana tak, żeby po jej przeczytaniu dało się zrobić integrację bez dopytywania. Wersja dla AI, z tą samą treścią w Markdown: https://seatbee.io/docs/integracja-strony.md.

Po co to jest

Twoja strona może sama pokazywać ceny, raty, czas dostępu i przyciski zakupu Twoich Kursów i Pakietów oraz Terminy Wydarzeń z wolnymi miejscami. Dane przychodzą z Seatbee, więc gdy zmienisz cenę albo dodasz Termin w Seatbee, strona zmienia się sama, bez przepisywania.

Zostaje u Organizatora (na jego stronie)Przychodzi z Seatbee (z danych)
wygląd, układ, kolory, czcionkiceny, cena przekreślona, raty
zdjęcia i własne opisyczas dostępu, liczba lekcji i czas filmów
kolejność sekcji i podstronTerminy, miasta, miejsca, wolne miejsca
które Kursy i Wydarzenia pokazać w danym miejsculinki zakupu i zapisu, stan sprzedaży, oznaczenie Seatbee

Płatność i zapis zawsze odbywają się w Seatbee: przycisk na Twojej stronie prowadzi do adresu url z danych. Dane są publiczne: bez klucza, bez logowania i bez żadnych danych Kursantek ani uczestników.

Adresy danych

Są dwa adresy, oba GET, odpowiedź JSON. W miejsce twoj-slug wpisz swój Adres publiczny (slug) z panelu Seatbee: Marka → Organizator. Pełny adres z Twoim slugiem jest też w panelu, w oknach „Cennik na Twoją stronę” (Kursy) i „Wydarzenia na Twoją stronę” (Wydarzenia).

GET https://seatbee.io/api/publiczne/twoj-slug/kursy
GET https://seatbee.io/api/publiczne/twoj-slug/wydarzenia

Kursy i Pakiety: /kursy

Opublikowane Kursy i Pakiety kursów z ceną obowiązującą teraz, ratami, czasem dostępu i adresem zakupu. Szkice, usunięte Kursy i Pakiety z archiwum nie przychodzą.

PoleTypZnaczenie
organizatorobjectOrganizator, którego są dane.
organizator.nazwastring | nullNazwa Organizatora; null, gdy jej nie wpisał.
organizator.urlstringStrona Organizatora w Seatbee (pod własną domeną, gdy jest aktywna). Link zapasowy, gdy nic innego nie da się pokazać.
seatbeeobjectOznaczenie Seatbee do pokazania przy cenniku (sekcja „Oznaczenie Seatbee”).
seatbee.pokazbooleantrue: pokaż oznaczenie. false: nie pokazuj (plan Organizatora wyłącza branding). Decyduje Seatbee, nie strona.
seatbee.tekststringNapis oznaczenia, np. „Zapisy i płatności: Seatbee”.
seatbee.urlstringLink oznaczenia (strona Seatbee z ?ref=<slug Organizatora>).
seatbee.logoUrlstringLogo Seatbee (SVG), gdy zamiast napisu chcesz pokazać logo; alt = seatbee.tekst.
logowanieUrlstringJeden link „Zaloguj” dla wszystkich Kursantek: strona, która wysyła link do logowania.
kursyarrayOpublikowane Kursy, w kolejności dodania. Szkiców i usuniętych nie ma.
kursy[].slugstringStały adres Kursu. Po nim łączysz własną kartę lub podstronę z danymi (scenariusz B).
kursy[].tytulstringTytuł z Seatbee.
kursy[].opisstringOpis jako zwykły tekst, bez formatowania (akapity rozdzielone pustą linią). Możesz go nie używać i pisać własne opisy.
kursy[].urlstringStrona Kursu w Seatbee, na której się go kupuje. Cel przycisku „Kup”.
kursy[].okladkaUrlstring | nullOkładka z Seatbee (adres pliku, może być na innym serwerze) albo null.
kursy[].cenanumber | nullCena brutto w zł obowiązująca teraz (także zaplanowana zmiana ceny). 0 = bezpłatny, null = jeszcze bez ceny.
kursy[].cenaPoprzednianumber | nullNajniższa wcześniejsza cena do pokazania jako przekreślona (Omnibus). Pokazuj tylko, gdy nie jest null. Pakiet ma zawsze null.
kursy[].waluta"PLN"Waluta ceny.
kursy[].dostepobject | nullJak długo trwa dostęp po zakupie. null tylko u Pakietu, którego Kursy mają różne okresy.
kursy[].dostep.rodzaj"na-zawsze" | "okres"na-zawsze: dostęp bez końca (brak innych pól). okres: dostęp na czas podany w dni.
kursy[].dostep.dninumberTylko przy okres: liczba dni dostępu, zawsze obecna.
kursy[].dostep.miesiacenumberTylko gdy Organizator ustawił okres w miesiącach. Wtedy to jest dokładna wartość (miesiące kalendarzowe), a dni to przybliżenie liczone od dziś.
kursy[].ratyobject | nullRaty, tylko gdy strona Kursu je teraz oferuje; inaczej null. Pakiet nie ma rat.
kursy[].raty.liczbanumberLiczba rat.
kursy[].raty.kwotynumber[]Kwoty kolejnych rat brutto w zł; sumują się do cena.
kursy[].raty.urlstringPodstrona „Kup na raty” w Seatbee.
kursy[].lekcjenumberLiczba Lekcji (Pakiet: suma jego Kursów).
kursy[].czasMinutynumberŁączny czas filmów w minutach (Pakiet: suma).
kursy[].wSprzedazybooleanfalse, gdy teraz nie da się kupić (sprzedaż wstrzymana, „Sprzedaż wkrótce”, brak otwartego Startu). Wtedy wyłącz przycisk, nie ukrywaj karty.
kursy[].listaOczekujacychobject | nullLista oczekujących Kursu (np. ukryty albo zaplanowany Start, Stripe jeszcze nie przyjmuje płatności): obiekt tylko wtedy, gdy strona w Seatbee pokazuje teraz formularz „Zostaw e-mail, damy znać o starcie sprzedaży” (sprzedaż jest „wkrótce”, a Organizator zbiera chętnych). Inaczej null. Sekcja „Lista oczekujących na własnej stronie”.
kursy[].listaOczekujacych.wlaczonabooleanZawsze true, gdy obiekt jest.
kursy[].listaOczekujacych.polaobjectO co pyta formularz.
kursy[].listaOczekujacych.pola.email"wymagane"E-mail jest zawsze wymagany.
kursy[].listaOczekujacych.pola.imie"wylaczone" | "opcjonalne" | "wymagane"Imię: nie pytamy, opcjonalne albo wymagane (ustawienie Organizatora).
kursy[].listaOczekujacych.pola.telefon"wylaczone" | "opcjonalne" | "wymagane"Telefon: jak imie.
kursy[].listaOczekujacych.zgodyobjectZgody w formularzu, do opisu na Twojej stronie (sam formularz jest w Seatbee).
kursy[].listaOczekujacych.zgody.dokumentyarrayObowiązkowa akceptacja opublikowanych Dokumentów prawnych Organizatora.
kursy[].listaOczekujacych.zgody.dokumenty[].rodzaj"regulamin" | "polityka-prywatnosci"Który dokument.
kursy[].listaOczekujacych.zgody.dokumenty[].urlstringAdres dokumentu.
kursy[].listaOczekujacych.zgody.powiadomienie"wymagana"Obowiązkowa zgoda na jeden e-mail „Sprzedaż otwarta” o tej pozycji.
kursy[].listaOczekujacych.zgody.marketingowa"dobrowolna"Dobrowolna zgoda marketingowa Organizatora.
kursy[].listaOczekujacych.zgody.wlasnearrayZgody własne Organizatora dla tej pozycji (może być pusta).
kursy[].listaOczekujacych.zgody.wlasne[].tekststringTreść zgody.
kursy[].listaOczekujacych.zgody.wlasne[].urlstring | nullDokument zgody albo null.
kursy[].listaOczekujacych.zgody.wlasne[].wymaganabooleantrue: bez niej nie da się zapisać.
kursy[].listaOczekujacych.urlstringFormularz na stronie w Seatbee (z kotwicą #lista-oczekujacych). Cel przycisku „Zostaw e-mail”.
kursy[].listaOczekujacych.ramkaUrlstringFormularz w ramce do <iframe> na Twojej stronie. Ładuje się tylko na stronach wpisanych w Marce w polu „Twoja strona www”.
pakietyarrayOpublikowane Pakiety kursów. Każdy ma wszystkie pola kursy[] (adres url prowadzi do strony Pakietu; listaOczekujacych zawsze null: Pakiet nie ma listy oczekujących) i dodatkowo kursy oraz stan.
pakiety[].kursystring[]Slugi opublikowanych Kursów w Pakiecie, w jego kolejności.
pakiety[].stan"w-sprzedazy" | "niedostepny"Stan sprzedaży: w-sprzedazy albo niedostepny (archiwum, Kurs z Pakietu wycofany, Stripe nie przyjmuje płatności). Przy niedostepny napisz „Chwilowo niedostępny”, nie „Sprzedaż wkrótce”.

Przykładowa odpowiedź:

{
  "organizator": {
    "nazwa": "Studio Ruchu",
    "url": "https://seatbee.io/twoj-slug"
  },
  "seatbee": {
    "pokaz": true,
    "tekst": "Zapisy i płatności: Seatbee",
    "url": "https://seatbee.io/?ref=twoj-slug",
    "logoUrl": "https://seatbee.io/icon.svg"
  },
  "logowanieUrl": "https://seatbee.io/twoj-slug/kursant/link",
  "kursy": [
    {
      "slug": "kurs-online",
      "tytul": "Kurs online",
      "opis": "Dwanaście lekcji wideo do ćwiczenia w domu.",
      "url": "https://seatbee.io/twoj-slug/kurs/kurs-online",
      "okladkaUrl": "https://pliki.example.com/okladki/kurs-online.jpg",
      "cena": 300,
      "cenaPoprzednia": 349,
      "waluta": "PLN",
      "dostep": {
        "rodzaj": "okres",
        "dni": 181,
        "miesiace": 6
      },
      "raty": {
        "liczba": 3,
        "kwoty": [
          120,
          90,
          90
        ],
        "url": "https://seatbee.io/twoj-slug/kurs/kurs-online/raty"
      },
      "lekcje": 12,
      "czasMinuty": 185,
      "wSprzedazy": true,
      "listaOczekujacych": null
    },
    {
      "slug": "mini-kurs",
      "tytul": "Mini kurs",
      "opis": "Trzy krótkie lekcje na początek.",
      "url": "https://seatbee.io/twoj-slug/kurs/mini-kurs",
      "okladkaUrl": null,
      "cena": 0,
      "cenaPoprzednia": null,
      "waluta": "PLN",
      "dostep": {
        "rodzaj": "okres",
        "dni": 30
      },
      "raty": null,
      "lekcje": 3,
      "czasMinuty": 40,
      "wSprzedazy": true,
      "listaOczekujacych": null
    },
    {
      "slug": "kurs-trenerski",
      "tytul": "Kurs trenerski",
      "opis": "Nabór na luty.",
      "url": "https://seatbee.io/twoj-slug/kurs/kurs-trenerski",
      "okladkaUrl": null,
      "cena": 1200,
      "cenaPoprzednia": null,
      "waluta": "PLN",
      "dostep": {
        "rodzaj": "na-zawsze"
      },
      "raty": null,
      "lekcje": 0,
      "czasMinuty": 0,
      "wSprzedazy": false,
      "listaOczekujacych": {
        "wlaczona": true,
        "pola": {
          "email": "wymagane",
          "imie": "opcjonalne",
          "telefon": "wylaczone"
        },
        "zgody": {
          "dokumenty": [
            {
              "rodzaj": "regulamin",
              "url": "https://seatbee.io/twoj-slug/regulamin"
            },
            {
              "rodzaj": "polityka-prywatnosci",
              "url": "https://seatbee.io/twoj-slug/polityka-prywatnosci"
            }
          ],
          "powiadomienie": "wymagana",
          "marketingowa": "dobrowolna",
          "wlasne": [
            {
              "tekst": "Zgadzam się na zdjęcia z zajęć.",
              "url": null,
              "wymagana": false
            }
          ]
        },
        "url": "https://seatbee.io/twoj-slug/kurs/kurs-trenerski#lista-oczekujacych",
        "ramkaUrl": "https://seatbee.io/osadzenie/twoj-slug/kurs/kurs-trenerski/formularz"
      }
    }
  ],
  "pakiety": [
    {
      "slug": "pakiet-pelny",
      "tytul": "Pakiet pełny",
      "opis": "Oba kursy w jednym zakupie.",
      "url": "https://seatbee.io/twoj-slug/pakiet/pakiet-pelny",
      "okladkaUrl": null,
      "cena": 279,
      "cenaPoprzednia": null,
      "waluta": "PLN",
      "dostep": null,
      "raty": null,
      "lekcje": 15,
      "czasMinuty": 225,
      "wSprzedazy": false,
      "listaOczekujacych": null,
      "kursy": [
        "kurs-online",
        "mini-kurs"
      ],
      "stan": "niedostepny"
    }
  ]
}

Wydarzenia, Terminy i Pakiety wydarzeń: /wydarzenia

Opublikowane Wydarzenia z Terminami, które jeszcze się nie zaczęły. Wydarzenie bez przyszłego Terminu nie przychodzi wcale (jego listę oczekujących pokażesz linkiem albo ramką z panelu).

W pakiety przychodzą opublikowane Pakiety wydarzeń: Zestaw (konkretne Terminy kupowane razem jedną płatnością) i Karnet (liczba wejść do wyboru w okresie ważności). Skład Zestawu to lista kod Terminów: znajdziesz je w wydarzenia[].terminy[].kod, więc przy Pakiecie pokażesz daty i miasta bez osobnego zapytania. Skład Karnetu to slugi Wydarzeń z wydarzenia. Kupuje się zawsze na stronie Pakietu (url).

PoleTypZnaczenie
organizatorobjectOrganizator, którego są dane.
organizator.nazwastring | nullNazwa Organizatora; null, gdy jej nie wpisał.
organizator.urlstringStrona Organizatora w Seatbee (pod własną domeną, gdy jest aktywna). Link zapasowy.
seatbeeobjectOznaczenie Seatbee do pokazania przy liście (sekcja „Oznaczenie Seatbee”).
seatbee.pokazbooleantrue: pokaż oznaczenie. false: nie pokazuj.
seatbee.tekststringNapis oznaczenia.
seatbee.urlstringLink oznaczenia.
seatbee.logoUrlstringLogo Seatbee (SVG).
wydarzeniaarrayOpublikowane, otwarte Wydarzenia, które mają co najmniej jeden Termin w przyszłości, ułożone po pierwszym Terminie. Szkiców, Wydarzeń zamkniętych (tylko dla Grup, z Tajnym tunelem) i ukrytych za Bramką maili nie ma.
wydarzenia[].slugstringStały adres Wydarzenia. Po nim pokazujesz jedno Wydarzenie w wybranym miejscu (scenariusz E).
wydarzenia[].tytulstringTytuł.
wydarzenia[].opisstringOpis jako zwykły tekst.
wydarzenia[].urlstringStrona Wydarzenia w Seatbee ze wszystkimi Terminami.
wydarzenia[].ramkaUrlstringRamka Osadzenia tego Wydarzenia (do <iframe>), gdy wolisz gotowy widok Seatbee.
wydarzenia[].okladkaUrlstring | nullGrafika Wydarzenia albo null.
wydarzenia[].listaOczekujacychobject | nullLista oczekujących całego Wydarzenia, gdy wszystkie jego przyszłe Terminy mają „Zapisy wkrótce”: obiekt tylko wtedy, gdy strona w Seatbee pokazuje teraz formularz „Zostaw e-mail, damy znać o starcie sprzedaży” (sprzedaż jest „wkrótce”, a Organizator zbiera chętnych). Inaczej null. Sekcja „Lista oczekujących na własnej stronie”.
wydarzenia[].listaOczekujacych.wlaczonabooleanZawsze true, gdy obiekt jest.
wydarzenia[].listaOczekujacych.polaobjectO co pyta formularz.
wydarzenia[].listaOczekujacych.pola.email"wymagane"E-mail jest zawsze wymagany.
wydarzenia[].listaOczekujacych.pola.imie"wylaczone" | "opcjonalne" | "wymagane"Imię: nie pytamy, opcjonalne albo wymagane (ustawienie Organizatora).
wydarzenia[].listaOczekujacych.pola.telefon"wylaczone" | "opcjonalne" | "wymagane"Telefon: jak imie.
wydarzenia[].listaOczekujacych.zgodyobjectZgody w formularzu, do opisu na Twojej stronie (sam formularz jest w Seatbee).
wydarzenia[].listaOczekujacych.zgody.dokumentyarrayObowiązkowa akceptacja opublikowanych Dokumentów prawnych Organizatora.
wydarzenia[].listaOczekujacych.zgody.dokumenty[].rodzaj"regulamin" | "polityka-prywatnosci"Który dokument.
wydarzenia[].listaOczekujacych.zgody.dokumenty[].urlstringAdres dokumentu.
wydarzenia[].listaOczekujacych.zgody.powiadomienie"wymagana"Obowiązkowa zgoda na jeden e-mail „Sprzedaż otwarta” o tej pozycji.
wydarzenia[].listaOczekujacych.zgody.marketingowa"dobrowolna"Dobrowolna zgoda marketingowa Organizatora.
wydarzenia[].listaOczekujacych.zgody.wlasnearrayZgody własne Organizatora dla tej pozycji (może być pusta).
wydarzenia[].listaOczekujacych.zgody.wlasne[].tekststringTreść zgody.
wydarzenia[].listaOczekujacych.zgody.wlasne[].urlstring | nullDokument zgody albo null.
wydarzenia[].listaOczekujacych.zgody.wlasne[].wymaganabooleantrue: bez niej nie da się zapisać.
wydarzenia[].listaOczekujacych.urlstringFormularz na stronie w Seatbee (z kotwicą #lista-oczekujacych). Cel przycisku „Zostaw e-mail”.
wydarzenia[].listaOczekujacych.ramkaUrlstringFormularz w ramce do <iframe> na Twojej stronie. Ładuje się tylko na stronach wpisanych w Marce w polu „Twoja strona www”.
wydarzenia[].terminyarrayTerminy, które się jeszcze nie zaczęły, po dniu i godzinie. Minione Terminy znikają same.
wydarzenia[].terminy[].startstringPoczątek, ISO 8601 z przesunięciem Europe/Warsaw, np. 2026-11-20T18:00:00+01:00.
wydarzenia[].terminy[].koniecstring | nullKoniec tego samego dnia albo null.
wydarzenia[].terminy[].format"stacjonarnie" | "online"Format Terminu. Przy online pole miasto ma wartość „Online”, a miejsce to opcjonalny opis (np. „Zoom, link przyjdzie mailem”).
wydarzenia[].terminy[].miastostring | nullMiasto albo null; „Online” przy Terminie online.
wydarzenia[].terminy[].miejscestring | nullMiejsce (sala, adres) albo null.
wydarzenia[].terminy[].cenanumber | nullCena brutto w zł; 0 = bezpłatnie. Gdy Termin ma Typy biletów: najtańszy dostępny (możesz napisać „od …”).
wydarzenia[].terminy[].waluta"PLN"Waluta ceny.
wydarzenia[].terminy[].limitnumber | nullLimit miejsc, tylko gdy Termin pokazuje wolne miejsca; inaczej null.
wydarzenia[].terminy[].wolneMiejscanumber | nullWolne miejsca, tylko gdy Termin je pokazuje; inaczej null.
wydarzenia[].terminy[].wyprzedanybooleantrue: brak miejsc. Pokaż „Brak miejsc”, przycisk wyłączony.
wydarzenia[].terminy[].wSprzedazybooleanfalse przy braku miejsc i gdy zapisy jeszcze nie ruszyły. Gdy false, a wyprzedany jest false: „Zapisy wkrótce”, przycisk wyłączony.
wydarzenia[].terminy[].urlstringGdzie się zapisać: strona Terminu albo od razu płatność w Seatbee. Cel przycisku „Zapisz się”.
wydarzenia[].terminy[].kodstringStały identyfikator Terminu (ten sam co na końcu adresu jego strony). Po nim Zestaw w pakiety[].terminy wskazuje swoje Terminy.
wydarzenia[].terminy[].listaOczekujacychobject | nullLista oczekujących tego Terminu, gdy ma „Zapisy wkrótce” (Stripe Organizatora jeszcze nie przyjmuje płatności): obiekt tylko wtedy, gdy strona w Seatbee pokazuje teraz formularz „Zostaw e-mail, damy znać o starcie sprzedaży” (sprzedaż jest „wkrótce”, a Organizator zbiera chętnych). Inaczej null. Sekcja „Lista oczekujących na własnej stronie”.
wydarzenia[].terminy[].listaOczekujacych.wlaczonabooleanZawsze true, gdy obiekt jest.
wydarzenia[].terminy[].listaOczekujacych.polaobjectO co pyta formularz.
wydarzenia[].terminy[].listaOczekujacych.pola.email"wymagane"E-mail jest zawsze wymagany.
wydarzenia[].terminy[].listaOczekujacych.pola.imie"wylaczone" | "opcjonalne" | "wymagane"Imię: nie pytamy, opcjonalne albo wymagane (ustawienie Organizatora).
wydarzenia[].terminy[].listaOczekujacych.pola.telefon"wylaczone" | "opcjonalne" | "wymagane"Telefon: jak imie.
wydarzenia[].terminy[].listaOczekujacych.zgodyobjectZgody w formularzu, do opisu na Twojej stronie (sam formularz jest w Seatbee).
wydarzenia[].terminy[].listaOczekujacych.zgody.dokumentyarrayObowiązkowa akceptacja opublikowanych Dokumentów prawnych Organizatora.
wydarzenia[].terminy[].listaOczekujacych.zgody.dokumenty[].rodzaj"regulamin" | "polityka-prywatnosci"Który dokument.
wydarzenia[].terminy[].listaOczekujacych.zgody.dokumenty[].urlstringAdres dokumentu.
wydarzenia[].terminy[].listaOczekujacych.zgody.powiadomienie"wymagana"Obowiązkowa zgoda na jeden e-mail „Sprzedaż otwarta” o tej pozycji.
wydarzenia[].terminy[].listaOczekujacych.zgody.marketingowa"dobrowolna"Dobrowolna zgoda marketingowa Organizatora.
wydarzenia[].terminy[].listaOczekujacych.zgody.wlasnearrayZgody własne Organizatora dla tej pozycji (może być pusta).
wydarzenia[].terminy[].listaOczekujacych.zgody.wlasne[].tekststringTreść zgody.
wydarzenia[].terminy[].listaOczekujacych.zgody.wlasne[].urlstring | nullDokument zgody albo null.
wydarzenia[].terminy[].listaOczekujacych.zgody.wlasne[].wymaganabooleantrue: bez niej nie da się zapisać.
wydarzenia[].terminy[].listaOczekujacych.urlstringFormularz na stronie w Seatbee (z kotwicą #lista-oczekujacych). Cel przycisku „Zostaw e-mail”.
wydarzenia[].terminy[].listaOczekujacych.ramkaUrlstringFormularz w ramce do <iframe> na Twojej stronie. Ładuje się tylko na stronach wpisanych w Marce w polu „Twoja strona www”.
pakietyarrayOpublikowane Pakiety wydarzeń: Zestawy i Karnety, od najstarszego. Szkiców i Pakietów z archiwum nie ma. Pakiety kursów są w /kursy.
pakiety[].slugstringStały adres Pakietu.
pakiety[].tytulstringNazwa Pakietu.
pakiety[].rodzaj"zestaw" | "karnet"zestaw: wybrane Terminy kupowane razem (miejsca na wszystkich od razu). karnet: liczba wejść do wyboru spośród Terminów wskazanych Wydarzeń w okresie ważności (miejsce przy wyborze Terminu).
pakiety[].opisstringOpis jako zwykły tekst.
pakiety[].urlstringStrona Pakietu w Seatbee, gdzie się go kupuje. Cel przycisku „Kup”.
pakiety[].okladkaUrlstring | nullOkładka Pakietu albo null.
pakiety[].cenanumberCena brutto w zł obowiązująca teraz (z wariantem cenowym, gdy trwa).
pakiety[].cenaPoprzednianumber | nullNajniższa wcześniejsza cena do pokazania jako przekreślona (Omnibus), tylko gdy trwa wariant cenowy; inaczej null.
pakiety[].waluta"PLN"Waluta ceny.
pakiety[].wSprzedazybooleanfalse, gdy Pakietu nie da się teraz kupić: Zestaw wyprzedany (pełny któryś Termin), sprzedaż zakończona (zaczął się pierwszy Termin albo minęła data końca sprzedaży), chwilowo niedostępny. Karta zostaje, przycisk wyłączony.
pakiety[].stan"w-sprzedazy" | "wyprzedany" | "zakonczona" | "niedostepny"Dlaczego (wSprzedazy bez zmian): wyprzedany (Zestaw z pełnym Terminem, „Brak miejsc”), zakonczona („Sprzedaż zakończona”), niedostepny („Chwilowo niedostępny”). Pakiet nigdy nie jest „wkrótce” i nie ma listy oczekujących.
pakiety[].terminystring[] | nullZestaw: kod jego Terminów w kolejności jego wersji (chronologicznej w chwili publikacji; gdy Termin przesunięto później, sortuj po start, jeśli potrzebujesz kolejności dat); każdy jest w wydarzenia[].terminy[].kod tej odpowiedzi (Termin, który się zaczął, znika z listy). Karnet: null.
pakiety[].wejscianumber | nullKarnet: liczba wejść. Zestaw: null.
pakiety[].waznoscobject | nullKarnet: jak długo jest ważny Karnet kupiony teraz; niewykorzystane wejścia potem przepadają. Zestaw: null.
pakiety[].waznosc.rodzaj"od-zakupu" | "do-dnia"od-zakupu: przez dni (albo miesiace) od zakupu. do-dnia: do końca dnia dzien.
pakiety[].waznosc.dninumberTylko przy od-zakupu: liczba dni od zakupu (przy miesiącach: dni Karnetu kupionego teraz).
pakiety[].waznosc.miesiacenumberTylko przy od-zakupu, gdy Organizator ustawił ważność w miesiącach: dokładna liczba miesięcy.
pakiety[].waznosc.dzienstringTylko przy do-dnia: ostatni dzień ważności, RRRR-MM-DD (czas Europe/Warsaw).
pakiety[].wydarzeniastring[] | nullKarnet: slugi jego Wydarzeń, w jego kolejności; tylko te, które są w wydarzenia tej odpowiedzi. Zestaw: null.

Przykładowa odpowiedź:

{
  "organizator": {
    "nazwa": "Studio Ruchu",
    "url": "https://seatbee.io/twoj-slug"
  },
  "seatbee": {
    "pokaz": true,
    "tekst": "Zapisy i płatności: Seatbee",
    "url": "https://seatbee.io/?ref=twoj-slug",
    "logoUrl": "https://seatbee.io/icon.svg"
  },
  "wydarzenia": [
    {
      "slug": "warsztat-ceramiki",
      "tytul": "Warsztat ceramiki",
      "opis": "Trzy godziny przy kole garncarskim.",
      "url": "https://seatbee.io/twoj-slug/warsztat-ceramiki",
      "ramkaUrl": "https://seatbee.io/osadzenie/twoj-slug/warsztat-ceramiki",
      "okladkaUrl": "https://pliki.example.com/grafiki/warsztat-ceramiki.jpg",
      "terminy": [
        {
          "start": "2026-11-20T18:00:00+01:00",
          "koniec": "2026-11-20T21:00:00+01:00",
          "format": "stacjonarnie",
          "miasto": "Kraków",
          "miejsce": "Pracownia, ul. Długa 5",
          "cena": 180,
          "waluta": "PLN",
          "limit": 12,
          "wolneMiejsca": 4,
          "wyprzedany": false,
          "wSprzedazy": true,
          "url": "https://seatbee.io/twoj-slug/warsztat-ceramiki/k7d2a9f01c",
          "kod": "k7d2a9f01c",
          "listaOczekujacych": null
        },
        {
          "start": "2026-12-05T10:00:00+01:00",
          "koniec": null,
          "format": "online",
          "miasto": "Online",
          "miejsce": "Zoom, link przyjdzie mailem",
          "cena": 180,
          "waluta": "PLN",
          "limit": null,
          "wolneMiejsca": null,
          "wyprzedany": true,
          "wSprzedazy": false,
          "url": "https://seatbee.io/twoj-slug/warsztat-ceramiki/b41e0c77d3",
          "kod": "b41e0c77d3",
          "listaOczekujacych": null
        }
      ],
      "listaOczekujacych": null
    },
    {
      "slug": "ceramika-dla-dzieci",
      "tytul": "Ceramika dla dzieci",
      "opis": "Zajęcia dla dzieci od 7 lat.",
      "url": "https://seatbee.io/twoj-slug/ceramika-dla-dzieci",
      "ramkaUrl": "https://seatbee.io/osadzenie/twoj-slug/ceramika-dla-dzieci",
      "okladkaUrl": null,
      "terminy": [
        {
          "start": "2026-12-12T10:00:00+01:00",
          "koniec": null,
          "format": "stacjonarnie",
          "miasto": "Kraków",
          "miejsce": null,
          "cena": 120,
          "waluta": "PLN",
          "limit": null,
          "wolneMiejsca": null,
          "wyprzedany": false,
          "wSprzedazy": false,
          "url": "https://seatbee.io/twoj-slug/ceramika-dla-dzieci/c2f9e01a7b",
          "kod": "c2f9e01a7b",
          "listaOczekujacych": {
            "wlaczona": true,
            "pola": {
              "email": "wymagane",
              "imie": "opcjonalne",
              "telefon": "wylaczone"
            },
            "zgody": {
              "dokumenty": [
                {
                  "rodzaj": "regulamin",
                  "url": "https://seatbee.io/twoj-slug/regulamin"
                },
                {
                  "rodzaj": "polityka-prywatnosci",
                  "url": "https://seatbee.io/twoj-slug/polityka-prywatnosci"
                }
              ],
              "powiadomienie": "wymagana",
              "marketingowa": "dobrowolna",
              "wlasne": [
                {
                  "tekst": "Zgadzam się na zdjęcia z zajęć.",
                  "url": null,
                  "wymagana": false
                }
              ]
            },
            "url": "https://seatbee.io/twoj-slug/ceramika-dla-dzieci/c2f9e01a7b#lista-oczekujacych",
            "ramkaUrl": "https://seatbee.io/osadzenie/twoj-slug/ceramika-dla-dzieci/c2f9e01a7b/formularz"
          }
        }
      ],
      "listaOczekujacych": {
        "wlaczona": true,
        "pola": {
          "email": "wymagane",
          "imie": "opcjonalne",
          "telefon": "wylaczone"
        },
        "zgody": {
          "dokumenty": [
            {
              "rodzaj": "regulamin",
              "url": "https://seatbee.io/twoj-slug/regulamin"
            },
            {
              "rodzaj": "polityka-prywatnosci",
              "url": "https://seatbee.io/twoj-slug/polityka-prywatnosci"
            }
          ],
          "powiadomienie": "wymagana",
          "marketingowa": "dobrowolna",
          "wlasne": [
            {
              "tekst": "Zgadzam się na zdjęcia z zajęć.",
              "url": null,
              "wymagana": false
            }
          ]
        },
        "url": "https://seatbee.io/twoj-slug/ceramika-dla-dzieci#lista-oczekujacych",
        "ramkaUrl": "https://seatbee.io/osadzenie/twoj-slug/ceramika-dla-dzieci/formularz"
      }
    }
  ],
  "pakiety": [
    {
      "slug": "cykl-ceramiki",
      "tytul": "Cykl ceramiki",
      "rodzaj": "zestaw",
      "opis": "Oba Terminy taniej niż osobno.",
      "url": "https://seatbee.io/twoj-slug/pakiet/cykl-ceramiki",
      "okladkaUrl": null,
      "cena": 300,
      "cenaPoprzednia": null,
      "waluta": "PLN",
      "wSprzedazy": false,
      "stan": "wyprzedany",
      "terminy": [
        "k7d2a9f01c",
        "b41e0c77d3"
      ],
      "wejscia": null,
      "waznosc": null,
      "wydarzenia": null
    },
    {
      "slug": "karnet-5-wejsc",
      "tytul": "Karnet 5 wejść",
      "rodzaj": "karnet",
      "opis": "Pięć warsztatów do wyboru w trzy miesiące.",
      "url": "https://seatbee.io/twoj-slug/pakiet/karnet-5-wejsc",
      "okladkaUrl": "https://pliki.example.com/okladki/karnet.jpg",
      "cena": 720,
      "cenaPoprzednia": 800,
      "waluta": "PLN",
      "wSprzedazy": true,
      "stan": "w-sprzedazy",
      "terminy": null,
      "wejscia": 5,
      "waznosc": {
        "rodzaj": "od-zakupu",
        "dni": 92,
        "miesiace": 3
      },
      "wydarzenia": [
        "warsztat-ceramiki"
      ]
    },
    {
      "slug": "karnet-grudzien",
      "tytul": "Karnet grudniowy",
      "rodzaj": "karnet",
      "opis": "Dwa wejścia do końca roku.",
      "url": "https://seatbee.io/twoj-slug/pakiet/karnet-grudzien",
      "okladkaUrl": null,
      "cena": 320,
      "cenaPoprzednia": null,
      "waluta": "PLN",
      "wSprzedazy": true,
      "stan": "w-sprzedazy",
      "terminy": null,
      "wejscia": 2,
      "waznosc": {
        "rodzaj": "do-dnia",
        "dzien": "2026-12-31"
      },
      "wydarzenia": [
        "warsztat-ceramiki"
      ]
    }
  ]
}

Błędy i limit zapytań

StatusTreśćCo zrobić
200danepokaż je
404{ "error": "not_found" }zły slug (literówka albo zmieniony Adres publiczny). Też jest pamiętany do 60 s.
429{ "error": "rate_limited" }, nagłówek Retry-After: 60przekroczony limit; poczekaj 60 s. Strony nie ukrywaj: pokaż ostatnie znane dane albo link do organizator.url.
500{ "error": "server_error" }chwilowy błąd; jak przy 429.

Limit: 60 zapytań na minutę i 600 na godzinę z jednego adresu IP dla jednego Organizatora (oba adresy liczą się razem). Gdy dane pobiera przeglądarka, każdy odwiedzający ma swój limit i nie da się go przekroczyć zwykłym oglądaniem. Gdy pobiera serwer albo budowanie strony, wszystkie zapytania idą z jednego adresu: wtedy pamiętaj odpowiedź co najmniej 60 s i nie pytaj przy każdym wyświetleniu (scenariusz F).

Odświeżanie i pamięć podręczna

Odpowiedź ma nagłówek Cache-Control: public, s-maxage=60, stale-while-revalidate=300. Oznacza to, że pamięć pośrednia może trzymać odpowiedź do 60 s, a potem jeszcze do 300 s oddać starą odpowiedź, w tle pobierając nową.

Po zmianie w Seatbee (cena, usunięty Termin, wstrzymana sprzedaż) pierwsze wejście na stronę może jeszcze pokazać stare dane, a kolejne już nowe. To nie jest błąd. Odczekaj minutę i odśwież stronę dwa razy, zanim zgłosisz, że „nie działa”.

Zasady widoczności

Strona pokazuje tylko to, co przyszło w danych, i nigdy nie ukrywa przycisku zamiast go wyłączyć. Kurs albo Pakiet:

Stan w odpowiedziCo pokazuje strona
slug nie ma w kursy ani pakiety (nieopublikowany, usunięty, szkic)karta znika
jest, wSprzedazy: false, listaOczekujacych nie jest nullkarta zostaje, napis „Sprzedaż wkrótce” i przycisk „Zostaw e-mail” do listaOczekujacych.url (albo formularz w ramce)
jest, wSprzedazy: false, listaOczekujacych: nullkarta zostaje, przycisk zakupu wyłączony, napis „Sprzedaż wkrótce” (Pakiet: według stan)
jest, wSprzedazy: truekarta z ceną i przyciskiem do url
wszystkie karty danego cennika zniknęłyzamiast cennika krótki komunikat, np. „Zapisy wkrótce”, i link do organizator.url
zapytanie się nie udało (sieć, 429, 5xx)nie ukrywać kart; pokazać ostatnie znane dane albo link do organizator.url

Wydarzenie i Termin:

Stan w odpowiedziCo pokazuje strona
Wydarzenia nie ma (szkic, usunięte, bez przyszłych Terminów)znika
Wydarzenie z listaOczekujacych (same „Zapisy wkrótce”)Terminy z „Zapisy wkrótce” i przycisk „Zostaw e-mail” do listaOczekujacych.url (albo formularz w ramce)
Termin minąłznika (nie ma go w odpowiedzi)
wyprzedany: true„Brak miejsc”, przycisk wyłączony
wSprzedazy: false, wyprzedany: false„Zapisy wkrótce”, przycisk wyłączony; gdy Termin ma listaOczekujacych, obok „Zostaw e-mail” do listaOczekujacych.url
wSprzedazy: trueprzycisk „Zapisz się” do url; wolne miejsca, gdy wolneMiejsca nie jest null
brak Wydarzeń w odpowiedzikomunikat, np. „Nowe terminy wkrótce”, i link do organizator.url
zapytanie się nie udałonie ukrywać; ostatnie znane dane albo link do organizator.url

Pakiet wydarzeń (Zestaw albo Karnet):

Stan w odpowiedziCo pokazuje strona
slug nie ma w pakiety (szkic, archiwum)karta znika
jest, wSprzedazy: falsekarta zostaje, przycisk wyłączony, napis według stan: „Brak miejsc” (wyprzedany), „Sprzedaż zakończona” (zakonczona), „Chwilowo niedostępny” (niedostepny)
jest, wSprzedazy: truekarta z ceną (i przekreśloną cenaPoprzednia, gdy nie jest null) i przyciskiem do url
kod z terminy ZestawuTermin z wydarzenia[].terminy[] o tym kod: data, miasto, miejsce

Lista oczekujących na własnej stronie

Gdy sprzedaż Kursu, Wydarzenia albo Terminu jest „wkrótce” (np. ukryty albo zaplanowany Start, Stripe jeszcze nie przyjmuje płatności, brak przyszłych Terminów), strona w Seatbee pokazuje w miejscu „Kup” formularz „Zostaw e-mail, damy znać o starcie sprzedaży”. W danych ta pozycja ma wtedy listaOczekujacych (inaczej null). Gdy sprzedaż ruszy, Seatbee wyśle każdej osobie z listy jeden e-mail „Sprzedaż otwarta” z linkiem do zakupu (o ile Organizator tego nie wyłączył), a listaOczekujacych znowu będzie null.

Ramka z danych, gdy formularz ma się pojawiać sam, tylko wtedy, gdy Kurs zbiera chętnych (miejsce w stronie: <div id="seatbee-lista"></div>; ramka ma swoją Stopkę produktu, więc oznaczenia Seatbee nie dodajesz):

// Lista oczekujących jednego Kursu w ramce, z danych /kursy (slug Kursu w KURS).
const KURS = 'kurs-trenerski'
fetch('https://seatbee.io/api/publiczne/twoj-slug/kursy')
  .then((odpowiedz) => odpowiedz.ok ? odpowiedz.json() : Promise.reject(odpowiedz.status))
  .then((dane) => {
    const kurs = dane.kursy.find((k) => k.slug === KURS)
    const miejsce = document.querySelector('#seatbee-lista')
    if (!kurs || !kurs.listaOczekujacych) return
    const ramka = document.createElement('iframe')
    ramka.src = kurs.listaOczekujacych.ramkaUrl
    ramka.title = kurs.tytul
    ramka.style.cssText = 'display:block;width:100%;height:480px;border:0'
    // Ramka podaje swoją wysokość; bez tego przewija się w środku.
    window.addEventListener('message', (e) => {
      if (e.source === ramka.contentWindow && e.data && e.data.type === 'seatbee:resize' && Number(e.data.height)) ramka.style.height = Number(e.data.height) + 'px'
    })
    ramka.addEventListener('load', () => ramka.contentWindow.postMessage('seatbee:height', '*'))
    miejsce.append(ramka)
  })
  .catch(() => {})

Oznaczenie Seatbee

Gdy seatbee.pokaz jest true, przy każdej liście albo cenniku z danymi Seatbee umieść oznaczenie: mały napis seatbee.tekst albo logo seatbee.logoUrl (z alt = seatbee.tekst), z linkiem do seatbee.url, np. pod przyciskami. Nie usuwaj go i nie ukrywaj stylami (display: none, przezroczystość, kolor tła, mikroskopijna czcionka). Gdy seatbee.pokaz jest false, nie pokazuj go. O wartości decyduje plan Organizatora w Seatbee, nie strona; kod ze scenariuszy robi to sam.

Scenariusze z gotowym kodem

Każdy scenariusz to jeden kawałek HTML do wklejenia w miejscu sekcji: znaczniki, szablon karty i skrypt. Kod działa w czystym HTML, wstawia tekst przez textContent (nigdy jako HTML), stosuje zasady widoczności i oznaczenie Seatbee. Podmień tylko adres danych i wygląd (klasy, style).

Który wybrać: gdy chcesz, żeby nowy Kurs pojawiał się sam, weź A. Gdy każdy Kurs ma własną podstronę z własnym opisem, weź B (nowy Kurs wymaga wtedy nowej podstrony). Dla Wydarzeń analogicznie: C to pełna lista, D zapowiedź na stronie głównej, E landing jednego warsztatu.

A. Lista wszystkich Kursów i Pakietów

Jeden szablon karty, pętla po kursy i pakiety w kolejności odpowiedzi. Nowy Kurs albo Pakiet pojawia się sam, wycofany znika. Gdy nic nie ma: komunikat i link do organizator.url.

<section id="seatbee-kursy">
  <div class="karty"></div>
</section>

<template id="seatbee-karta">
  <article class="seatbee-karta">
    <img class="okladka" alt="" hidden>
    <h3 class="tytul"></h3>
    <p class="opis"></p>
    <p><s class="cena-poprzednia" hidden></s> <strong class="cena"></strong></p>
    <p class="raty" hidden><span></span> <a>Kup na raty</a></p>
    <p class="dostep"></p>
    <a class="kup"></a>
  </article>
</template>

<style>
  .seatbee-karta [aria-disabled="true"], .seatbee-termin [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
  .seatbee-oznaczenie { display: block; margin-top: 12px; font-size: 13px; }
</style>

<script type="module">
  const ADRES = 'https://seatbee.io/api/publiczne/twoj-slug/kursy'
  const STRONA_ZAPISOW = 'https://seatbee.io/twoj-slug' // organizator.url: link, gdy danych nie da się pobrać

  // Pobiera dane. Przy błędzie (sieć, 404, 429, 5xx) oddaje ostatnie znane dane z tej przeglądarki albo null.
  async function pobierz(adres) {
    const klucz = 'seatbee:' + adres
    try {
      const odpowiedz = await fetch(adres)
      if (!odpowiedz.ok) throw new Error('HTTP ' + odpowiedz.status)
      const dane = await odpowiedz.json()
      try { localStorage.setItem(klucz, JSON.stringify(dane)) } catch {}
      return dane
    } catch {
      try { return JSON.parse(localStorage.getItem(klucz)) } catch { return null }
    }
  }
  const zl = (kwota, waluta) => kwota.toLocaleString('pl-PL', { style: 'currency', currency: waluta, minimumFractionDigits: Number.isInteger(kwota) ? 0 : 2 })
  const cenaTekst = (p) => p.cena === null ? '' : p.cena === 0 ? 'Bezpłatny' : zl(p.cena, p.waluta)
  // 1 rata, 2–4 raty, 5–21 rat, 22–24 raty…
  const liczbaRat = (n) => n + ' ' + (n === 1 ? 'rata' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'raty' : 'rat')
  const dostepTekst = (d) => !d ? '' : d.rodzaj === 'na-zawsze' ? 'Dostęp na zawsze' : d.miesiace ? 'Dostęp na ' + d.miesiace + ' mies.' : 'Dostęp na ' + d.dni + ' dni'
  // Przycisk: aktywny link do Seatbee albo wyłączony z napisem. Nigdy nie ukrywaj go zamiast wyłączyć.
  function przycisk(link, url, aktywny, napis, napisWylaczony) {
    if (aktywny) { link.href = url; link.textContent = napis; link.removeAttribute('aria-disabled') }
    else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.textContent = napisWylaczony }
  }
  // Oznaczenie Seatbee: dodaj, gdy seatbee.pokaz jest true. Nie usuwaj go i nie ukrywaj stylami.
  function oznaczenie(seatbee) {
    const link = document.createElement('a')
    link.className = 'seatbee-oznaczenie'
    link.href = seatbee.url
    link.textContent = seatbee.tekst
    return link
  }
  function komunikat(tekst, adres) {
    const akapit = document.createElement('p')
    const link = document.createElement('a')
    link.href = adres
    link.textContent = 'Zobacz stronę zapisów'
    akapit.append(tekst + ' ', link)
    return akapit
  }
  // Cena, raty, dostęp i przycisk jednej karty z danych jednego Kursu albo Pakietu.
  function wypelnij(karta, p) {
    karta.querySelector('.cena').textContent = cenaTekst(p)
    const poprzednia = karta.querySelector('.cena-poprzednia')
    poprzednia.hidden = p.cenaPoprzednia === null
    poprzednia.textContent = p.cenaPoprzednia === null ? '' : zl(p.cenaPoprzednia, p.waluta)
    const raty = karta.querySelector('.raty')
    raty.hidden = !p.raty
    if (p.raty) {
      raty.querySelector('span').textContent = 'albo ' + liczbaRat(p.raty.liczba) + ': ' + p.raty.kwoty.map((k) => zl(k, p.waluta)).join(' + ')
      raty.querySelector('a').href = p.raty.url
    }
    karta.querySelector('.dostep').textContent = dostepTekst(p.dostep)
    przycisk(karta.querySelector('.kup'), p.url, p.wSprzedazy, 'Kup', 'Sprzedaż wkrótce')
  }

  const sekcja = document.querySelector('#seatbee-kursy')
  const karty = sekcja.querySelector('.karty')
  const szablon = document.querySelector('#seatbee-karta')
  const dane = await pobierz(ADRES)

  if (!dane) {
    karty.append(komunikat('Nie udało się wczytać cennika.', STRONA_ZAPISOW))
  } else {
    // Jedna karta w pętli dla każdej pozycji, w kolejności odpowiedzi: nowy Kurs pojawi się sam, wycofany zniknie.
    const pozycje = [...dane.kursy, ...dane.pakiety]
    for (const p of pozycje) {
      const karta = szablon.content.cloneNode(true)
      if (p.okladkaUrl) { const okladka = karta.querySelector('.okladka'); okladka.src = p.okladkaUrl; okladka.hidden = false }
      karta.querySelector('.tytul').textContent = p.tytul
      karta.querySelector('.opis').textContent = p.opis
      wypelnij(karta, p)
      karty.append(karta)
    }
    if (!pozycje.length) karty.append(komunikat('Zapisy wkrótce.', dane.organizator.url))
    if (dane.seatbee.pokaz) sekcja.append(oznaczenie(dane.seatbee))
  }
</script>

B. Cennik na własnej podstronie Kursu

Wzorzec: strona, na której każdy Kurs ma własny landing, a w jego cenniku każdy wariant jest oznaczony slugiem Kursu albo Pakietu z Seatbee (seatbee: "<slug>" w danych strony, w HTML atrybut data-seatbee="<slug>"). Tytuł i opis wariantu piszesz sam; skrypt dopisuje cenę, raty, dostęp i przycisk, a wariant, którego nie ma w danych, usuwa.

Aktualną listę slugów Twoich Kursów i Pakietów widać w panelu w oknie „Cennik na Twoją stronę” i w polu slug odpowiedzi.

<!-- Podstrona Kursu. Tytuły, opisy i wygląd wariantów są Twoje; cena, raty, dostęp i przycisk przychodzą z Seatbee.
     Każdy wariant cennika ma data-seatbee="<slug Kursu albo Pakietu>". -->
<section id="cennik">
  <article class="seatbee-karta" data-seatbee="kurs-online">
    <h3>Sam kurs</h3>
    <p>Twój opis tego wariantu.</p>
    <p><s class="cena-poprzednia" hidden></s> <strong class="cena"></strong></p>
    <p class="raty" hidden><span></span> <a>Kup na raty</a></p>
    <p class="dostep"></p>
    <a class="kup" href="https://seatbee.io/twoj-slug/kurs/kurs-online">Kup</a>
  </article>
  <article class="seatbee-karta" data-seatbee="pakiet-pelny">
    <h3>Kurs z pakietem</h3>
    <p>Twój opis tego wariantu.</p>
    <p><s class="cena-poprzednia" hidden></s> <strong class="cena"></strong></p>
    <p class="raty" hidden><span></span> <a>Kup na raty</a></p>
    <p class="dostep"></p>
    <a class="kup" href="https://seatbee.io/twoj-slug/pakiet/pakiet-pelny">Kup</a>
  </article>
</section>

<style>
  .seatbee-karta [aria-disabled="true"], .seatbee-termin [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
  .seatbee-oznaczenie { display: block; margin-top: 12px; font-size: 13px; }
</style>

<script type="module">
  const ADRES = 'https://seatbee.io/api/publiczne/twoj-slug/kursy'

  // Pobiera dane. Przy błędzie (sieć, 404, 429, 5xx) oddaje ostatnie znane dane z tej przeglądarki albo null.
  async function pobierz(adres) {
    const klucz = 'seatbee:' + adres
    try {
      const odpowiedz = await fetch(adres)
      if (!odpowiedz.ok) throw new Error('HTTP ' + odpowiedz.status)
      const dane = await odpowiedz.json()
      try { localStorage.setItem(klucz, JSON.stringify(dane)) } catch {}
      return dane
    } catch {
      try { return JSON.parse(localStorage.getItem(klucz)) } catch { return null }
    }
  }
  const zl = (kwota, waluta) => kwota.toLocaleString('pl-PL', { style: 'currency', currency: waluta, minimumFractionDigits: Number.isInteger(kwota) ? 0 : 2 })
  const cenaTekst = (p) => p.cena === null ? '' : p.cena === 0 ? 'Bezpłatny' : zl(p.cena, p.waluta)
  // 1 rata, 2–4 raty, 5–21 rat, 22–24 raty…
  const liczbaRat = (n) => n + ' ' + (n === 1 ? 'rata' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'raty' : 'rat')
  const dostepTekst = (d) => !d ? '' : d.rodzaj === 'na-zawsze' ? 'Dostęp na zawsze' : d.miesiace ? 'Dostęp na ' + d.miesiace + ' mies.' : 'Dostęp na ' + d.dni + ' dni'
  // Przycisk: aktywny link do Seatbee albo wyłączony z napisem. Nigdy nie ukrywaj go zamiast wyłączyć.
  function przycisk(link, url, aktywny, napis, napisWylaczony) {
    if (aktywny) { link.href = url; link.textContent = napis; link.removeAttribute('aria-disabled') }
    else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.textContent = napisWylaczony }
  }
  // Oznaczenie Seatbee: dodaj, gdy seatbee.pokaz jest true. Nie usuwaj go i nie ukrywaj stylami.
  function oznaczenie(seatbee) {
    const link = document.createElement('a')
    link.className = 'seatbee-oznaczenie'
    link.href = seatbee.url
    link.textContent = seatbee.tekst
    return link
  }
  function komunikat(tekst, adres) {
    const akapit = document.createElement('p')
    const link = document.createElement('a')
    link.href = adres
    link.textContent = 'Zobacz stronę zapisów'
    akapit.append(tekst + ' ', link)
    return akapit
  }
  // Cena, raty, dostęp i przycisk jednej karty z danych jednego Kursu albo Pakietu.
  function wypelnij(karta, p) {
    karta.querySelector('.cena').textContent = cenaTekst(p)
    const poprzednia = karta.querySelector('.cena-poprzednia')
    poprzednia.hidden = p.cenaPoprzednia === null
    poprzednia.textContent = p.cenaPoprzednia === null ? '' : zl(p.cenaPoprzednia, p.waluta)
    const raty = karta.querySelector('.raty')
    raty.hidden = !p.raty
    if (p.raty) {
      raty.querySelector('span').textContent = 'albo ' + liczbaRat(p.raty.liczba) + ': ' + p.raty.kwoty.map((k) => zl(k, p.waluta)).join(' + ')
      raty.querySelector('a').href = p.raty.url
    }
    karta.querySelector('.dostep').textContent = dostepTekst(p.dostep)
    przycisk(karta.querySelector('.kup'), p.url, p.wSprzedazy, 'Kup', 'Sprzedaż wkrótce')
  }

  const cennik = document.querySelector('#cennik')
  const dane = await pobierz(ADRES)
  // Bez danych (błąd i brak ostatnich znanych) karty zostają takie, jak są w HTML: przycisk prowadzi do strony w Seatbee.
  if (dane) {
    const pozycje = new Map([...dane.kursy, ...dane.pakiety].map((p) => [p.slug, p]))
    for (const karta of cennik.querySelectorAll('[data-seatbee]')) {
      const p = pozycje.get(karta.dataset.seatbee)
      if (!p) { karta.remove(); continue } // nieopublikowany, usunięty albo szkic: karta znika
      wypelnij(karta, p) // wSprzedazy: false: karta zostaje, przycisk „Sprzedaż wkrótce” wyłączony
    }
    if (!cennik.querySelector('[data-seatbee]')) cennik.append(komunikat('Zapisy wkrótce.', dane.organizator.url))
    if (dane.seatbee.pokaz) cennik.append(oznaczenie(dane.seatbee))
  }
</script>

C. Lista wszystkich Wydarzeń z Terminami

Np. zakładka „Warsztaty”: jedna karta Wydarzenia z listą jego Terminów, w kolejności z odpowiedzi. Nowe Wydarzenie albo nowy Termin pojawia się sam, minione znikają.

<section id="seatbee-wydarzenia">
  <div class="lista"></div>
</section>

<template id="seatbee-wydarzenie">
  <article class="seatbee-wydarzenie">
    <img class="okladka" alt="" hidden>
    <h3 class="tytul"></h3>
    <p class="opis"></p>
    <ul class="terminy"></ul>
    <a class="wiecej">Wszystkie szczegóły</a>
  </article>
</template>
<template id="seatbee-termin">
  <li class="seatbee-termin">
    <strong class="nazwa"></strong>
    <span class="kiedy"></span> · <span class="gdzie"></span> · <span class="cena"></span>
    <span class="miejsca"></span>
    <a class="zapis"></a>
  </li>
</template>

<style>
  .seatbee-karta [aria-disabled="true"], .seatbee-termin [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
  .seatbee-oznaczenie { display: block; margin-top: 12px; font-size: 13px; }
</style>

<script type="module">
  const ADRES = 'https://seatbee.io/api/publiczne/twoj-slug/wydarzenia'
  const STRONA_ZAPISOW = 'https://seatbee.io/twoj-slug'

  // Pobiera dane. Przy błędzie (sieć, 404, 429, 5xx) oddaje ostatnie znane dane z tej przeglądarki albo null.
  async function pobierz(adres) {
    const klucz = 'seatbee:' + adres
    try {
      const odpowiedz = await fetch(adres)
      if (!odpowiedz.ok) throw new Error('HTTP ' + odpowiedz.status)
      const dane = await odpowiedz.json()
      try { localStorage.setItem(klucz, JSON.stringify(dane)) } catch {}
      return dane
    } catch {
      try { return JSON.parse(localStorage.getItem(klucz)) } catch { return null }
    }
  }
  const zl = (kwota, waluta) => kwota.toLocaleString('pl-PL', { style: 'currency', currency: waluta, minimumFractionDigits: Number.isInteger(kwota) ? 0 : 2 })
  const godzina = (iso) => new Date(iso).toLocaleTimeString('pl-PL', { timeZone: 'Europe/Warsaw', hour: '2-digit', minute: '2-digit' })
  function kiedy(t) {
    const dzien = new Date(t.start).toLocaleDateString('pl-PL', { timeZone: 'Europe/Warsaw', weekday: 'long', day: 'numeric', month: 'long' })
    return dzien + ', ' + godzina(t.start) + (t.koniec ? '–' + godzina(t.koniec) : '')
  }
  function przycisk(link, url, aktywny, napis, napisWylaczony) {
    if (aktywny) { link.href = url; link.textContent = napis; link.removeAttribute('aria-disabled') }
    else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.textContent = napisWylaczony }
  }
  function oznaczenie(seatbee) {
    const link = document.createElement('a')
    link.className = 'seatbee-oznaczenie'
    link.href = seatbee.url
    link.textContent = seatbee.tekst
    return link
  }
  function komunikat(tekst, adres) {
    const akapit = document.createElement('p')
    const link = document.createElement('a')
    link.href = adres
    link.textContent = 'Zobacz stronę zapisów'
    akapit.append(tekst + ' ', link)
    return akapit
  }
  // Jeden wiersz Terminu z szablonu #seatbee-termin.
  function termin(t, tytul) {
    const wiersz = document.querySelector('#seatbee-termin').content.cloneNode(true)
    const nazwa = wiersz.querySelector('.nazwa')
    if (nazwa) nazwa.textContent = tytul
    wiersz.querySelector('.kiedy').textContent = kiedy(t)
    wiersz.querySelector('.gdzie').textContent = [t.miasto, t.miejsce].filter(Boolean).join(', ')
    wiersz.querySelector('.cena').textContent = t.cena === null ? '' : t.cena === 0 ? 'Bezpłatnie' : zl(t.cena, t.waluta)
    // Przy braku miejsc mówi to sam wyłączony przycisk.
    wiersz.querySelector('.miejsca').textContent = t.wyprzedany || t.wolneMiejsca === null ? '' : 'Wolne miejsca: ' + t.wolneMiejsca
    const zapis = wiersz.querySelector('.zapis')
    if (t.wyprzedany) przycisk(zapis, t.url, false, '', 'Brak miejsc')
    else przycisk(zapis, t.url, t.wSprzedazy, 'Zapisz się', 'Zapisy wkrótce')
    return wiersz
  }

  const sekcja = document.querySelector('#seatbee-wydarzenia')
  const lista = sekcja.querySelector('.lista')
  const dane = await pobierz(ADRES)

  if (!dane) {
    lista.append(komunikat('Nie udało się wczytać terminów.', STRONA_ZAPISOW))
  } else {
    // Jedna karta Wydarzenia z listą jego Terminów, w kolejności odpowiedzi.
    for (const w of dane.wydarzenia) {
      const karta = document.querySelector('#seatbee-wydarzenie').content.cloneNode(true)
      if (w.okladkaUrl) { const okladka = karta.querySelector('.okladka'); okladka.src = w.okladkaUrl; okladka.hidden = false }
      karta.querySelector('.tytul').textContent = w.tytul
      karta.querySelector('.opis').textContent = w.opis
      karta.querySelector('.wiecej').href = w.url
      const terminy = karta.querySelector('.terminy')
      for (const t of w.terminy) terminy.append(termin(t, ''))
      lista.append(karta)
    }
    if (!dane.wydarzenia.length) lista.append(komunikat('Nowe terminy wkrótce.', dane.organizator.url))
    if (dane.seatbee.pokaz) sekcja.append(oznaczenie(dane.seatbee))
  }
</script>

D. Najbliższe N Terminów w sekcji strony

Np. na stronie głównej: Terminy wszystkich Wydarzeń razem, po dacie startu, tylko pierwsze ILE, z linkiem „Wszystkie terminy”.

<section id="seatbee-najblizsze">
  <h2>Najbliższe terminy</h2>
  <ul class="terminy"></ul>
</section>
<template id="seatbee-termin">
  <li class="seatbee-termin">
    <strong class="nazwa"></strong>
    <span class="kiedy"></span> · <span class="gdzie"></span> · <span class="cena"></span>
    <span class="miejsca"></span>
    <a class="zapis"></a>
  </li>
</template>

<style>
  .seatbee-karta [aria-disabled="true"], .seatbee-termin [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
  .seatbee-oznaczenie { display: block; margin-top: 12px; font-size: 13px; }
</style>

<script type="module">
  const ADRES = 'https://seatbee.io/api/publiczne/twoj-slug/wydarzenia'
  const STRONA_ZAPISOW = 'https://seatbee.io/twoj-slug'
  const ILE = 3 // ile najbliższych Terminów pokazać

  // Pobiera dane. Przy błędzie (sieć, 404, 429, 5xx) oddaje ostatnie znane dane z tej przeglądarki albo null.
  async function pobierz(adres) {
    const klucz = 'seatbee:' + adres
    try {
      const odpowiedz = await fetch(adres)
      if (!odpowiedz.ok) throw new Error('HTTP ' + odpowiedz.status)
      const dane = await odpowiedz.json()
      try { localStorage.setItem(klucz, JSON.stringify(dane)) } catch {}
      return dane
    } catch {
      try { return JSON.parse(localStorage.getItem(klucz)) } catch { return null }
    }
  }
  const zl = (kwota, waluta) => kwota.toLocaleString('pl-PL', { style: 'currency', currency: waluta, minimumFractionDigits: Number.isInteger(kwota) ? 0 : 2 })
  const godzina = (iso) => new Date(iso).toLocaleTimeString('pl-PL', { timeZone: 'Europe/Warsaw', hour: '2-digit', minute: '2-digit' })
  function kiedy(t) {
    const dzien = new Date(t.start).toLocaleDateString('pl-PL', { timeZone: 'Europe/Warsaw', weekday: 'long', day: 'numeric', month: 'long' })
    return dzien + ', ' + godzina(t.start) + (t.koniec ? '–' + godzina(t.koniec) : '')
  }
  function przycisk(link, url, aktywny, napis, napisWylaczony) {
    if (aktywny) { link.href = url; link.textContent = napis; link.removeAttribute('aria-disabled') }
    else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.textContent = napisWylaczony }
  }
  function oznaczenie(seatbee) {
    const link = document.createElement('a')
    link.className = 'seatbee-oznaczenie'
    link.href = seatbee.url
    link.textContent = seatbee.tekst
    return link
  }
  function komunikat(tekst, adres) {
    const akapit = document.createElement('p')
    const link = document.createElement('a')
    link.href = adres
    link.textContent = 'Zobacz stronę zapisów'
    akapit.append(tekst + ' ', link)
    return akapit
  }
  // Jeden wiersz Terminu z szablonu #seatbee-termin.
  function termin(t, tytul) {
    const wiersz = document.querySelector('#seatbee-termin').content.cloneNode(true)
    const nazwa = wiersz.querySelector('.nazwa')
    if (nazwa) nazwa.textContent = tytul
    wiersz.querySelector('.kiedy').textContent = kiedy(t)
    wiersz.querySelector('.gdzie').textContent = [t.miasto, t.miejsce].filter(Boolean).join(', ')
    wiersz.querySelector('.cena').textContent = t.cena === null ? '' : t.cena === 0 ? 'Bezpłatnie' : zl(t.cena, t.waluta)
    // Przy braku miejsc mówi to sam wyłączony przycisk.
    wiersz.querySelector('.miejsca').textContent = t.wyprzedany || t.wolneMiejsca === null ? '' : 'Wolne miejsca: ' + t.wolneMiejsca
    const zapis = wiersz.querySelector('.zapis')
    if (t.wyprzedany) przycisk(zapis, t.url, false, '', 'Brak miejsc')
    else przycisk(zapis, t.url, t.wSprzedazy, 'Zapisz się', 'Zapisy wkrótce')
    return wiersz
  }

  const sekcja = document.querySelector('#seatbee-najblizsze')
  const lista = sekcja.querySelector('.terminy')
  const dane = await pobierz(ADRES)

  if (!dane) {
    lista.append(komunikat('Nie udało się wczytać terminów.', STRONA_ZAPISOW))
  } else {
    // Terminy wszystkich Wydarzeń razem, po dacie startu, tylko pierwsze ILE.
    const najblizsze = dane.wydarzenia
      .flatMap((w) => w.terminy.map((t) => ({ w, t })))
      .sort((a, b) => new Date(a.t.start) - new Date(b.t.start))
      .slice(0, ILE)
    for (const { w, t } of najblizsze) lista.append(termin(t, w.tytul))
    if (!najblizsze.length) lista.append(komunikat('Nowe terminy wkrótce.', dane.organizator.url))
    const wszystkie = document.createElement('a')
    wszystkie.href = dane.organizator.url
    wszystkie.textContent = 'Wszystkie terminy'
    const akapit = document.createElement('p')
    akapit.append(wszystkie)
    sekcja.append(akapit)
    if (dane.seatbee.pokaz) sekcja.append(oznaczenie(dane.seatbee))
  }
</script>

E. Jedno Wydarzenie na landingu warsztatu

Landing jednego warsztatu z własną treścią; z Seatbee przychodzą tylko Terminy tego Wydarzenia, dopasowane po slug (atrybut data-seatbee sekcji). Gdy Wydarzenia nie ma w danych, zamiast Terminów pojawia się komunikat i link.

<!-- Landing jednego warsztatu. Treść strony jest Twoja; z Seatbee przychodzą tylko Terminy tego Wydarzenia. -->
<section id="seatbee-terminy" data-seatbee="warsztat-ceramiki">
  <h2>Terminy</h2>
  <ul class="terminy"></ul>
</section>
<template id="seatbee-termin">
  <li class="seatbee-termin">
    <strong class="nazwa"></strong>
    <span class="kiedy"></span> · <span class="gdzie"></span> · <span class="cena"></span>
    <span class="miejsca"></span>
    <a class="zapis"></a>
  </li>
</template>

<style>
  .seatbee-karta [aria-disabled="true"], .seatbee-termin [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
  .seatbee-oznaczenie { display: block; margin-top: 12px; font-size: 13px; }
</style>

<script type="module">
  const ADRES = 'https://seatbee.io/api/publiczne/twoj-slug/wydarzenia'
  const STRONA_WYDARZENIA = 'https://seatbee.io/twoj-slug/warsztat-ceramiki' // wydarzenia[].url: link, gdy danych nie da się pobrać

  // Pobiera dane. Przy błędzie (sieć, 404, 429, 5xx) oddaje ostatnie znane dane z tej przeglądarki albo null.
  async function pobierz(adres) {
    const klucz = 'seatbee:' + adres
    try {
      const odpowiedz = await fetch(adres)
      if (!odpowiedz.ok) throw new Error('HTTP ' + odpowiedz.status)
      const dane = await odpowiedz.json()
      try { localStorage.setItem(klucz, JSON.stringify(dane)) } catch {}
      return dane
    } catch {
      try { return JSON.parse(localStorage.getItem(klucz)) } catch { return null }
    }
  }
  const zl = (kwota, waluta) => kwota.toLocaleString('pl-PL', { style: 'currency', currency: waluta, minimumFractionDigits: Number.isInteger(kwota) ? 0 : 2 })
  const godzina = (iso) => new Date(iso).toLocaleTimeString('pl-PL', { timeZone: 'Europe/Warsaw', hour: '2-digit', minute: '2-digit' })
  function kiedy(t) {
    const dzien = new Date(t.start).toLocaleDateString('pl-PL', { timeZone: 'Europe/Warsaw', weekday: 'long', day: 'numeric', month: 'long' })
    return dzien + ', ' + godzina(t.start) + (t.koniec ? '–' + godzina(t.koniec) : '')
  }
  function przycisk(link, url, aktywny, napis, napisWylaczony) {
    if (aktywny) { link.href = url; link.textContent = napis; link.removeAttribute('aria-disabled') }
    else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.textContent = napisWylaczony }
  }
  function oznaczenie(seatbee) {
    const link = document.createElement('a')
    link.className = 'seatbee-oznaczenie'
    link.href = seatbee.url
    link.textContent = seatbee.tekst
    return link
  }
  function komunikat(tekst, adres) {
    const akapit = document.createElement('p')
    const link = document.createElement('a')
    link.href = adres
    link.textContent = 'Zobacz stronę zapisów'
    akapit.append(tekst + ' ', link)
    return akapit
  }
  // Jeden wiersz Terminu z szablonu #seatbee-termin.
  function termin(t, tytul) {
    const wiersz = document.querySelector('#seatbee-termin').content.cloneNode(true)
    const nazwa = wiersz.querySelector('.nazwa')
    if (nazwa) nazwa.textContent = tytul
    wiersz.querySelector('.kiedy').textContent = kiedy(t)
    wiersz.querySelector('.gdzie').textContent = [t.miasto, t.miejsce].filter(Boolean).join(', ')
    wiersz.querySelector('.cena').textContent = t.cena === null ? '' : t.cena === 0 ? 'Bezpłatnie' : zl(t.cena, t.waluta)
    // Przy braku miejsc mówi to sam wyłączony przycisk.
    wiersz.querySelector('.miejsca').textContent = t.wyprzedany || t.wolneMiejsca === null ? '' : 'Wolne miejsca: ' + t.wolneMiejsca
    const zapis = wiersz.querySelector('.zapis')
    if (t.wyprzedany) przycisk(zapis, t.url, false, '', 'Brak miejsc')
    else przycisk(zapis, t.url, t.wSprzedazy, 'Zapisz się', 'Zapisy wkrótce')
    return wiersz
  }

  const sekcja = document.querySelector('#seatbee-terminy')
  const lista = sekcja.querySelector('.terminy')
  const dane = await pobierz(ADRES)

  if (!dane) {
    lista.append(komunikat('Nie udało się wczytać terminów.', STRONA_WYDARZENIA))
  } else {
    const w = dane.wydarzenia.find((wydarzenie) => wydarzenie.slug === sekcja.dataset.seatbee)
    // Wydarzenia nie ma w odpowiedzi (szkic, usunięte albo bez przyszłych Terminów): Terminy znikają, zostaje komunikat.
    if (!w) lista.append(komunikat('Nowe terminy wkrótce.', dane.organizator.url))
    else for (const t of w.terminy) lista.append(termin(t, ''))
    if (dane.seatbee.pokaz) sekcja.append(oznaczenie(dane.seatbee))
  }
</script>

F. Strona statyczna z budowaniem

Gdy strona jest budowana (Astro, Next.js eksport statyczny, Hugo, Eleventy) i chcesz, żeby ceny i Terminy były w HTML dla wyszukiwarek: pobierz dane raz przy budowaniu, wygeneruj z nich karty, oznaczenie Seatbee (gdy seatbee.pokaz jest true) i dane strukturalne, a w przeglądarce odśwież je tym samym kodem co w scenariuszu B. Odwiedzający widzi aktualne dane bez przebudowy strony, a gdy zapytanie się nie uda, zostają dane z budowania.

Jak uniknąć 429: jedno zapytanie na adres dla całego budowania (nie jedno na podstronę ani na kartę), ponowienie po Retry-After, a przy renderowaniu na serwerze pamięć co najmniej 60 s.

// pobierz-dane-seatbee.mjs: uruchom raz przed budowaniem strony, np. "node pobierz-dane-seatbee.mjs && npm run build".
// Jedno zapytanie na adres dla całego budowania; wszystkie podstrony czytają te same pliki z katalogu dane/.
const { mkdir, readFile, writeFile } = await import('node:fs/promises')

const ADRESY = {
  kursy: 'https://seatbee.io/api/publiczne/twoj-slug/kursy',
  wydarzenia: 'https://seatbee.io/api/publiczne/twoj-slug/wydarzenia',
}

async function pobierz(adres, proby = 3) {
  for (let proba = 1; ; proba += 1) {
    const odpowiedz = await fetch(adres)
    if (odpowiedz.ok) return odpowiedz.json()
    if (odpowiedz.status === 429 && proba < proby) {
      // Limit zapytań: poczekaj tyle, ile mówi Retry-After, i spróbuj jeszcze raz.
      const sekundy = Number(odpowiedz.headers.get('Retry-After')) || 60
      await new Promise((gotowe) => setTimeout(gotowe, sekundy * 1000))
      continue
    }
    throw new Error(adres + ': HTTP ' + odpowiedz.status)
  }
}

await mkdir('dane', { recursive: true })
for (const [nazwa, adres] of Object.entries(ADRESY)) {
  const plik = 'dane/seatbee-' + nazwa + '.json'
  try {
    await writeFile(plik, JSON.stringify(await pobierz(adres), null, 2))
  } catch (blad) {
    // Bez nowych danych budowanie korzysta z ostatnich zapisanych; bez żadnych przerywa się.
    await readFile(plik).catch(() => { throw blad })
    console.warn('Seatbee: zostają ostatnie dane w ' + plik + ' (' + blad.message + ')')
  }
}
<!-- Zbudowana strona: karty są już w HTML (z dane/seatbee-kursy.json, dla wyszukiwarek), każda z data-seatbee,
     tak jak w scenariuszu B. Skrypt odświeża je w przeglądarce odwiedzającego, bez przebudowy strony. -->
<section id="cennik">
  <article class="seatbee-karta" data-seatbee="kurs-online">
    <h3>Kurs online</h3>
    <p><s class="cena-poprzednia">349 zł</s> <strong class="cena">300 zł</strong></p>
    <p class="raty"><span>albo 3 raty: 120 zł + 90 zł + 90 zł</span> <a href="https://seatbee.io/twoj-slug/kurs/kurs-online/raty">Kup na raty</a></p>
    <p class="dostep">Dostęp na 6 mies.</p>
    <a class="kup" href="https://seatbee.io/twoj-slug/kurs/kurs-online">Kup</a>
  </article>
  <!-- Oznaczenie wyrenderowane przy budowaniu, gdy seatbee.pokaz był true. -->
  <a class="seatbee-oznaczenie" href="https://seatbee.io/?ref=twoj-slug">Zapisy i płatności: Seatbee</a>
</section>

<style>
  .seatbee-karta [aria-disabled="true"], .seatbee-termin [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
  .seatbee-oznaczenie { display: block; margin-top: 12px; font-size: 13px; }
</style>

<script type="module">
  const ADRES = 'https://seatbee.io/api/publiczne/twoj-slug/kursy'

  // Pobiera dane. Przy błędzie (sieć, 404, 429, 5xx) oddaje ostatnie znane dane z tej przeglądarki albo null.
  async function pobierz(adres) {
    const klucz = 'seatbee:' + adres
    try {
      const odpowiedz = await fetch(adres)
      if (!odpowiedz.ok) throw new Error('HTTP ' + odpowiedz.status)
      const dane = await odpowiedz.json()
      try { localStorage.setItem(klucz, JSON.stringify(dane)) } catch {}
      return dane
    } catch {
      try { return JSON.parse(localStorage.getItem(klucz)) } catch { return null }
    }
  }
  const zl = (kwota, waluta) => kwota.toLocaleString('pl-PL', { style: 'currency', currency: waluta, minimumFractionDigits: Number.isInteger(kwota) ? 0 : 2 })
  const cenaTekst = (p) => p.cena === null ? '' : p.cena === 0 ? 'Bezpłatny' : zl(p.cena, p.waluta)
  // 1 rata, 2–4 raty, 5–21 rat, 22–24 raty…
  const liczbaRat = (n) => n + ' ' + (n === 1 ? 'rata' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'raty' : 'rat')
  const dostepTekst = (d) => !d ? '' : d.rodzaj === 'na-zawsze' ? 'Dostęp na zawsze' : d.miesiace ? 'Dostęp na ' + d.miesiace + ' mies.' : 'Dostęp na ' + d.dni + ' dni'
  // Przycisk: aktywny link do Seatbee albo wyłączony z napisem. Nigdy nie ukrywaj go zamiast wyłączyć.
  function przycisk(link, url, aktywny, napis, napisWylaczony) {
    if (aktywny) { link.href = url; link.textContent = napis; link.removeAttribute('aria-disabled') }
    else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.textContent = napisWylaczony }
  }
  // Oznaczenie Seatbee: dodaj, gdy seatbee.pokaz jest true. Nie usuwaj go i nie ukrywaj stylami.
  function oznaczenie(seatbee) {
    const link = document.createElement('a')
    link.className = 'seatbee-oznaczenie'
    link.href = seatbee.url
    link.textContent = seatbee.tekst
    return link
  }
  function komunikat(tekst, adres) {
    const akapit = document.createElement('p')
    const link = document.createElement('a')
    link.href = adres
    link.textContent = 'Zobacz stronę zapisów'
    akapit.append(tekst + ' ', link)
    return akapit
  }
  // Cena, raty, dostęp i przycisk jednej karty z danych jednego Kursu albo Pakietu.
  function wypelnij(karta, p) {
    karta.querySelector('.cena').textContent = cenaTekst(p)
    const poprzednia = karta.querySelector('.cena-poprzednia')
    poprzednia.hidden = p.cenaPoprzednia === null
    poprzednia.textContent = p.cenaPoprzednia === null ? '' : zl(p.cenaPoprzednia, p.waluta)
    const raty = karta.querySelector('.raty')
    raty.hidden = !p.raty
    if (p.raty) {
      raty.querySelector('span').textContent = 'albo ' + liczbaRat(p.raty.liczba) + ': ' + p.raty.kwoty.map((k) => zl(k, p.waluta)).join(' + ')
      raty.querySelector('a').href = p.raty.url
    }
    karta.querySelector('.dostep').textContent = dostepTekst(p.dostep)
    przycisk(karta.querySelector('.kup'), p.url, p.wSprzedazy, 'Kup', 'Sprzedaż wkrótce')
  }

  const cennik = document.querySelector('#cennik')
  // Gdy zapytanie się nie uda, zostają dane z budowania: nic nie znika.
  const dane = await pobierz(ADRES)
  if (dane) {
    const pozycje = new Map([...dane.kursy, ...dane.pakiety].map((p) => [p.slug, p]))
    for (const karta of cennik.querySelectorAll('[data-seatbee]')) {
      const p = pozycje.get(karta.dataset.seatbee)
      if (!p) { karta.remove(); continue }
      wypelnij(karta, p)
    }
    if (!cennik.querySelector('[data-seatbee]')) cennik.append(komunikat('Zapisy wkrótce.', dane.organizator.url))
    // Oznaczenie z budowania zostaje albo znika według świeżego seatbee.pokaz.
    const znak = cennik.querySelector('.seatbee-oznaczenie')
    if (dane.seatbee.pokaz && !znak) cennik.append(oznaczenie(dane.seatbee))
    if (!dane.seatbee.pokaz && znak) znak.remove()
  }
</script>

Dane strukturalne schema.org

Opisz pokazane Kursy jako Course z Offer, a każdy Termin jako Event z Offer, w <script type="application/ld+json">. Dane strukturalne muszą zgadzać się z tym, co widać na stronie, więc generuj je z tej samej odpowiedzi, najlepiej przy budowaniu albo na serwerze (scenariusz F).

schema.orgPole Seatbee
Course.name / description / url / imagetytul / opis / url / okladkaUrl
Course.provider (Organization)organizator.nazwa (pomiń name, gdy null), organizator.url
Course.hasCourseInstance{ "@type": "CourseInstance", "courseMode": "online" }
Course.offers (Offer): price, priceCurrency, urlcena, waluta, url (bez offers, gdy cena jest null)
Offer.availability KursuwSprzedazy: true → InStock, false → PreOrder
Event.name / description / imagewydarzenia[].tytul / opis / okladkaUrl
Event.startDate / endDateterminy[].start / koniec
Event.location (Place z PostalAddress)miejsce (name, bez niego miasto), miasto (addressLocality); Termin bez miasta i bez miejsca: bez Event
Event.eventAttendanceMode, location Terminu onlineformat: "online" → OnlineEventAttendanceMode i { "@type": "VirtualLocation", "url": url }; inaczej OfflineEventAttendanceMode
Event.organizer (Organization)organizator.nazwa, organizator.url
Event.offers (Offer): price, priceCurrency, urlterminy[].cena, waluta, url (bez offers, gdy cena jest null)
Offer.availability Terminuwyprzedany → SoldOut, wSprzedazy: true → InStock, inaczej PreOrder

Google od września 2025 nie pokazuje już wyników rozszerzonych dla kursów (Course), więc te dane tylko opisują stronę dla innych wyszukiwarek i asystentów AI. Dla Wydarzeń (Event) Google wymaga name, startDate i location z adresem.

// Dane strukturalne schema.org z odpowiedzi Seatbee. Najlepiej generuj je przy budowaniu albo na serwerze
// (scenariusz F) i wstaw do HTML jako <script type="application/ld+json">JSON.stringify(...)</script>.
const dostepnosc = (wyprzedany, wSprzedazy) =>
  wyprzedany ? 'https://schema.org/SoldOut' : wSprzedazy ? 'https://schema.org/InStock' : 'https://schema.org/PreOrder'

// Bez nazwy (null) samo url: nigdy name: null.
const organizacja = (organizator) => ({ '@type': 'Organization', ...(organizator.nazwa ? { name: organizator.nazwa } : {}), url: organizator.url })

// Kurs albo Pakiet (dane.kursy[], dane.pakiety[]) jako Course z Offer.
function kursJsonLd(p, dane) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Course',
    name: p.tytul,
    description: p.opis,
    url: p.url,
    ...(p.okladkaUrl ? { image: p.okladkaUrl } : {}),
    provider: organizacja(dane.organizator),
    hasCourseInstance: { '@type': 'CourseInstance', courseMode: 'online' },
    ...(p.cena === null ? {} : {
      offers: { '@type': 'Offer', price: p.cena, priceCurrency: p.waluta, url: p.url, availability: dostepnosc(false, p.wSprzedazy) },
    }),
  }
}

// Jeden Termin (dane.wydarzenia[].terminy[]) jako Event z Offer; null, gdy Termin stacjonarny nie ma ani miasta, ani
// miejsca (Event bez miejsca nie jest poprawny, więc go nie wstawiaj). Termin online ma VirtualLocation z adresem zapisu.
function terminJsonLd(t, w, dane) {
  const online = t.format === 'online'
  if (!online && !t.miasto && !t.miejsce) return null
  return {
    '@context': 'https://schema.org',
    '@type': 'Event',
    name: w.tytul,
    description: w.opis,
    startDate: t.start,
    ...(t.koniec ? { endDate: t.koniec } : {}),
    eventStatus: 'https://schema.org/EventScheduled',
    eventAttendanceMode: online ? 'https://schema.org/OnlineEventAttendanceMode' : 'https://schema.org/OfflineEventAttendanceMode',
    location: online
      ? { '@type': 'VirtualLocation', url: t.url }
      : { '@type': 'Place', name: t.miejsce ?? t.miasto, address: { '@type': 'PostalAddress', ...(t.miasto ? { addressLocality: t.miasto } : {}), addressCountry: 'PL' } },
    ...(w.okladkaUrl ? { image: w.okladkaUrl } : {}),
    organizer: organizacja(dane.organizator),
    // Bez ceny (null) bez offers: nigdy nie podawaj 0, bo to znaczy „bezpłatnie”.
    ...(t.cena === null ? {} : {
      offers: { '@type': 'Offer', price: t.cena, priceCurrency: t.waluta, url: t.url, availability: dostepnosc(t.wyprzedany, t.wSprzedazy) },
    }),
  }
}

Najczęstsze błędy

Dla asystenta AI

Gdy budujesz integrację na podstawie tej instrukcji: wybierz scenariusz (A–F) według opisu strony, weź kod scenariusza i dopasuj tylko wygląd do strony, wstaw prawdziwy adres danych Organizatora i trzymaj się tabel „Zasady widoczności” i „Oznaczenie Seatbee”. Nie wymyślaj pól, których nie ma w tabelach pól. Ta instrukcja: https://seatbee.io/docs/integracja-strony, Markdown: https://seatbee.io/docs/integracja-strony.md.