Konwersja z zajęć próbnych na stałe
Po zajęciach próbnych klient dostaje SMS z linkiem, wybiera stały termin w grupie dobranej przez trenera i opłaca pierwszy pełny miesiąc. Ten dokument opisuje całą ścieżkę — od przypisania poziomu do opłaconego zapisu.
Poprzedni krok opisuje Poziom uczestnika zajęć próbnych. Ten sam wybór terminu, ale bez etapu próbnego, opisuje Zapis na zajęcia stałe dla powracających klientów. Skuteczność tej ścieżki — ogółem, per lokalizacja i per trener, dla dowolnego zakresu dat — pokazuje raport Konwersja próbnych.
Przebieg
👤 Instrukcja dla recepcji i biura
Co się dzieje automatycznie
Godzinę po tym, jak trener zamknie obecność i przypisze poziom, klient dostaje SMS-a i e-mail z linkiem. Nikt nie musi tego uruchamiać ręcznie.
Wiadomość dostaje wyłącznie uczestnik, który:
- był obecny na zajęciach próbnych, oraz
- ma przypisany co najmniej jeden poziom.
Nieobecny nie dostaje nic — nie ma przypisanego poziomu, więc nie ma czego mu zaproponować. Jeśli trener poprawi obecność albo wyczyści poziom, zanim wiadomość wyjdzie, zaproszenie zostaje anulowane. Wiadomość już wysłana nie jest cofana.
Co widzi klient
Link otwiera aplikację (albo przeglądarkę) i od razu pokazuje okno wyboru terminu. Klient nie musi znać hasła — link go loguje.
W oknie widzi tylko cykliczne zajęcia w grupie dobranej przez trenera, z trenerem, kortem, dniem i godziną. Przy każdym terminie jest liczba wolnych miejsc i kwota pierwszej płatności.
:::note Wiek nie ogranicza listy Lista nie jest zawężana przedziałem wiekowym rodzaju zajęć. Decyduje wyłącznie grupa, którą wskazał trener — to on widział uczestnika na korcie i to jego ocena ma pierwszeństwo przed widełkami wieku. :::
Zapis obejmuje cały cykl
Klient nie wybiera pojedynczych zajęć. Zapis obejmuje wszystkie przyszłe wystąpienia wybranej serii, a płatność online — pierwszy pełny miesiąc. Kolejne miesiące klient rozlicza normalnie w zakładce Płatności.
Miejsce musi być wolne we wszystkich zajęciach cyklu — jeżeli choć jedne są zapełnione, termin nie pojawia się na liście.
Krok płatności: faktura i regulamin
Po wybraniu terminu klient trafia na krok płatności — ten sam ekran co przy zapisie powracającego klienta, z podsumowaniem pierwszego miesiąca.
Klient może tu zaznaczyć, że chce fakturę (na osobę prywatną albo na firmę — z
wyszukiwaniem danych po NIP). Dane zapisują się na uczestniku (tabela invoice) przed
zapisem na zajęcia, bo to je czyta webhook płatności, decydując między fakturą a
paragonem.
Przed przejściem do Przelewy24 trzeba zaznaczyć akceptację regulaminu zajęć i RODO.
Bez zaznaczenia przycisk „Zapłać" jest nieaktywny, a serwer odrzuca zapis bez
znacznika akceptacji (statute_required) — checkbox nie jest tylko ozdobą interfejsu.
Akceptacja zapisuje się w historii pierwszych zajęć cyklu jako zdarzenie
consent_accepted.
Kodów rabatowych ta ścieżka nie oferuje — zaproszenie po próbnych ma z góry ustaloną kwotę pierwszego miesiąca.
Ekran „Zapis gotowy — została płatność" (przerwana płatność, patrz niżej) też wymaga akceptacji regulaminu przed dokończeniem płatności.
„Żaden termin mi nie pasuje"
Klient może wysłać zgłoszenie i opisać, jakie godziny by mu odpowiadały. Zgłoszenie
trafia jako zadanie na tablicę (Dashboard ➔ Zadania) z kompletem kontekstu:
uczestnik, wiek, telefon, przypisane poziomy, lokalizacja i link do leada. Dodatkowo
notatka dopisuje się do leada.
Rejestracja klienta nie jest blokowana — dostaje potwierdzenie, że recepcja się odezwie.
Czego klient nie może zrobić
Zapisu z konwersji nie da się opłacić po jednych zajęciach. W zakładce Płatności takie pozycje nie mają własnego przycisku „Zapłać" — rozlicza się je łącznie, całym miesiącem. Dotyczy to również kolejnych miesięcy tego zapisu.
⚙️ Ustawienia
Ścieżka: Dashboard ➔ Ustawienia zajęć próbnych
Wszystkie ustawienia są per miasto.
| Ustawienie | Domyślnie | Znaczenie |
|---|---|---|
| Wysyłaj zaproszenie po zajęciach próbnych | włączone | Wyłącza cały mechanizm dla miasta |
| Opóźnienie wysyłki (minuty) | 60 | Ile czasu po przypisaniu poziomu wychodzi wiadomość |
| Ważność linku (dni) | 14 | Po tym czasie link przestaje działać |
| Minimum zajęć w pierwszej płatności | 2 | Patrz niżej |
Minimum zajęć w pierwszej płatności
„Pełny miesiąc" to domyślnie wszystkie zajęcia od dziś do końca bieżącego miesiąca kalendarzowego. Przy konwersji pod koniec miesiąca zostawałyby jedne zajęcia albo żadne, a klient płaciłby 70 zł zamiast za miesiąc — czyli dokładnie to, czemu ten mechanizm ma zapobiegać.
Dlatego jeśli w bieżącym miesiącu zostało mniej wystąpień niż ta wartość, pierwsza
płatność obejmuje resztę bieżącego miesiąca razem z kolejnym pełnym miesiącem.
Wpisanie 0 wyłącza to zachowanie.
🛠 Dokumentacja techniczna
Model danych
trial_followup (migracja 0198) — jeden wiersz na uczestnika i zajęcia próbne:
| Kolumna | Znaczenie |
|---|---|
token | 32-znakowy hex w linku; unikalny |
send_after | Kiedy najwcześniej wysłać (stemplowane przy zapisie, nie liczone przez cron) |
sent_at | Wypełniane przed wysyłką, żeby awaria workera nie powtórzyła SMS-a |
opened_at | Pierwsze wejście w link (dla CRM) |
enrolled_series_id, completed_at | Domknięta konwersja |
cancelled_at | Uczestnik przestał się kwalifikować przed wysyłką |
expires_at | Wyliczane z trial_followup_link_ttl_days |
payment.monthly_only (migracja 0199) — 1 na każdej płatności z konwersji.
Kluczowe moduły
| Plik | Rola |
|---|---|
lib/trial-followup.ts | Kolejka zaproszeń — syncTrialFollowups |
lib/trial-followup-send.ts | Cron */15 * * * *, gate trial_followup_invite |
lib/trial-followup-options.ts | Lista terminów + openTrialFollowup (sesja z tokenu) |
lib/trial-followup-billing.ts | selectFirstMonthOccurrences — okno pierwszej płatności |
lib/trial-followup-enroll.ts | enrollFromFollowup — zapis, faktura, oznaczenie płatności |
lib/enrollment-consent.ts | Zapis akceptacji regulaminu w historii zajęć — wspólny |
components/forms/recurring-series/enrollment-payment-step.tsx | Krok płatności — wspólny z zapisem powracającego klienta |
lib/monthly-payment-guard.ts | Blokada częściowej zapłaty miesiąca |
lib/reception-request.ts | Zgłoszenie „brak terminu" → tickets + lead |
app/api/public/trial-followup/route.ts | open / options / enroll / no_match |
app/continue/[token]/ | Publiczna strona z dialogiem |
Trigger kolejki siedzi w syncTrialLevels (lib/actions/trial-level.ts) — to jedyne
miejsce, które w jednym momencie zna uczestników próbnych, obecność i finalny zestaw
poziomów.
Logowanie z linku
Klient nie loguje się ręcznie — nie widzi ekranu logowania i nie podaje hasła. Kolejność jest taka:
middleware.tstraktuje/continuejako stronę publiczną, więc pierwsze wejście bez ciasteczka nie odbija na/auth/login.- Strona renderuje się i od razu woła
open. openTrialFollowupmintuje sesję Better Auth dlaplayer.owner_email.- Od tego momentu klient jest zalogowany jako opiekun — dialog, zapis i płatność lecą normalnymi, uwierzytelnionymi ścieżkami.
Jeśli w tej przeglądarce ktoś był już zalogowany, mintowana sesja go zastępuje: link dotyczy konkretnego uczestnika i to jego opiekun ma dokończyć zapis.
:::warning Opiekun bez konta
Gdy dla player.owner_email nie ma konta, token nie ma kogo zalogować. Flow zatrzymuje
się od razu ze stanem no_account i komunikatem, żeby zadzwonić do recepcji — zamiast
zapisać uczestnika na cały cykl i zostawić go z płatnościami, których nie da się opłacić
(/api/payments/initialize wymaga sesji i odbiłby na stronę logowania).
Dotyczy to głównie graczy zakładanych ręcznie przez recepcję. Uczestnik, który przeszedł publiczną rejestrację z AP-634, konto ma zawsze — powstaje przy weryfikacji SMS. :::
Autoryzacja linku
Token wymienia się na zwykłą sesję Better Auth właściciela konta uczestnika
(lib/session-mint.ts, ta sama ścieżka co logowanie kodem SMS). Dzięki temu płatność
idzie istniejącym, zalogowanym endpointem /api/payments/initialize zamiast publicznej
kopii integracji P24.
Link działa do wygaśnięcia, a nie jednorazowo: klient wychodzi do Przelewy24 i wraca,
odświeża stronę albo otwiera ją na drugim urządzeniu. Zakres ogranicza expires_at
oraz to, że sesja powstaje wyłącznie dla właściciela tego jednego uczestnika. Po
domknięciu konwersji (completed_at) token nie mintuje już sesji.
Wymuszenie miesiąca
Mechanizm miesięczny istniał wcześniej — payment_frequencies na activity_types
(migracja 0124) i grupowanie w lib/utils/payment-frequency.ts. Konwersja nie
przestawia konfiguracji rodzaju zajęć, bo to zmieniłoby zasady wszystkim jego klientom.
Zamiast tego oznacza konkretne płatności flagą monthly_only, którą honorują
isMonthlyPayment i allowsOneTimePayment.
Realnym egzekwowaniem jest guard w /api/payments/initialize: odrzuca zestaw zawierający
płatność monthly_only bez kompletu rodzeństwa z tego samego miesiąca (klucz: gracz +
seria + miesiąc kalendarzowy w Europe/Warsaw). Ukrycie przycisku w UI to tylko warstwa
prezentacji.
Testowanie lokalnie
Cron drenujący kolejkę działa wyłącznie na workerze admin, więc lokalny next dev
nigdy sam nic nie wyśle. Do testów służy flaga w .env.local:
TRIAL_FOLLOWUP_INSTANT=true
Z nią zaproszenie idzie od razu po zapisaniu obecności z poziomem (bez opóźnienia z ustawień), a wygenerowany link ląduje w logu serwera:
INFO [queueTrialFollowups]: Trial follow-up dispatched instantly
{ gameId: 42, queued: 1, sent: 1, links: [ 'http://localhost:3000/continue/…' ] }
Wystarczy skopiować link z konsoli — nie trzeba czekać na SMS-a, którego lokalne środowisko i tak zwykle nie dostarczy.
Flaga jest zablokowana przy NODE_ENV=production niezależnie od wartości zmiennej, żeby
skopiowany plik env nie skasował opóźnienia na produkcji. Linki zbierane są do wyniku
tylko przy włączonej fladze — to tokeny na okaziciela i nie mają czego szukać w logach
produkcyjnych.
Przerwana płatność
Zapis do serii nie jest wycofywany po porzuceniu płatności. Płatności zostają jako
pending i wpadają w istniejące przypomnienia oraz wygaszanie. completed_at gwarantuje,
że powrót na stronę nie zapisze uczestnika po raz drugi; nieudany zapis zwalnia
zaproszenie z powrotem, żeby klient mógł spróbować ponownie.
Ponowne wejście w link po dokonanym zapisie nie jest ślepym zaułkiem: strona rozpoznaje stan „zapisany, nieopłacony" i pokazuje przycisk Dokończ płatność, który wznawia hand-off do Przelewy24 na tych samych płatnościach. Token mintuje sesję również w tym stanie — bez zalogowanego opiekuna nie da się zapłacić.
Kto jest obciążany
addPlayerToRecurringSeries ustawia payment.user_email na osobę aktualnie
zalogowaną. Przy recepcji dodającej gracza ręcznie to bez znaczenia, ale w tym flow
byłoby błędem: /api/payments/initialize odrzuca płatności należące do kogoś innego, a
getPaymentsByIds rozstrzyga własność po właścicielu gracza. Gdyby więc zapis wykonała
sesja trenera (np. link otwarty w przeglądarce, w której trener sprawdzał obecność),
opiekun nigdy by tej płatności nie zobaczył ani nie opłacił.
Dlatego konwersja jawnie przepisuje user_email na player.owner_email — i robi to
zarówno przy zapisie, jak i przy wznowieniu płatności, żeby naprawić także zapisy
powstałe wcześniej.