Przejdź do głównej zawartości

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.

UstawienieDomyślnieZnaczenie
Wysyłaj zaproszenie po zajęciach próbnychwłączoneWyłącza cały mechanizm dla miasta
Opóźnienie wysyłki (minuty)60Ile czasu po przypisaniu poziomu wychodzi wiadomość
Ważność linku (dni)14Po tym czasie link przestaje działać
Minimum zajęć w pierwszej płatności2Patrz 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:

KolumnaZnaczenie
token32-znakowy hex w linku; unikalny
send_afterKiedy najwcześniej wysłać (stemplowane przy zapisie, nie liczone przez cron)
sent_atWypełniane przed wysyłką, żeby awaria workera nie powtórzyła SMS-a
opened_atPierwsze wejście w link (dla CRM)
enrolled_series_id, completed_atDomknięta konwersja
cancelled_atUczestnik przestał się kwalifikować przed wysyłką
expires_atWyliczane z trial_followup_link_ttl_days

payment.monthly_only (migracja 0199) — 1 na każdej płatności z konwersji.

Kluczowe moduły

PlikRola
lib/trial-followup.tsKolejka zaproszeń — syncTrialFollowups
lib/trial-followup-send.tsCron */15 * * * *, gate trial_followup_invite
lib/trial-followup-options.tsLista terminów + openTrialFollowup (sesja z tokenu)
lib/trial-followup-billing.tsselectFirstMonthOccurrences — okno pierwszej płatności
lib/trial-followup-enroll.tsenrollFromFollowup — zapis, faktura, oznaczenie płatności
lib/enrollment-consent.tsZapis akceptacji regulaminu w historii zajęć — wspólny
components/forms/recurring-series/enrollment-payment-step.tsxKrok płatności — wspólny z zapisem powracającego klienta
lib/monthly-payment-guard.tsBlokada częściowej zapłaty miesiąca
lib/reception-request.tsZgłoszenie „brak terminu" → tickets + lead
app/api/public/trial-followup/route.tsopen / 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:

  1. middleware.ts traktuje /continue jako stronę publiczną, więc pierwsze wejście bez ciasteczka nie odbija na /auth/login.
  2. Strona renderuje się i od razu woła open.
  3. openTrialFollowup mintuje sesję Better Auth dla player.owner_email.
  4. 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.