Zapis na zajęcia stałe dla powracających klientów
Klient, którego już znamy, nie musi przechodzić przez zajęcia próbne po raz drugi. W kalendarzu uczestnika ma przycisk, który otwiera ten sam wybór stałych terminów co konwersja po zajęciach próbnych — bez linku z SMS-a, bez weryfikacji telefonu i bez etapu próbnego.
Przebieg 4-etapowego kreatora zapisu (Wizard)
Proces zapisu na zajęcia stałe dla powracających klientów jest ujednolicony z procesem zapisu na zajęcia próbne i składa się z 4 czytelnych etapów (EnrollmentWizardShell):
- Krok 1: Wybór uczestnika (
participant):- Klient wybiera dziecko/uczestnika ze swojego konta, które posiada już przypisaną grupę/poziom przez trenera lub recepcję.
- Jeśli żaden z uczestników na koncie nie ma przypisanej grupy, opcja w menu jest nieaktywna (disabled).
- Jeśli co najmniej jeden uczestnik ma grupę, okno się otwiera i na liście wyświetlani są wyłącznie uczestnicy uprawnieni.
- Krok 2: Adres korespondencyjny (
address):- Wprowadzany raz na klienta, automatycznie predefiniowany z profilu użytkownika (
user_metadata). - Wyszukiwanie z rejestrów GUS / TERYT & PRG oraz kodów pocztowych dla wygody i bezbłędności.
- Wprowadzany raz na klienta, automatycznie predefiniowany z profilu użytkownika (
- Krok 3: Wybór terminu (
slots):- Wyświetlenie stałych terminów w przypisanych grupach z widocznymi odznakami poziomów, informacją o całym cyklu oraz możliwością zaznaczenia wielu grup (np. poniedziałek + czwartek).
- Opcja zgłoszenia „Żaden termin mi nie pasuje" z notatką dla recepcji.
- Krok 4: Płatność (
payment):- Podsumowanie wybranych serii i pierwszej opłaty miesięcznej, kod rabatowy, preferencje faktury (z NIP-em) oraz obowiązkowa akceptacja regulaminu i RODO z przekierowaniem do Przelewy24.
👤 Instrukcja dla recepcji i biura
Gdzie klient znajduje przycisk
Ścieżka: Zajęcia ➔ (uczestnik) — baner nad kalendarzem uczestnika.
Widok Zajęcia otwiera się domyślnie na zakładce Grupowe — to jest oferta, po
którą przychodzi zdecydowana większość klientów, i to tam siedzi zapis na zajęcia stałe.
Link z parametrem ?tab=group automatycznie inicjuje proces zapisu na stałe (startRegularEnrollment) i otwiera okno zapisu (lub wybór uczestnika). Parametry ?tab=individual oraz
?tab=vacation otwierają odpowiednio treningi indywidualne i tryb wakacyjny (tak wchodzą linki rejestracyjne z kampanii dla zalogowanych klientów).
Osobno działa ?tab=group-signup — parametr, z którym wchodzą linki rejestracyjne na
zajęcia grupowe. Nie wybiera okna z góry: uczestnikom, którzy nie mają jeszcze za sobą
zajęć próbnych, otwiera zapis na próbne, a tym, których trener przypisał już do grupy —
zapis na stałe. Reklama zajęć grupowych trafia bowiem i do klientów przed próbnymi, a
tym okno zapisu na stałe nie miałoby czego zaproponować (zapis na stałe wymaga przypisania
do grupy).
Pomiędzy tymi dwiema grupami jest trzecia: klient po próbnych, ale przed przypisaniem do
grupy. Dla niego parametr nie otwiera nic — zostaje na ekranie zajęć, gdzie baner
(awaitingGroupAssignment) mówi wprost, że klub dobiera grupę. Otwarcie okna zapisu na
stałe pokazałoby mu listę z każdym uczestnikiem wyszarzonym („uczestnik nie ma jeszcze
grupy”), czyli dokładnie ślepy zaułek, którego ten parametr ma unikać.
Parametr dokłada wyłącznie link kampanijny, który wskazał usługę. Zwykła rejestracja
konta (/auth/register, kreator bez tokenu) ląduje na gołym
/dashboard/user-activities, więc świeżo zarejestrowany klient nie dostaje okna
zapisu na twarz — sięga po nie sam, banerem lub przyciskiem nad kalendarzem.
Obok „Zapisz się na próbne zajęcia" pojawia się drugi przycisk „Zapisz się na zajęcia". Widać go tylko wtedy, gdy uczestnik ma już przypisany co najmniej jeden grupowy rodzaj zajęć. To przypisanie jest tym, z czego budowana jest lista terminów — bez niego nie ma czego zaproponować, więc przycisk się nie pokazuje.
Baner znika, gdy uczestnik ma już miejsce w zajęciach stałych — czyli figuruje jako
opłacony uczestnik przyszłego terminu z serii cyklicznej (getPlayersWithSeriesEnrollment).
Namawianie na zapis kogoś, kto co tydzień przychodzi na zajęcia, nic mu nie mówi. Sam
zapis pozostaje otwarty przyciskiem nad kalendarzem — dobranie kolejnego terminu jest
nadal możliwe, tyle że z inicjatywy klienta, a nie z banera.
Oba przyciski mają osobne warunki. Zaproszenie na próbne zależy od user_type
uczestnika: próbne przysługują typom newParticipant, newGuardian
i newParticipantAndGuardian, a uczestnik „powracający" dostaje wyłącznie zapis na
zajęcia stałe. Dlatego baner pokazuje się także wtedy, gdy jedynym dostępnym krokiem
jest zapis na zajęcia stałe.
Rekord uczestnika zakładany przy rejestracji konta dostaje user_type = 'newParticipant' — tak samo jak uczestnik dodany w kreatorze i uczestnik z landingu
zapisów na próbne. Wcześniej to pole zostawało puste i próbne były dla takiego klienta
zamknięte: picker wyszarzał go z powodem „Był(a) już na zajęciach próbnych", choć nigdy
na próbnych nie był. Puste user_type (rekordy zakładane przez recepcję, konta sprzed
migracji) nadal oznacza „próbne nie dotyczą" — ale picker mówi to teraz osobnym
komunikatem, zamiast przypisywać klientowi historię, której nie ma.
Przypisanie robi trener przy zajęciach próbnych (patrz Poziom uczestnika) albo recepcja ręcznie w profilu uczestnika.
Co widzi klient
To samo okno co przy konwersji z próbnych: cykliczne zajęcia w przypisanej grupie, z trenerem, kortem, dniem i godziną, liczbą wolnych miejsc i kwotą pierwszej płatności.
Różnice wobec konwersji z próbnych:
| Konwersja z próbnych | Powracający klient | |
|---|---|---|
| Wejście | link z SMS-a (/continue/{token}) | przycisk w kalendarzu |
| Logowanie | sesja mintowana z tokenu | klient jest już zalogowany |
| Zakres lokalizacji | adres, na którym odbyły się próbne | wszystkie adresy w mieście klienta |
| Faktura | tak, przed płatnością | tak, przed płatnością |
| Akceptacja regulaminu | wymagana przed płatnością | wymagana przed płatnością |
| Kod rabatowy | brak | tak, przed płatnością |
Zakres lokalizacji różni się celowo: przy próbnych wiadomo, gdzie klient był, a
powracający klient dopiero wybiera, gdzie chce grać. Miasto bierzemy z ustawienia
preferred_city na koncie klienta, a nie z żądania przeglądarki.
Zapis obejmuje cały cykl
Tak samo jak przy konwersji: klient nie wybiera pojedynczych zajęć, tylko wszystkie przyszłe wystąpienia wybranej serii, a płatność online obejmuje pierwszy pełny miesiąc. Miejsce musi być wolne we wszystkich zajęciach cyklu.
Reguła pełnego miesiąca (Minimum zajęć w pierwszej płatności) i blokada zapłaty za
pojedyncze zajęcia działają identycznie — opis w
Konwersji z zajęć próbnych.
Kod rabatowy, adres korespondencyjny i faktura
Przed przejściem do Przelewy24 klient może wpisać kod rabatowy, podać adres korespondencyjny oraz zaznaczyć, że chce fakturę (na osobę prywatną albo na firmę — z wyszukiwaniem danych po NIP).
Adres korespondencyjny (GUS / TERYT / PRG)
- Wprowadzany raz na klienta: Adres korespondencyjny jest powiązany z kontem klienta (
user/local_user). Po jednorazowym wypełnieniu podczas zapisu jest automatycznie zapamiętywany i podpowiadany przy kolejnych zapisach oraz widoczny w edycji profilu (/dashboard/user-profile). - Inteligentne autouzupełnianie: Pole wyszukiwania łączy się z oficjalnymi rejestrami państwowymi GUGiK / GUS TERYT & PRG oraz bazą kodów pocztowych:
- Wpisanie fragmentu nazwy miejscowości lub ulicy zwraca oficjalne podpowiedzi z rejestru GUS.
- Wpisanie kodu pocztowego (np.
45-809lub45809) automatycznie uzupełnia miejscowość i ulicę. - W razie niedostępności zewnętrznych rejestrów użytkownik zawsze może wprowadzić lub skorygować dane ręcznie.
- Wymagane pola: Miejscowość, ulica, numer budynku oraz kod pocztowy w formacie
XX-XXX(numer lokalu jest opcjonalny). Przycisk płatności pozostaje zablokowany do momentu uzupełnienia kompletnego adresu.
Kod jest w oknie tylko podglądany. Wiążąca weryfikacja dzieje się w
/api/payments/initialize na realnych płatnościach, więc kod, który przestał być ważny
między otwarciem okna a kliknięciem „Zapłać", zostanie odrzucony tam, a nie po cichu
uznany.
Dane do faktury zapisują się na uczestniku (tabela invoice) przed zapisem na
zajęcia — to je czyta webhook płatności, decydując między fakturą a paragonem.
Okno mówi o tym wprost: pod polami faktury jest informacja, że dane obejmą
wszystkich uczestników z konta i kolejne płatności, a zmienić je można w profilu.
NIP firmy jest sprawdzany sumą kontrolną (odrzucane są też numery z jednej
powtórzonej cyfry, np. 9999999999) — w oknie i ponownie w
/api/regular-enrollment (błąd invoice_tax_id_invalid). Bez tego zły numer
przechodził do Fakturowni, która odmawiała wystawienia dokumentu już po
opłaceniu zapisu — szczegóły w
Wystawianiu faktur.
Akceptacja regulaminu
Na kroku płatności klient musi zaznaczyć akceptację regulaminu zajęć i RODO. Do tego czasu przycisk „Zapłać" jest nieaktywny.
To nie jest wyłącznie blokada w interfejsie: /api/regular-enrollment odrzuca zapis bez
znacznika akceptacji błędem statute_required, więc żaden zapis na zajęcia stałe nie
powstanie bez zgody. Akceptacja zapisuje się w historii pierwszych zajęć cyklu jako
zdarzenie consent_accepted — po jednym wpisie na wybrany termin, widoczne tam, gdzie
recepcja czyta historię zajęć.
„Żaden termin mi nie pasuje"
Działa jak przy konwersji: zgłoszenie trafia jako zadanie na tablicę
(Dashboard ➔ Zadania) z kompletem kontekstu i notatką na leadzie. Etykieta źródła to
zapis powracającego klienta na zajęcia stałe, więc recepcja od razu wie, że to nie jest
klient po próbnych.
Kto może kogo zapisać
Opiekun zapisuje wyłącznie swoich uczestników. Recepcja i biuro (ADMIN,
BACKOFFICE) mogą zapisać dowolnego uczestnika, bo prowadzą ten sam zapis przez telefon.
W obu przypadkach płatność trafia na opiekuna uczestnika, nie na osobę klikającą. Gdyby zapis wykonała sesja recepcji, opiekun nigdy nie zobaczyłby tej płatności ani nie mógłby jej opłacić.
Ustawienia group_* — panel administracyjny
Ścieżka: Panel administracyjny ➔ Ustawienia zajęć stałych.
Ekran działa dokładnie tak jak „Ustawienia zajęć próbnych" i „Ustawienia zajęć wakacyjnych": ustawienia zapisujesz per miasto (globalny selektor miasta u góry decyduje, którą lokalizację edytujesz).
| Ustawienie | Klucz app_settings | Domyślnie | Co robi |
|---|---|---|---|
| Włącz samodzielny zapis | group_enrollment_enabled | włączone | Wyłączenie chowa przycisk i baner u klienta; recepcja zapisuje dalej |
| Natychmiastowa płatność (online) | group_immediate_payment_online | włączone | Zapis online przekierowuje na Przelewy24 i rezerwuje miejsce na czas płatności |
| Natychmiastowa płatność (pracownik) | group_immediate_payment_employee | wyłączone | To samo dla zapisu robionego przez recepcję |
| Czas na płatność online (min.) | group_payment_time_online | 10 | Jak długo trzymane jest miejsce dla nieopłaconego zapisu online |
| Czas na płatność pracownik (min.) | group_payment_time_employee | 10 | To samo dla zapisu przez pracownika |
| Czas na płatność (min.) | group_payment_time | 10 | Wartość zapasowa, gdy powyższe pole jest puste |
| Maksymalna liczba terminów | group_max_series | 7 (1–20) | Ile stałych terminów klient może wybrać w jednym zapisie |
Kilka rzeczy, które warto wiedzieć zanim coś przestawisz:
- Wyłączenie zapisu nie blokuje recepcji.
ADMINiBACKOFFICEwidzą terminy i zapisują dalej — przełącznik dotyczy wyłącznie samoobsługi klienta. Klient, który wejdzie ze starego linku?tab=group, zobaczy komunikat, żeby skontaktować się z recepcją, aPOST /api/regular-enrollmentodrzuci taki zapis błędemenrollment_disabled. Uwaga na to, gdzie recepcja zapisuje:/dashboard/user-activitiesi/api/regular-enrollmentsą kompilowane wyłącznie do workera klienckiego, więc ten kreator otwiera się z domeny klienta (sesja pracownika działa tam normalnie), a nie z panelu administracyjnego. - Natychmiastowa płatność pracownika ma zęby. Zapis z wymaganą płatnością jest
tymczasowy (
draft+paymentExpiresAt) i po upływie okna wypada z listy obecności (filterValidAttendees). Przy zapisie przez telefon, gdzie nikt nie płaci od razu, włączeniegroup_immediate_payment_employeeskasuje taki zapis po kilku minutach — dlatego domyślnie jest wyłączone. - O natychmiastowej płatności decydują wyłącznie ustawienia
group_*.payment_due_typerodzaju zajęć mówi, kiedy wystawiany jest rachunek za dany miesiąc (na grupachday_of_month, czyli 7. dnia miesiąca), i nie ma wpływu na to, jak długo trzymane jest miejsce. Do sierpnia 2026 hold wymagał dodatkowopayment_due_type = immediate, czego żadna grupa nie ma — przez co nie powstawał nigdy. - Puste pole „czas na płatność" oznacza brak limitu — miejsce nie wygasa samo i trzeba je zwolnić ręcznie.
- Maksymalna liczba terminów to realna decyzja biznesowa. Uczestnik trenujący dwa
razy w tygodniu potrzebuje co najmniej
2; ustawienie1zmusi go do dwóch osobnych zapisów (i dwóch płatności). Każdy wybrany termin to osobny zapis na cały cykl, dlatego wartość jest twardo ograniczona do 20. Kreator wygasza zaznaczanie po osiągnięciu limitu, a żądanie z większą liczbą terminów jest odrzucane, nie przycinane — po cichu obcięty termin oznaczałby, że klient płaci za inny tydzień niż wybrał.
W przeciwieństwie do zajęć próbnych i wakacyjnych rodzina group_* nie ma ustawień
„dni z wyprzedzeniem". Klient wybiera tu cotygodniowy termin z cyklu, a nie
konkretną datę, więc nie ma okna widoczności, które można by otworzyć na X dni.
🛠 Dokumentacja techniczna
Kluczowe moduły
| Plik | Rola |
|---|---|
lib/recurring-series-options.ts | Lista stałych terminów + przypisane grupy — wspólna dla obu wejść |
lib/game-availability-sql.ts | Definicja „zajętego miejsca" i strażnik seatAvailableSql używany w zapisie |
lib/recurring-enrollment.ts | Zapis na cały cykl + oznaczenie płatności monthly_only — wspólne |
lib/regular-enrollment.ts | Autoryzacja uczestnika, opcje i zapis dla zalogowanego klienta |
lib/group-activity-settings.ts | Typ, wartości domyślne i rozstrzyganie czasu na płatność dla ustawień group_* |
lib/actions/app-settings.ts | getGroupActivitySettings / updateGroupActivitySettings (cache per tenant) |
components/forms/group-activity-settings/ | Formularz ustawień w panelu administracyjnym |
lib/player-invoice-preferences.ts | Zapis preferencji faktury na uczestniku — wspólny z rejestracją na próbne |
lib/enrollment-consent.ts | Zapis akceptacji regulaminu w historii zajęć — wspólny |
app/api/regular-enrollment/route.ts | options / enroll / no_match |
lib/services/address-lookup.ts | Wyszukiwanie adresów w rejestrze GUGiK / GUS TERYT & PRG oraz kodów pocztowych |
app/api/address-search/route.ts | Endpoint API wyszukiwania i autouzupełniania adresów (?q=, ?postalCode=) |
components/forms/address/correspondence-address-card.tsx | Reużywalny komponent adresu z autouzupełnianiem GUS i kodów pocztowych |
components/forms/recurring-series/series-option-list.tsx | Karty terminów — wspólne dla obu okien |
components/forms/recurring-series/enrollment-payment-step.tsx | Krok płatności (adres, rabat, faktura, regulamin) — wspólny dla obu okien |
components/forms/regular-enrollment/ | Okno dwuetapowe: wybór terminu → płatność |
.../user-activities/[playerId]/components/EnrollBannerWrapper.tsx | Przycisk w banerze nad kalendarzem |
Migracje:
0200dodaje wierszroute_accessdla/api/regular-enrollment.0215dodaje kolumny adresu korespondencyjnego (addressStreet,addressBuildingNumber,addressApartmentNumber,addressPostalCode,addressCity) do tabeliuserilocal_useroraz uprawnienia do/api/address-search.0221dodaje typ powiadomieniaregular_enrollment_confirmed.0222dodaje typ powiadomieniaregular_enrollment_pending_payment.0226zasiewa ustawieniagroup_*(kategoria „Zajęcia stałe") dla każdego miasta i każdego tenanta, plus wierszALL.INSERT OR IGNORE, więc nie nadpisuje wartości ustawionych przez admina i można ją puścić ponownie. Lista domyślnych wartości to tabelaVALUESw CTE, a nie łańcuchUNION ALL— D1 ma niski limit liczby członów w compound SELECT i sześć z nich już go przekracza (too many terms in compound SELECT).
Kiedy klient dostaje potwierdzenie
enrollPlayerInSeries woła addPlayerToRecurringSeries z opcją
deferConfirmationUntilPaid, więc na tej ścieżce nie leci
player_added_to_recurring_series. Potwierdzenie regular_enrollment_confirmed (e-mail
oraz SMS) wychodzi dopiero z webhooka Przelewy24 po zaksięgowaniu pierwszego miesiąca —
szczegóły w Powiadomieniach systemowych.
Powód jest prosty: klient, który porzuci płatność, nie powinien dostać wiadomości
mówiącej, że jest zapisany. Dodanie uczestnika przez recepcję nie przechodzi przez
enrollPlayerInSeries i potwierdza tak jak wcześniej.
Żeby jednak nie zostawić klienta w ciszy, enrollPlayerInSeries wysyła od razu
regular_enrollment_pending_payment — e-mail „zapis przyjęty, czekamy na płatność" z
kwotą do zapłaty i, jeśli obowiązuje, godziną wygaśnięcia rezerwacji miejsca.
Co dzieje się dalej, zależy od ustawień group_* dla miasta kortu:
- z włączoną natychmiastową płatnością (
group_immediate_payment_onlinedla klienta,group_immediate_payment_employeedla recepcji) —addPlayerToRecurringSerieszapisuje uczestnika jakodraft: truei ustawiapayment_expires_atna okno z ustawień (group_payment_time_online,group_payment_time_employee, z zapasowymgroup_payment_time; puste = brak wygasania). Po tym czasieexpirePendingPaymentszwalnia miejsce i wysyłapayment_expired. Domyślnie zapis recepcji nie tworzy draftu, ale klub może to włączyć. - z wyłączoną — płatność czeka w zakładce Płatności bez terminu wygaśnięcia i ten e-mail jest jedynym sygnałem, jaki klient dostaje.
Kwota po wygaśnięciu zapisu
Zapis obejmuje cały cykl, więc addPlayerToRecurringSeries zakłada po jednej płatności
na każdy termin (38 terminów sezonu = 38 wierszy) i wszystkim nadaje ten sam
payment_expires_at. Do bramki płatniczej idzie jednak tylko pierwszy miesiąc. Gdy
zapis wygasa, unieważniane są wszystkie wiersze — ale komunikaty muszą mówić o tym, o co
klient był proszony, a nie o wartości całego sezonu.
Tym zajmuje się lib/series-upfront-billing.ts. Zapis jest identyfikowany trójką
uczestnik + seria + termin wygaśnięcia (wszystkie płatności jednego zapisu dzielą ten
sam payment_expires_at, więc druga próba zapisu do tej samej grupy nie miesza się z
pierwszą). Dla każdego takiego zapisu moduł doczytuje z bazy komplet jego płatności —
nie opiera się na tym, co akurat wygasło w danej turze, bo jeden przebieg crona bierze
maksymalnie 500 wierszy i potrafi przeciąć sezonowy zapis na pół. Na tym komplecie
odtwarza okno pierwszego miesiąca tą samą funkcją, którą liczy je koszyk
(selectFirstMonthOccurrences), i zwraca kwotę oraz liczbę terminów tej raty.
Regułę „minimalna liczba zajęć w pierwszym miesiącu"
(trial_conversion_min_first_month_sessions) obie strony czytają dla miasta kortu:
koszyk bierze je z kortu serii (selectFirstMonthPayments), a wygaszanie z payment.city,
które resolvePaymentLocation zapisuje z tego samego kortu. Wcześniej koszyk czytał je dla
miasta z sesji klienta — przy klubach z różnymi ustawieniami per miasto obie strony mogłyby
wskazać inny miesiąc.
Korzystają z tego dwa miejsca:
- SMS/push
payment_expired— podaje kwotę i liczbę terminów pierwszego miesiąca (np. „412,50 PLN, liczba terminów: 5"), a nie sumę całego cyklu, - wpis w Historii aktywności — każde zdarzenie
attendee_expireddostaje wparamspoladueAmountidueSessions, a scalony wpis pokazuje je w polu Do zapłaty było („412,50 zł za 5 terminów") zamiast sumować ceny wszystkich terminów. Nagłówek wpisu nadal mówi o wszystkich anulowanych terminach, więc obie liczby — zwolnione terminy i opłacane terminy — widać w jednym miejscu.
Płatności spoza serii (rezerwacja kortu, pojedyncze zajęcia) liczą się w całości, a gdy terminów nie da się odczytać, oba kanały wracają do zwykłej sumy — bezpieczniej podać kwotę zawyżoną niż zerową.
Serie bez opłaty potwierdzają natychmiast przy zapisie — nie powstaje żadna płatność, więc żaden webhook by tej wiadomości nie zwolnił.
Dlaczego to nie jest kopia konwersji
AP-649 miał odtworzyć flow z AP-646/647/648 dla innego wejścia. Zamiast kopiować,
wspólna część została wyciągnięta: zapytanie o terminy, wyliczenie pierwszego
miesiąca, oznaczanie płatności i karty w UI mają po jednej implementacji. lib/trial-followup-options.ts
i lib/trial-followup-enroll.ts zostały z tym, co faktycznie dotyczy zaproszenia:
tokenem, jego cyklem życia i mintowaniem sesji.
Konsekwencja praktyczna: zmiana zasad wyliczania pierwszego miesiąca albo tego, co znaczy „wolne miejsce", działa od razu na obu ścieżkach.
Zabezpieczenie przed podwójnym zapisem
Konwersja z próbnych ma token, który można „zająć" (completed_at). Tutaj takiego wiersza
nie ma, więc rolę strażnika pełni sama lista: zapytanie o terminy odrzuca serie, w których
uczestnik już jest (already_enrolled), a enrollInRegularSeries przelicza listę na
nowo i wymaga, żeby wybrana seria wciąż na niej była. Drugie kliknięcie dostaje
series_unavailable zamiast drugiego miesiąca płatności.
Dodatkowo addPlayerToRecurringSeries pomija zajęcia, w których uczestnik już figuruje, i
wystawia płatności wyłącznie za faktycznie dopisane wystąpienia.
Blokada zapisu na pełną grupę
Celem jest powiedzieć klientowi „nie ma miejsc" zanim zobaczy bramkę płatności, i nie dopuścić do sytuacji, w której zapłacił za grupę, do której się nie zmieścił.
Co widzi klient. Pełna grupa nadal jest na liście terminów — z czerwoną plakietką „Brak wolnych miejsc", wyszarzona i nieklikalna. Nie znika, bo znikający kafelek nie niesie żadnej informacji: klient szukający wtorkowej grupy zobaczyłby po prostu listę bez niej i nie wiedziałby, czy grupa jest pełna, czy coś się zepsuło. Jeśli miejsca skończą się już po otwarciu okna, zapis jest odrzucany komunikatem „Wszystkie miejsca w tej grupie są już zajęte. Wybierz inny termin.", lista odświeża się sama, a klient wraca do kroku wyboru terminu z odznaczonym niedostępnym terminem.
Trzy warstwy.
- Lista (
loadRecurringSeriesOptions) — liczyseats_leftjako minimum po wszystkich przyszłych wystąpieniach serii. Zapis obejmuje cały cykl, więc jedne pełne zajęcia w środku blokują całość. Wynik jest przycięty do zera, żeby ręcznie przepełniona przez recepcję grupa nie pokazała ujemnej liczby miejsc. - Weryfikacja przy wysłaniu (
enrollInRegularSeries,enrollFromFollowup) — lista jest przeliczana od nowa i każdy wybrany termin musi miećseats_left > 0. Obecność na liście nie jest dowodem dostępności; bramką jest liczba miejsc. - Strażnik przy zapisie (
addPlayerToRecurringSeries) —UPDATEkażdego wystąpienia ma warunekseatAvailableSql, liczony przez SQLite na wierszu sprzed zapisu. Dwa żądania walczące o ostatnie miejsce nie mogą wygrać oba: przegrany dostaje zero zmienionych wierszy, wystąpienia dopisane wcześniej w tym cyklu są cofane, a funkcja rzucaseries_full— przed utworzeniem jakiejkolwiek płatności.
Warstwa 3 jest tą, która faktycznie gwarantuje limit; warstwy 1 i 2 istnieją po to, żeby klient dowiedział się wcześniej i bez wchodzenia w płatność.
Istniał obok tego POST /api/games/recurring-attendance — endpoint workera
administracyjnego, który wołał addPlayerToRecurringSeries bez odroczenia potwierdzenia i
bez kroku płatności. Usunięty w sierpniu 2026: nie miał wywołań w UI ani w historii repo, a
w logach produkcyjnych liczba zapisów przez kreator pokrywała się co do jednego z liczbą
wszystkich zapisów na cykl. Po włączeniu blokady miejsca na zajęcia grupowe zostawiłby
uczestnika z wygasającym miejscem, którego nic nie potwierdza.
Skutek uboczny do świadomej akceptacji: addPlayerToRecurringSeries nie pozwala już
przepełnić serii nikomu, także recepcji. W grafiku pojedynczych zajęć pracownik nadal
może dodać osobę ponad limit (gameDialog.gameFull — „Limit osiągnięty. Dodajesz
dodatkową osobę."), bo tamta ścieżka nie przechodzi przez tę funkcję. Gdyby recepcja
potrzebowała przepełnić cały cykl, trzeba dołożyć jawny parametr pomijający strażnika —
nie znosić go.
Definicja „zajętego miejsca" jest jedna dla całego systemu (attendeeHoldsSeatSql):
uczestnik potwierdzony albo draft, którego okno płatności jeszcze nie minęło. Anulowani i
przeterminowane rezerwacje nie liczą się do limitu — dzięki temu nieopłacony zapis zwalnia
miejsce automatycznie.
Ta sama definicja obowiązuje w widokach dla pracownika. attendeeHoldsSeat
(lib/attendee-seat.ts) to jej odpowiednik w TypeScripcie i pilnuje, żeby licznik miejsc w
kalendarzu, dymek z listą uczestników i okno „Szczegóły zajęć" pokazywały ten sam skład
grupy. Osoba, której płatność właśnie trwa, jest widoczna na liście z etykietą „Oczekuje
płatności" — zajmuje miejsce, więc recepcja musi ją widzieć. Gdy okno płatności minie,
znika ze wszystkich trzech miejsc naraz.
Co się dzieje, gdy klient nie zapłaci w terminie
Zwolnienie miejsca nie zależy od crona. expirePendingPayments przestawia tylko status
płatności na expired i dopisuje wpis do historii zajęć — tablicy attendees nie rusza.
Miejsce zwalnia się leniwie, w chwili minięcia paymentExpiresAt na samym wpisie
uczestnika, więc działa nawet gdyby cron nie wstał. Nieświeży wpis znika fizycznie przy
najbliższym zapisie do tych zajęć (filterValidAttendees).
Ten sam predykat rozstrzyga pytanie „czy ten uczestnik już tu jest?" — already_enrolled w
liście terminów i getPlayersWithSeriesEnrollment. To jest istotne: gdyby liczenie miejsc i
sprawdzanie zapisu rozjechały się, miejsce byłoby wolne dla wszystkich oprócz osoby,
której właśnie przepadło — jedyna osoba pewnie zainteresowana ponownym zapisem widziałaby
„Brak dostępnych terminów". Dopóki hold jest żywy, seria dalej znika z listy tej osoby i to
jest właśnie zabezpieczenie przed podwójnym zapisem.
Uwaga konfiguracyjna: hold (draft + paymentExpiresAt) powstaje, gdy w ustawieniach
group_* dla miasta kortu włączona jest natychmiastowa płatność dla danej ścieżki zapisu,
zajęcia mają cenę większą od zera, a „Czas na płatność" nie jest pusty. Wyczyszczenie tego
pola wyłącza wygasanie: płatność nie dostaje payment_expires_at, cron jej nie widzi
(payment_expires_at IS NOT NULL), a miejsce jest zajęte bezterminowo.
Rezerwacja miejsca na czas płatności
addPlayerToRecurringSeries zapisuje uczestnika jako draft z paymentExpiresAt, gdy
płatność jest wymagana natychmiast. Do AP-649 okno tej rezerwacji brało się z ustawień
rezerwacji kortów (online_payment_time, z zapasowym 10) i dotyczyło wyłącznie roli
CLIENT. Teraz decydują ustawienia group_* dla miasta kortu:
- czy płatność jest natychmiastowa —
group_immediate_payment_onlinedla klienta,group_immediate_payment_employeedla recepcji, - ile trwa rezerwacja —
resolveGroupPaymentTime(): ustawienie szczegółowe, potem wspólnegroup_payment_time, anullw obu oznacza brak wygasania.
Oba warunki plus cena klienta (ta po uwzględnieniu indywidualnego cennika — ta sama,
którą naliczane są płatności) składa resolveEnrollmentHold()
w lib/group-activity-settings.ts — jedyne miejsce, które o holdzie decyduje.
Przedłużenie holdu przy wejściu do bramki
Gdy klient przechodzi do płatności, extendPaymentExpiration daje mu 15 minut od nowa.
Przedłużany jest cały cykl, nie tylko to, co jest w koszyku:
EXTEND_SERIES_PAYMENT_HOLD_SQL— wszystkie oczekujące płatności tej serii,EXTEND_SERIES_SEAT_HOLDS_SQL— wpisy uczestnika we wszystkich przyszłych instancjach,EXTEND_SEAT_HOLD_SQL— pojedyncze miejsce, dla płatności bez serii (wakacyjne, próbne).
Powód jest ten sam co przy AP-884: do bramki trafia tylko pierwszy miesiąc, a hold
obejmuje cały cykl. Przedłużenie samego koszyka zostawiłoby miesiące 2..N na pierwotnym
terminie — wygasłyby, gdy klient jeszcze płaci, a potem nie miałby ich już jak odzyskać:
CONFIRM_SERIES_SEATS_SQL odmawia potwierdzenia wygasłego holdu, a
CLEAR_SERIES_PAYMENT_HOLD_SQL rusza wyłącznie wiersze pending.
Terminy przesuwają się tylko do przodu, a hold, który zdążył wygasnąć, nie jest wskrzeszany — mógł go w międzyczasie zająć ktoś inny.
Wartości domyślne (online włączone, 10 minut, pracownik wyłączony) odtwarzają dotychczasowe
zachowanie, więc samo wdrożenie niczego nie zmienia. Klub, który miał ustawione własne
online_payment_time dla kortów, dostanie od teraz 10 minut na zajęciach stałych, dopóki
nie ustawi group_payment_time_online po swojemu.
Co się dzieje, gdy klient zapłaci (AP-884)
Zapis obejmuje cały cykl, ale do bramki płatności trafia tylko pierwszy miesiąc.
Wszystkie wystąpienia dostają jednak ten sam hold (draft + paymentExpiresAt), a
wszystkie wygenerowane płatności — ten sam payment_expires_at. Bez domknięcia zapisu po
opłaceniu pierwszego miesiąca dalsze miesiące zostawały draftami: cron
expirePendingPayments przestawiał je na expired kilka minut po tym, jak klient zapłacił,
a zajęcia znikały z jego kalendarza i listy „Zajęcia" (zwalniając przy okazji miejsce w
grupie).
Dlatego potwierdzenie płatności serii domyka cały zapis:
CONFIRM_SERIES_SEATS_SQL— zdejmujedraft(orazpaymentStartedAt/paymentExpiresAt) z wpisu uczestnika we wszystkich przyszłych, nieodwołanych wystąpieniach tej serii, ale tylko tam, gdzie hold jeszcze stoi — warunkiem jestattendeeHoldsSeatSql, ta sama definicja „ten uczestnik jest na zajęciach", której używa liczenie miejsc. Hold, który zdążył wygasnąć, zostaje nietknięty: miejsce wróciło do puli i mógł je już zająć ktoś inny, więc spóźnione rozliczenie (np. gotówka przyjęta po terminie) nie może po cichu przepełnić grupy na resztę cyklu. Ten sam warunek zostawia w spokoju wpistype = 'cancelled'tego uczestnika.CLEAR_SERIES_PAYMENT_HOLD_SQL— kasujepayment_expires_atna pozostałych oczekujących płatnościachschooltego uczestnika i tej serii. Zachowują własne terminy płatności i są ścigane jak każda inna zaległość, ale cron ich już nie unieważnia.
Oba zapytania siedzą w lib/series-enrollment-sql.ts, a wywołuje je
confirmRecurringSeriesEnrollment z clearGameExpiresAt — czyli z tego samego wąskiego
gardła, przez które przechodzi każde potwierdzenie płatności (online, portfel, gotówka w
klubie, płatność dzielona, rozliczenie masowe). Seria jest rozpoznawana po
related_ids[1], które zapisuje createPaymentsForRecurringSeries, więc płatność spoza
zapisu na cykl nie jest ruszana. Operacja jest idempotentna.
Zachowanie przy braku płatności się nie zmienia: nic nie zostaje potwierdzone, hold wygasa i miejsca wracają do puli (patrz sekcja wyżej).
Odświeżanie listy „Zajęcia" po stronie klienta
Next trzyma payload dynamicznej trasy w cache routera przez staleTimes.dynamic (30 s),
więc wejście na „Zajęcia" z innej zakładki potrafiło pokazać migawkę sprzed zapisu.
useAutoRefresh (hooks/useAutoRefresh.ts) w UserActivities odświeża dane serwerowe w
trzech momentach:
- powrót na tę samą trasę w obrębie jednego dokumentu — pierwsze wejście zawsze idzie
po dane do serwera (trasa dynamiczna z
loading.tsxjest prefetchowana tylko jako szkielet), więc dopiero ponowne wejście może dostać payload z cache'u routera, - powrót strony na wierzch —
visibilitychange,focus, przywrócenie z bfcache; nie częściej niż raz na 10 s, - upływ terminu płatności widocznego na stronie — wpis wygaszony przez serwer znika
razem z banerem odliczania, zamiast czekać na ręczne
F5. Terminy są odduplikowane (zapis na cykl daje jedną płatność na wystąpienie, wszystkie z tym samym terminem — bez tego jeden zapis oznaczałby kilkanaście równoczesnych odświeżeń). Jeśli po odświeżeniu serwer dalej zwraca ten sam termin — bo zegar urządzenia się spieszy — hook dopytuje jeszcze do czterech razy co 15 s, a potem czeka na powrót strony na wierzch.
Autoryzacja
Endpoint wymaga sesji (getAuthSession), waliduje CSRF i jest limitowany (20 żądań/min).
Właściciel jest rozstrzygany zapytaniem po player.owner_email, a nie po tym, co przyszło
w żądaniu — playerId z ciała żądania nie daje dostępu do cudzego uczestnika.
Miasto pochodzi z sesji (preferred_city), nie z żądania: lista terminów i ustawienia
pierwszego miesiąca są per lokalizacja, więc przeglądarka nie może wskazać klubu, którego
klient nie wybrał.