# 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.

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](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, czcionki | ceny, cena przekreślona, raty |
| zdjęcia i własne opisy | czas dostępu, liczba lekcji i czas filmów |
| kolejność sekcji i podstron | Terminy, miasta, miejsca, wolne miejsca |
| które Kursy i Wydarzenia pokazać w danym miejscu | linki 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).

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

- Odpowiedź może przeczytać każda strona (nagłówek `Access-Control-Allow-Origin: *`), więc pobierasz ją wprost z przeglądarki odwiedzającego.
- Wszystkie adresy w odpowiedzi są pełne (`https://…`). Strony Organizatora mają jego własną domenę, gdy jest aktywna.
- Gdy zmienisz Adres publiczny w panelu, zmienia się też adres danych: popraw go na stronie.
- Gdy strony publiczne Organizatora są niedostępne (np. skończył się plan), odpowiedź ma puste listy.

## 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ą.

| Pole | Typ | Znaczenie |
| --- | --- | --- |
| `organizator` | `object` | Organizator, którego są dane. |
| `organizator.nazwa` | `string \| null` | Nazwa Organizatora; `null`, gdy jej nie wpisał. |
| `organizator.url` | `string` | Strona Organizatora w Seatbee (pod własną domeną, gdy jest aktywna). Link zapasowy, gdy nic innego nie da się pokazać. |
| `seatbee` | `object` | Oznaczenie Seatbee do pokazania przy cenniku (sekcja „Oznaczenie Seatbee”). |
| `seatbee.pokaz` | `boolean` | `true`: pokaż oznaczenie. `false`: nie pokazuj (plan Organizatora wyłącza branding). Decyduje Seatbee, nie strona. |
| `seatbee.tekst` | `string` | Napis oznaczenia, np. „Zapisy i płatności: Seatbee”. |
| `seatbee.url` | `string` | Link oznaczenia (strona Seatbee z `?ref=<slug Organizatora>`). |
| `seatbee.logoUrl` | `string` | Logo Seatbee (SVG), gdy zamiast napisu chcesz pokazać logo; `alt` = `seatbee.tekst`. |
| `logowanieUrl` | `string` | Jeden link „Zaloguj” dla wszystkich Kursantek: strona, która wysyła link do logowania. |
| `kursy` | `array` | Opublikowane Kursy, w kolejności dodania. Szkiców i usuniętych nie ma. |
| `kursy[].slug` | `string` | Stały adres Kursu. Po nim łączysz własną kartę lub podstronę z danymi (scenariusz B). |
| `kursy[].tytul` | `string` | Tytuł z Seatbee. |
| `kursy[].opis` | `string` | Opis jako zwykły tekst, bez formatowania (akapity rozdzielone pustą linią). Możesz go nie używać i pisać własne opisy. |
| `kursy[].url` | `string` | Strona Kursu w Seatbee, na której się go kupuje. Cel przycisku „Kup”. |
| `kursy[].okladkaUrl` | `string \| null` | Okładka z Seatbee (adres pliku, może być na innym serwerze) albo `null`. |
| `kursy[].cena` | `number \| null` | Cena brutto w zł obowiązująca teraz (także zaplanowana zmiana ceny). `0` = bezpłatny, `null` = jeszcze bez ceny. |
| `kursy[].cenaPoprzednia` | `number \| null` | Najniż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[].dostep` | `object \| null` | Jak 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.dni` | `number` | Tylko przy `okres`: liczba dni dostępu, zawsze obecna. |
| `kursy[].dostep.miesiace` | `number` | Tylko 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[].raty` | `object \| null` | Raty, tylko gdy strona Kursu je teraz oferuje; inaczej `null`. Pakiet nie ma rat. |
| `kursy[].raty.liczba` | `number` | Liczba rat. |
| `kursy[].raty.kwoty` | `number[]` | Kwoty kolejnych rat brutto w zł; sumują się do `cena`. |
| `kursy[].raty.url` | `string` | Podstrona „Kup na raty” w Seatbee. |
| `kursy[].lekcje` | `number` | Liczba Lekcji (Pakiet: suma jego Kursów). |
| `kursy[].czasMinuty` | `number` | Łączny czas filmów w minutach (Pakiet: suma). |
| `kursy[].wSprzedazy` | `boolean` | `false`, gdy teraz nie da się kupić (sprzedaż wstrzymana, „Sprzedaż wkrótce”, brak otwartego Startu). Wtedy wyłącz przycisk, nie ukrywaj karty. |
| `kursy[].listaOczekujacych` | `object \| null` | Lista 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.wlaczona` | `boolean` | Zawsze `true`, gdy obiekt jest. |
| `kursy[].listaOczekujacych.pola` | `object` | O 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.zgody` | `object` | Zgody w formularzu, do opisu na Twojej stronie (sam formularz jest w Seatbee). |
| `kursy[].listaOczekujacych.zgody.dokumenty` | `array` | Obowiązkowa akceptacja opublikowanych Dokumentów prawnych Organizatora. |
| `kursy[].listaOczekujacych.zgody.dokumenty[].rodzaj` | `"regulamin" \| "polityka-prywatnosci"` | Który dokument. |
| `kursy[].listaOczekujacych.zgody.dokumenty[].url` | `string` | Adres 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.wlasne` | `array` | Zgody własne Organizatora dla tej pozycji (może być pusta). |
| `kursy[].listaOczekujacych.zgody.wlasne[].tekst` | `string` | Treść zgody. |
| `kursy[].listaOczekujacych.zgody.wlasne[].url` | `string \| null` | Dokument zgody albo `null`. |
| `kursy[].listaOczekujacych.zgody.wlasne[].wymagana` | `boolean` | `true`: bez niej nie da się zapisać. |
| `kursy[].listaOczekujacych.url` | `string` | Formularz na stronie w Seatbee (z kotwicą `#lista-oczekujacych`). Cel przycisku „Zostaw e-mail”. |
| `kursy[].listaOczekujacych.ramkaUrl` | `string` | Formularz w ramce do `<iframe>` na Twojej stronie. Ładuje się tylko na stronach wpisanych w Marce w polu „Twoja strona www”. |
| `pakiety` | `array` | Opublikowane 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[].kursy` | `string[]` | 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ź:

```json
{
  "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`).

| Pole | Typ | Znaczenie |
| --- | --- | --- |
| `organizator` | `object` | Organizator, którego są dane. |
| `organizator.nazwa` | `string \| null` | Nazwa Organizatora; `null`, gdy jej nie wpisał. |
| `organizator.url` | `string` | Strona Organizatora w Seatbee (pod własną domeną, gdy jest aktywna). Link zapasowy. |
| `seatbee` | `object` | Oznaczenie Seatbee do pokazania przy liście (sekcja „Oznaczenie Seatbee”). |
| `seatbee.pokaz` | `boolean` | `true`: pokaż oznaczenie. `false`: nie pokazuj. |
| `seatbee.tekst` | `string` | Napis oznaczenia. |
| `seatbee.url` | `string` | Link oznaczenia. |
| `seatbee.logoUrl` | `string` | Logo Seatbee (SVG). |
| `wydarzenia` | `array` | Opublikowane, 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[].slug` | `string` | Stały adres Wydarzenia. Po nim pokazujesz jedno Wydarzenie w wybranym miejscu (scenariusz E). |
| `wydarzenia[].tytul` | `string` | Tytuł. |
| `wydarzenia[].opis` | `string` | Opis jako zwykły tekst. |
| `wydarzenia[].url` | `string` | Strona Wydarzenia w Seatbee ze wszystkimi Terminami. |
| `wydarzenia[].ramkaUrl` | `string` | Ramka Osadzenia tego Wydarzenia (do `<iframe>`), gdy wolisz gotowy widok Seatbee. |
| `wydarzenia[].okladkaUrl` | `string \| null` | Grafika Wydarzenia albo `null`. |
| `wydarzenia[].listaOczekujacych` | `object \| null` | Lista 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.wlaczona` | `boolean` | Zawsze `true`, gdy obiekt jest. |
| `wydarzenia[].listaOczekujacych.pola` | `object` | O 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.zgody` | `object` | Zgody w formularzu, do opisu na Twojej stronie (sam formularz jest w Seatbee). |
| `wydarzenia[].listaOczekujacych.zgody.dokumenty` | `array` | Obowiązkowa akceptacja opublikowanych Dokumentów prawnych Organizatora. |
| `wydarzenia[].listaOczekujacych.zgody.dokumenty[].rodzaj` | `"regulamin" \| "polityka-prywatnosci"` | Który dokument. |
| `wydarzenia[].listaOczekujacych.zgody.dokumenty[].url` | `string` | Adres 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.wlasne` | `array` | Zgody własne Organizatora dla tej pozycji (może być pusta). |
| `wydarzenia[].listaOczekujacych.zgody.wlasne[].tekst` | `string` | Treść zgody. |
| `wydarzenia[].listaOczekujacych.zgody.wlasne[].url` | `string \| null` | Dokument zgody albo `null`. |
| `wydarzenia[].listaOczekujacych.zgody.wlasne[].wymagana` | `boolean` | `true`: bez niej nie da się zapisać. |
| `wydarzenia[].listaOczekujacych.url` | `string` | Formularz na stronie w Seatbee (z kotwicą `#lista-oczekujacych`). Cel przycisku „Zostaw e-mail”. |
| `wydarzenia[].listaOczekujacych.ramkaUrl` | `string` | Formularz w ramce do `<iframe>` na Twojej stronie. Ładuje się tylko na stronach wpisanych w Marce w polu „Twoja strona www”. |
| `wydarzenia[].terminy` | `array` | Terminy, które się jeszcze nie zaczęły, po dniu i godzinie. Minione Terminy znikają same. |
| `wydarzenia[].terminy[].start` | `string` | Początek, ISO 8601 z przesunięciem Europe/Warsaw, np. `2026-11-20T18:00:00+01:00`. |
| `wydarzenia[].terminy[].koniec` | `string \| null` | Koniec 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[].miasto` | `string \| null` | Miasto albo `null`; „Online” przy Terminie online. |
| `wydarzenia[].terminy[].miejsce` | `string \| null` | Miejsce (sala, adres) albo `null`. |
| `wydarzenia[].terminy[].cena` | `number \| null` | Cena 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[].limit` | `number \| null` | Limit miejsc, tylko gdy Termin pokazuje wolne miejsca; inaczej `null`. |
| `wydarzenia[].terminy[].wolneMiejsca` | `number \| null` | Wolne miejsca, tylko gdy Termin je pokazuje; inaczej `null`. |
| `wydarzenia[].terminy[].wyprzedany` | `boolean` | `true`: brak miejsc. Pokaż „Brak miejsc”, przycisk wyłączony. |
| `wydarzenia[].terminy[].wSprzedazy` | `boolean` | `false` przy braku miejsc i gdy zapisy jeszcze nie ruszyły. Gdy `false`, a `wyprzedany` jest `false`: „Zapisy wkrótce”, przycisk wyłączony. |
| `wydarzenia[].terminy[].url` | `string` | Gdzie się zapisać: strona Terminu albo od razu płatność w Seatbee. Cel przycisku „Zapisz się”. |
| `wydarzenia[].terminy[].kod` | `string` | Stały identyfikator Terminu (ten sam co na końcu adresu jego strony). Po nim Zestaw w `pakiety[].terminy` wskazuje swoje Terminy. |
| `wydarzenia[].terminy[].listaOczekujacych` | `object \| null` | Lista 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.wlaczona` | `boolean` | Zawsze `true`, gdy obiekt jest. |
| `wydarzenia[].terminy[].listaOczekujacych.pola` | `object` | O 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.zgody` | `object` | Zgody w formularzu, do opisu na Twojej stronie (sam formularz jest w Seatbee). |
| `wydarzenia[].terminy[].listaOczekujacych.zgody.dokumenty` | `array` | Obowiązkowa akceptacja opublikowanych Dokumentów prawnych Organizatora. |
| `wydarzenia[].terminy[].listaOczekujacych.zgody.dokumenty[].rodzaj` | `"regulamin" \| "polityka-prywatnosci"` | Który dokument. |
| `wydarzenia[].terminy[].listaOczekujacych.zgody.dokumenty[].url` | `string` | Adres 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.wlasne` | `array` | Zgody własne Organizatora dla tej pozycji (może być pusta). |
| `wydarzenia[].terminy[].listaOczekujacych.zgody.wlasne[].tekst` | `string` | Treść zgody. |
| `wydarzenia[].terminy[].listaOczekujacych.zgody.wlasne[].url` | `string \| null` | Dokument zgody albo `null`. |
| `wydarzenia[].terminy[].listaOczekujacych.zgody.wlasne[].wymagana` | `boolean` | `true`: bez niej nie da się zapisać. |
| `wydarzenia[].terminy[].listaOczekujacych.url` | `string` | Formularz na stronie w Seatbee (z kotwicą `#lista-oczekujacych`). Cel przycisku „Zostaw e-mail”. |
| `wydarzenia[].terminy[].listaOczekujacych.ramkaUrl` | `string` | Formularz w ramce do `<iframe>` na Twojej stronie. Ładuje się tylko na stronach wpisanych w Marce w polu „Twoja strona www”. |
| `pakiety` | `array` | Opublikowane Pakiety wydarzeń: Zestawy i Karnety, od najstarszego. Szkiców i Pakietów z archiwum nie ma. Pakiety kursów są w `/kursy`. |
| `pakiety[].slug` | `string` | Stały adres Pakietu. |
| `pakiety[].tytul` | `string` | Nazwa 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[].opis` | `string` | Opis jako zwykły tekst. |
| `pakiety[].url` | `string` | Strona Pakietu w Seatbee, gdzie się go kupuje. Cel przycisku „Kup”. |
| `pakiety[].okladkaUrl` | `string \| null` | Okładka Pakietu albo `null`. |
| `pakiety[].cena` | `number` | Cena brutto w zł obowiązująca teraz (z wariantem cenowym, gdy trwa). |
| `pakiety[].cenaPoprzednia` | `number \| null` | Najniższa wcześniejsza cena do pokazania jako przekreślona (Omnibus), tylko gdy trwa wariant cenowy; inaczej `null`. |
| `pakiety[].waluta` | `"PLN"` | Waluta ceny. |
| `pakiety[].wSprzedazy` | `boolean` | `false`, 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[].terminy` | `string[] \| null` | Zestaw: `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[].wejscia` | `number \| null` | Karnet: liczba wejść. Zestaw: `null`. |
| `pakiety[].waznosc` | `object \| null` | Karnet: 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.dni` | `number` | Tylko przy `od-zakupu`: liczba dni od zakupu (przy miesiącach: dni Karnetu kupionego teraz). |
| `pakiety[].waznosc.miesiace` | `number` | Tylko przy `od-zakupu`, gdy Organizator ustawił ważność w miesiącach: dokładna liczba miesięcy. |
| `pakiety[].waznosc.dzien` | `string` | Tylko przy `do-dnia`: ostatni dzień ważności, `RRRR-MM-DD` (czas Europe/Warsaw). |
| `pakiety[].wydarzenia` | `string[] \| null` | Karnet: slugi jego Wydarzeń, w jego kolejności; tylko te, które są w `wydarzenia` tej odpowiedzi. Zestaw: `null`. |

Przykładowa odpowiedź:

```json
{
  "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ń

| Status | Treść | Co zrobić |
| --- | --- | --- |
| `200` | dane | pokaż 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: 60` | przekroczony 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 odpowiedzi | Co pokazuje strona |
| --- | --- |
| `slug` nie ma w `kursy` ani `pakiety` (nieopublikowany, usunięty, szkic) | karta znika |
| jest, `wSprzedazy: false`, `listaOczekujacych` nie jest `null` | karta zostaje, napis „Sprzedaż wkrótce” i przycisk „Zostaw e-mail” do `listaOczekujacych.url` (albo formularz w ramce) |
| jest, `wSprzedazy: false`, `listaOczekujacych: null` | karta zostaje, przycisk zakupu wyłączony, napis „Sprzedaż wkrótce” (Pakiet: według `stan`) |
| jest, `wSprzedazy: true` | karta z ceną i przyciskiem do `url` |
| wszystkie karty danego cennika zniknęły | zamiast 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 odpowiedzi | Co 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: true` | przycisk „Zapisz się” do `url`; wolne miejsca, gdy `wolneMiejsca` nie jest `null` |
| brak Wydarzeń w odpowiedzi | komunikat, np. „Nowe terminy wkrótce”, i link do `organizator.url` |
| zapytanie się nie udało | **nie** ukrywać; ostatnie znane dane albo link do `organizator.url` |

Pakiet wydarzeń (Zestaw albo Karnet):

| Stan w odpowiedzi | Co pokazuje strona |
| --- | --- |
| `slug` nie ma w `pakiety` (szkic, archiwum) | karta znika |
| jest, `wSprzedazy: false` | karta zostaje, przycisk wyłączony, napis według `stan`: „Brak miejsc” (`wyprzedany`), „Sprzedaż zakończona” (`zakonczona`), „Chwilowo niedostępny” (`niedostepny`) |
| jest, `wSprzedazy: true` | karta z ceną (i przekreśloną `cenaPoprzednia`, gdy nie jest `null`) i przyciskiem do `url` |
| `kod` z `terminy` Zestawu | Termin 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`.

- **Link**: przycisk „Zostaw e-mail” prowadzi do `listaOczekujacych.url` (formularz na stronie w Seatbee). Działa wszędzie.
- **Ramka**: formularz w Twojej stronie, w `<iframe>` z `listaOczekujacych.ramkaUrl`. Ramka ładuje się tylko na stronach wpisanych w panelu Seatbee: Marka → „Twoja strona www”. Sama dopasowuje wysokość, ma kolory Twojej Marki i po zapisie pokazuje „Dziękujemy, damy znać”. Po starcie sprzedaży ta sama ramka pokazuje przycisk do zakupu. Gotową linię kodu ramki znajdziesz w panelu, w Sprzedaży Kursu albo Wydarzenia, w karcie „Lista oczekujących”; to jedyna droga dla Wydarzenia bez przyszłego Terminu, którego nie ma w danych.
- Własnego formularza wysyłającego dane do Seatbee nie rób: zgody, limit prób i ochrona przed botami są w formularzu Seatbee. Pola `pola` i `zgody` służą do opisu na Twojej stronie (np. „podaj e-mail i imię”).

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):

```js
// 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).

- **A**: automatyczna lista wszystkich Kursów i Pakietów.
- **B**: cennik na własnej podstronie Kursu, dopasowany po `slug`.
- **C**: lista wszystkich Wydarzeń z Terminami.
- **D**: najbliższe N Terminów w sekcji strony.
- **E**: jedno Wydarzenie na landingu warsztatu, po `slug`.
- **F**: strona statyczna z budowaniem (SEO) i odświeżaniem w przeglądarce.

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`.

```html
<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>
```

- **Astro:** wklej kod do komponentu `.astro`; skrypt oznacz `<script is:inline type="module">`, żeby Astro go nie przerabiało.
- **Next.js:** przenieś skrypt do komponentu z `'use client'` i uruchom go w `useEffect`, albo pobieraj na serwerze z `fetch(ADRES, { next: { revalidate: 60 } })` (nigdy bez pamięci, patrz „Limit zapytań”).
- **WordPress:** wklej całość do bloku „Własny HTML” (Custom HTML) na stronie albo w szablonie.
- **Webflow:** wklej całość do elementu Embed (Code Embed) w miejscu sekcji.

### 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.

```html
<!-- 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>
```

- **Astro:** trzymaj warianty jako tablicę `{ nazwa, opis, seatbee: "kurs-online" }` i renderuj `<article data-seatbee={wariant.seatbee}>`; skrypt jak wyżej z `is:inline`.
- **Next.js:** to samo: tablica wariantów z polem `seatbee`, karty z `data-seatbee`, skrypt w komponencie z `'use client'` (`useEffect`) albo dane z serwera z `revalidate: 60`.
- **WordPress:** wklej cennik do bloku „Własny HTML”; każdy wariant musi mieć `data-seatbee`.
- **Webflow:** dodaj do karty wariantu atrybut własny `data-seatbee` (Element settings → Custom attributes), a skrypt wklej w Embed na dole strony.

### 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ą.

```html
<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>
```

- **Astro:** wklej kod do komponentu `.astro`; skrypt oznacz `<script is:inline type="module">`, żeby Astro go nie przerabiało.
- **Next.js:** przenieś skrypt do komponentu z `'use client'` i uruchom go w `useEffect`, albo pobieraj na serwerze z `fetch(ADRES, { next: { revalidate: 60 } })` (nigdy bez pamięci, patrz „Limit zapytań”).
- **WordPress:** wklej całość do bloku „Własny HTML” (Custom HTML) na stronie albo w szablonie.
- **Webflow:** wklej całość do elementu Embed (Code Embed) w miejscu sekcji.

### 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”.

```html
<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>
```

- **Astro:** wklej kod do komponentu `.astro`; skrypt oznacz `<script is:inline type="module">`, żeby Astro go nie przerabiało.
- **Next.js:** przenieś skrypt do komponentu z `'use client'` i uruchom go w `useEffect`, albo pobieraj na serwerze z `fetch(ADRES, { next: { revalidate: 60 } })` (nigdy bez pamięci, patrz „Limit zapytań”).
- **WordPress:** wklej całość do bloku „Własny HTML” (Custom HTML) na stronie albo w szablonie.
- **Webflow:** wklej całość do elementu Embed (Code Embed) w miejscu sekcji.

### 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.

```html
<!-- 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>
```

- **Astro:** wklej kod do komponentu `.astro`; skrypt oznacz `<script is:inline type="module">`, żeby Astro go nie przerabiało.
- **Next.js:** przenieś skrypt do komponentu z `'use client'` i uruchom go w `useEffect`, albo pobieraj na serwerze z `fetch(ADRES, { next: { revalidate: 60 } })` (nigdy bez pamięci, patrz „Limit zapytań”).
- **WordPress:** wklej całość do bloku „Własny HTML” (Custom HTML) na stronie albo w szablonie.
- **Webflow:** wklej w Embed; slug Wydarzenia wpisz w `data-seatbee` sekcji.

### 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.

```js
// 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 + ')')
  }
}
```

```html
<!-- 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>
```

- **Astro:** w pliku `.astro` czytaj `dane/seatbee-kursy.json` (albo `await fetch(ADRES)` w części serwerowej, wykonywane raz przy budowaniu) i renderuj karty z `data-seatbee`; skrypt odświeżający z `is:inline`.
- **Next.js:** w Server Component `await fetch(ADRES, { next: { revalidate: 60 } })`: strona odświeża dane najwyżej co minutę, nie przy każdym wejściu; do tego skrypt odświeżający w komponencie klienckim.
- **WordPress:** strona nie jest budowana, więc wystarczą scenariusze A–E. Gdy PHP ma pobierać dane na serwerze, trzymaj odpowiedź w transient na co najmniej 60 s.
- **Webflow:** strona nie jest budowana z danych; użyj scenariuszy A–E w Embed.

## 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.org | Pole Seatbee |
| --- | --- |
| `Course.name` / `description` / `url` / `image` | `tytul` / `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`, `url` | `cena`, `waluta`, `url` (bez `offers`, gdy `cena` jest `null`) |
| `Offer.availability` Kursu | `wSprzedazy: true` → `InStock`, `false` → `PreOrder` |
| `Event.name` / `description` / `image` | `wydarzenia[].tytul` / `opis` / `okladkaUrl` |
| `Event.startDate` / `endDate` | `terminy[].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 online | `format: "online"` → `OnlineEventAttendanceMode` i `{ "@type": "VirtualLocation", "url": url }`; inaczej `OfflineEventAttendanceMode` |
| `Event.organizer` (`Organization`) | `organizator.nazwa`, `organizator.url` |
| `Event.offers` (`Offer`): `price`, `priceCurrency`, `url` | `terminy[].cena`, `waluta`, `url` (bez `offers`, gdy `cena` jest `null`) |
| `Offer.availability` Terminu | `wyprzedany` → `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.

```js
// 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

- **Ręczne przepisywanie cen, rat albo Terminów** do treści strony. Po zmianie w Seatbee strona kłamie. Każdą liczbę i każdy link bierz z danych.
- **Ukrywanie przycisku albo karty zamiast wyłączenia** przy `wSprzedazy: false`. Karta zostaje z napisem „Sprzedaż wkrótce” albo „Zapisy wkrótce”; znika tylko to, czego nie ma w odpowiedzi.
- **Ukrywanie wszystkiego przy błędzie zapytania.** Pokaż ostatnie znane dane albo link do `organizator.url`.
- **Usuwanie albo chowanie oznaczenia Seatbee**, gdy `seatbee.pokaz` jest `true`.
- **Pytanie API na serwerze przy każdym wyświetleniu strony.** Wszystkie zapytania idą wtedy z jednego adresu i szybko trafią na 429. Pobieraj w przeglądarce albo trzymaj odpowiedź co najmniej 60 s.
- **Lista wpisana na stałe** (np. trzy karty z trzema slugami w scenariuszu A). Nowy Kurs się wtedy nie pojawi; w A renderuj w pętli to, co przyszło.
- **Wstawianie tekstu z danych jako HTML** (`innerHTML`). Używaj `textContent`.
- **Zgłaszanie „nie działa” zaraz po zmianie.** Pierwsza odpowiedź po zmianie może być jeszcze stara (patrz „Odświeżanie”).

## 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](https://seatbee.io/docs/integracja-strony), Markdown: [https://seatbee.io/docs/integracja-strony.md](https://seatbee.io/docs/integracja-strony.md).
