Linki rejestracyjne na zajęcia próbne
Publiczne, tokenowe linki do samodzielnego zapisu na zajęcia próbne, które umieszcza się na stronie WWW lub w reklamie, wraz ze statystykami wejść i konwersji na leada.
👤 Instrukcja dla pracownika (Marketing / Biuro)
Po co to jest
Zamiast prosić klienta o telefon do recepcji, publikujesz jeden link na stronie WWW, w poście lub w reklamie. Każdy, kto w niego kliknie, trafia na stronę zapisu na zajęcia próbne. System liczy, ile osób weszło w link i ilu z nich faktycznie zaczęło się zapisywać — dzięki temu wiadomo, która strona i która kampania realnie przynosi zapisy.
Link jest wielorazowy — jeden adres obsługuje dowolną liczbę klientów.
Jak wygenerować link
- Wejdź w
Dashboard ➔ Statystyki ➔ Linki rejestracyjne. - Kliknij Wygeneruj link.
- Uzupełnij:
- Nazwa — do czego link służy, np. „Strona główna – zajęcia próbne”. Widzisz ją tylko Ty, w tabeli.
- Opis dla klienta — tekst wyświetlany klientowi na stronie zapisu. Puste pole = tekst domyślny.
- Kanał pozyskania — kanał, którym zostaną oznaczone leady z tego linku (Google, Meta, inne online, offline, polecenie).
- Kampania — dowolna etykieta do rozróżniania akcji, np.
landing-wrzesien. - Miasto — lokalizacja, do której link zapisuje. „Wybiera klient” robi link ogólnopolski: klient sam wskaże miasto na pierwszym ekranie.
- Usługa — „Zajęcia grupowe” albo „Rezerwacje” prowadzi prosto do właściwego formularza, z pominięciem kafelków. „Wybiera klient” pokazuje pełną ofertę.
- Rodzaj zajęć — zawęża zajęcia grupowe do jednego typu. Nieaktywne, gdy usługą są rezerwacje — wynajem kortu nie ma typu zajęć.
- Wygasa — zostaw puste, jeśli link ma działać bezterminowo (typowe dla linku na stronie WWW). Datę ustaw tylko dla akcji ograniczonych czasowo; link działa do końca wybranego dnia (23:59 czasu polskiego). Ikona ✕ obok kalendarza czyści datę z powrotem na bezterminowo.
- Zapisz. W tabeli pojawi się gotowy adres — skopiuj go ikoną 📋 i przekaż osobie prowadzącej stronę WWW lub wklej w reklamę.
Jak czytać statystyki
| Kolumna | Znaczenie |
|---|---|
| Wejścia | Ile razy otwarto stronę zapisu z tego linku. |
| Unikalni | Ilu różnych użytkowników weszło (jedna przeglądarka = jeden użytkownik). |
| Rozpoczęte | Ile osób kliknęło „Rozpocznij zapis”. |
| Leady | Ile zapisów utworzyło leada w CRM. |
| Konwersja | Leady ÷ wejścia. |
| Ostatnie wejście | Kiedy ktoś ostatnio kliknął w link. |
Odświeżenie strony przez tę samą osobę w tej samej sesji przeglądarki nie zawyża licznika wejść.
Wyłączanie i usuwanie
- Przełącznik „Aktywny” — wyłącza link, zachowując statystyki. Klient zobaczy komunikat o nieprawidłowym linku. Tego używaj na co dzień.
- Usuń — kasuje link razem z całą historią statystyk, bezpowrotnie. Używaj tylko dla linków utworzonych przez pomyłkę.
Link z ustawioną datą wygaśnięcia po jej minięciu sam przestaje działać i pokazuje klientowi komunikat „Link wygasł”.
Co widzi klient po wejściu w link (AP-636)
Po otwarciu linku klient dostaje trzy kafelki z rodzajami usług:
| Kafelek | Stan |
|---|---|
| Zajęcia grupowe | Aktywny, jeśli w mieście linku jest wolne miejsce na zajęciach grupowych w oknie widoczności zajęć próbnych. Bez wolnych miejsc kafelek jest wyszarzony z etykietą „Brak wolnych terminów”, a pod kafelkami pojawia się prośba o kontakt mailowy. |
| Zajęcia indywidualne | Wyszarzony, etykieta „Wkrótce” — flow powstanie w kolejnym etapie. |
| Rezerwacje | Wyszarzony, etykieta „Wkrótce” — flow powstanie w kolejnym etapie. |
Kliknięcie kafelka to moment liczony w statystykach jako Rozpoczęte.
Jeśli link ma ustawioną Usługę albo Rodzaj zajęć, ekran kafelków jest pomijany — klient od razu widzi, na co się zapisuje, i przycisk „Rozpocznij zapis”. Lista rodzajów zajęć zawiera wyłącznie aktywne zajęcia grupowe; rezerwacje kortu wskazuje się polem „Usługa”, bo nie mają typu zajęć.
Miasto wybiera się nad kafelkami, nie na osobnym ekranie, i tylko wtedy, gdy jest z czego wybierać: link z ustawionym miastem ani klub w jednej lokalizacji nie pokazują tego przełącznika. Raz wskazana lokalizacja nie jest już pytana w formularzu danych.
Kroki formularza (AP-637)
Po wyborze kafelka klient przechodzi kreator z paskiem kroków u góry: Usługa ➔ Opiekun ➔ Uczestnicy ➔ E-mail ➔ Telefon ➔ Termin ➔ Płatność. Na telefonie etykiety pod kółkami się nie mieszczą, więc poniżej paska pokazujemy tylko podpis bieżącego kroku („Krok 5 z 7 — Telefon”).
-
Opiekun — imię, nazwisko, adres e-mail (wymagany), numer telefonu w formacie
+48XXXXXXXXXoraz jedna zgoda obejmująca regulamin zajęć i klauzulę RODO. Lokalizacja padła już przy kafelkach, więc tu się jej nie powtarza. Pola niosą atrybutyautocomplete(given-name,family-name,email,tel), żeby przeglądarka wypełniła je jednym tapnięciem. Zmiana numeru telefonu unieważnia wcześniejszą weryfikację SMS, a zmiana adresu e-mail — weryfikację adresu (porównanie jest bez rozróżniania wielkości liter, więcAnna@ianna@to ten sam adres i potwierdzenie zostaje). -
Uczestnicy — na górze przełącznik „Ja też będę grać”: właściciel konta bardzo często sam wchodzi na kort, więc zapisuje się jednym kliknięciem (imię i nazwisko bierzemy z poprzedniego kroku, klient podaje tylko datę urodzenia). Dodatkowi uczestnicy — np. dzieci — to osobne karty z imieniem, nazwiskiem i datą urodzenia; można ich w ogóle nie dodawać. Jednym zapisem obsługujemy do 5 uczestników łącznie; termin dla każdego wybiera się osobno w kroku „Termin”, bo różny wiek oznacza różne grupy.
Włączenie przełącznika, gdy jedyna dodatkowa karta jest jeszcze pusta, usuwa tę kartę — inaczej pusty formularz blokowałby przejście dalej. Daty urodzenia wybiera się wspólnym komponentem
DatePicker(kalendarz z listami miesiąca i roku), tym samym, którego używa reszta aplikacji. -
E-mail — weryfikacja adresu 6-cyfrowym kodem wysłanym na ten adres. Kod idzie szablonem
mails/registration-code.htmli jest ważny 15 minut (okno OTP;verifyDeterministicOtpprzyjmuje też okno poprzednie, więc w praktyce 15–30 minut). Krok stoi przed SMS-em z dwóch powodów: literówka w adresie wychodzi na jaw zanim wydamy SMS-a, a konto zakładane później dostajeemailVerified = 1— bez tego zaproszenie do ustawienia hasła trafiałoby donikąd, a późniejsze logowanie Google nie mogłoby się podpiąć pod ten sam adres (patrz „Linkowanie kont społecznościowych”). -
Telefon — weryfikacja numeru kodem SMS (AP-638). SMS kończy się linią
@domena #kod, po której iOS i Android same podpowiadają kod nad klawiaturą, więc krok zwykle kosztuje jedno tapnięcie zamiast sześciu. Tu przebiega granica uwierzytelnienia opisana niżej. Endpoint odmawia zarówno wysyłki, jak i potwierdzenia kodu, dopóki adres e-mail nie jest potwierdzony — kroku nie da się ominąć wołając API bezpośrednio. -
Termin — wybór zajęć osobno dla każdego uczestnika (AP-639), opisany w następnej sekcji.
Niedokończona rejestracja jest zapisywana po każdym kroku. Klient, który zamknie
kartę i wróci w ten sam link, wraca na krok, na którym skończył — draft żyje
48 godzin i jest rozpoznawany po ciasteczku acepark_trial_draft (httpOnly).
Nawigacja „Wstecz”
Każdy krok kreatora ma przycisk „Wstecz”, aż do samych kafelków, a płatność wraca do wyboru terminu z zachowanym zaznaczeniem. Powrót nigdy niczego nie kasuje: wybranie tej samej usługi ponownie wraca do wypełnionego formularza, a nowy draft powstaje dopiero przy zmianie rodzaju zajęć.
Po weryfikacji SMS istnieją już konto, gracze i leady wypisane z danych z wcześniejszych kroków, więc te kroki zamieniają się w podgląd: pola są wyszarzone, znikają przyciski dodawania i usuwania uczestników, a zamiast „Dalej” zapisującego formularz jest zwykłe przejście dalej. Klient może więc cofnąć się i sprawdzić, co wpisał, ale nie osieroci rekordów, które z tych danych powstały. Kroki weryfikacji pokazują w tym stanie zielony komunikat „już potwierdzony” i nie wysyłają kolejnego kodu.
Ponowne wejście na krok weryfikacji uruchamia automatyczną wysyłkę, która trafia w cooldown — to nie jest błąd i nie pokazujemy z tego powodu czerwonego banera, bo odliczanie widać na przycisku „Wyślij ponownie”.
Krok „Termin” (AP-639)
Każdy uczestnik wybiera własny termin — rodzeństwo w różnym wieku trafia do różnych grup, a dorosły zapisujący też siebie do jeszcze innej. Przy więcej niż jednym uczestniku krok pokazuje zakładkę na osobę; przy zaznaczonym terminie zakładka dostaje ptaszka, a kreator sam przeskakuje do kolejnej osoby.
Na liście pokazujemy wyłącznie zajęcia, które klient naprawdę może wybrać:
- tylko zajęcia grupowe (
activity_types.type = 'group') — indywidualne i rezerwacje mają własne ścieżki (kafelki „Wkrótce”); - dopasowane wiekiem — wiek liczony w pełnych latach na dziś musi mieścić się
w
age_from…age_totypu zajęć (puste pole = brak ograniczenia z tej strony); - z wolnym miejscem — miejsca zajęte liczy ten sam SQL co reszta aplikacji (potwierdzeni uczestnicy plus nieprzeterminowane drafty płatności);
- w oknie widoczności — od teraz do
trial_days_advance_clientdni naprzód (ustawienie per miasto, domyślnie 7 dni), w mieście z draftu; - zajęcia odwołane i zarchiwizowane oraz typy zajęć bez limitu miejsc nie wchodzą.
Karta terminu pokazuje nazwę zajęć, godzinę, kort i liczbę wolnych miejsc. Dopóki choć jeden uczestnik nie ma terminu, przycisk „Dalej” jest nieaktywny.
Gdy dla uczestnika nie ma żadnego pasującego terminu, krok pokazuje komunikat z prośbą o kontakt z recepcją. Automatyczne zgłoszenie do recepcji („żaden termin mi nie pasuje”) powstaje w AP-824.
Krok „Termin” nie ma przycisku „Wstecz”: telefon jest już zweryfikowany, konto
i gracze założeni, więc ponowne przepisanie listy uczestników osierociłoby
utworzone dla nich rekordy player i lead.
Wybór jest weryfikowany jeszcze raz na serwerze przy zapisie — termin, który zapełnił się w trakcie wypełniania formularza albo przestał pasować wiekiem, jest odrzucany z komunikatem, zamiast cicho przejść dalej. Serwer pilnuje też, żeby dwoje uczestników z jednego draftu nie zajęło tego samego ostatniego miejsca.
Krok „Płatność” (AP-640)
Ostatni krok pokazuje podsumowanie: linia na uczestnika (imię, nazwa zajęć,
data i godzina, kort) z ceną, a pod nią sumę. Cena to trial_price z ustawień
miasta z draftu (domyślnie 29 zł), pomnożona przez liczbę uczestników.
Kod rabatowy — pole z przyciskiem „Zastosuj”. Kod jest sprawdzany od razu
(/api/discount-codes/validate) i po zastosowaniu podsumowanie pokazuje kwotę
rabatu oraz kwotę do zapłaty. To tylko podgląd — wiążącą walidację i zapis użycia
kodu robi /api/payments/initialize przy realnej płatności.
Faktura — przełącznik „Chcę fakturę”. Po włączeniu klient wybiera osobę prywatną albo firmę; firma wymaga nazwy i 10-cyfrowego NIP-u, adres jest wymagany zawsze, a e-mail do faktury jest opcjonalny (puste = adres podany przy zapisie). Dane lądują na drafcie w kolumnach o tych samych nazwach co przy obozach.
Przycisk „Zapłać” uruchamia jedno wywołanie serwera (action: 'pay'), które
po kolei:
- waliduje i zapisuje dane do faktury oraz kod rabatowy na drafcie,
- zapisuje każdego uczestnika na wybrane zajęcia przez
enrollInSkillAssessment— ten sam kod, którego używa recepcja i aplikacja klienta, więc blokowanie miejsca, okno na płatność i cennik mają jedno źródło prawdy, - zamyka draft (
step = 'done',completed_at) i kasuje ciasteczko, żeby powrót na link nie wznawiał zakończonego zapisu.
Dopiero potem przeglądarka woła processPayments(paymentIds, discountCode) —
istniejącą ścieżkę Przelewy24 — i klient trafia na bramkę płatności.
Jeśli któryś termin zapełni się między wyborem a kliknięciem „Zapłać”, zapis
zatrzymuje się z komunikatem i prośbą o kontakt z recepcją. Zamknięcie draftu jest
celowo niezależne od wyniku płatności: rejestracja jest w tym momencie
kompletna (konto, gracze, zapis na zajęcia), a nieopłacona płatność wygasa razem
z blokadą miejsca i zostaje jako payment do rozliczenia — nie wskrzeszamy przez
nią kreatora.
Uwaga na etapie wdrożenia
Utwardzenie płatności powstaje w podzadaniu AP-641 (obsługa przerwania płatności, ponowienie, wygasający link, SMS z paragonem/fakturą). Nie publikuj linków na stronie WWW przed jego wdrożeniem.
Konto klienta i logowanie
Granica uwierzytelnienia biegnie przez krok „Telefon": wszystko do weryfikacji SMS
włącznie jest publiczne (tam powstaje lead), a wybór terminu i płatność wykonuje już
zalogowany klient, na tych samych komponentach co reszta aplikacji. Krok
„E-mail" leży przed tą granicą i sam z siebie nie tworzy żadnych rekordów — stawia
tylko email_verified_at na drafcie.
Poprawny kod SMS uruchamia trzy rzeczy naraz:
- Konto — użytkownik Better Auth zakładany po stronie serwera na podany e-mail,
z losowym hasłem, którego klient nigdy nie widzi. Numer telefonu ląduje na koncie
jako potwierdzony (
phoneNumber,phoneVerified). - Sesja — ciasteczko sesji wraca tą samą odpowiedzią, więc klient przechodzi do kolejnego kroku bez ekranu logowania.
- Uczestnicy jako gracze — jeden
playerna uczestnika,owner_email= adres opiekuna. Gracz o tym samym imieniu, nazwisku i dacie urodzenia jest ponownie użyty, a nie duplikowany.
Równolegle idzie mail z linkiem do /auth/set-password. Od tego momentu klient ma
trzy drogi do swojego konta:
| Sposób | Od kiedy działa |
|---|---|
| Kod SMS na numer podany przy zapisie | od razu |
| E-mail i hasło | po ustawieniu hasła z maila |
| po ustawieniu hasła z maila albo po połączeniu w profilu |
Link z maila jest jednorazowy i ważny godzinę (domyślne resetPasswordTokenExpiresIn
w Better Auth). Gdy wygaśnie, klient nie jest zablokowany — ustawia hasło przez
„Nie pamiętam hasła" albo wchodzi kodem SMS.
Weryfikujemy oba kanały: adres kodem z maila, numer kodem SMS. Literówka w adresie nie przechodzi już przez kreator, więc konto bez możliwości ustawienia hasła z niej nie powstanie.
Google działa od razu — pod warunkiem potwierdzonego adresu
Better Auth odmawia doczepienia konta społecznościowego do użytkownika
z niezweryfikowanym e-mailem (account_not_linked), niezależnie od
trustedProviders. Gdyby odmowy nie było, ktoś, kto zapisze się na cudzy adres,
zostawia sobie na tym koncie logowanie SMS-em i przejmuje wszystko, co prawowity
właściciel adresu wprowadzi tam później, wchodząc „przez Google".
Konto zakładane przez kreator dostaje emailVerified = 1 właśnie dlatego, że kod
z maila jest dowodem posiadania skrzynki — więc „Zaloguj przez Google" działa od
pierwszej chwili. Ten sam znacznik ustawia użycie linku z maila
(onPasswordReset w lib/auth.ts), co ratuje konta założone przed dodaniem
kroku e-mail — te wciąż mają emailVerified = 0.
Dwa ekrany obsługują stan przejściowy kont sprzed tej zmiany:
- Odmowa linkowania — powrót na
/auth/login?error=account_not_linkedz wyjaśnieniem, żeby wejść SMS-em lub hasłem, a Google połączyć w profilu. - Konto tylko z Google — po wpisaniu takiego adresu formularz pokazuje przycisk
providera zamiast pytać o hasło, którego to konto nie ma
(
app/auth/login/social-account-notice.tsx).
Połączenie z poziomu zalogowanej sesji (Profil → Połączone konta, linkSocial())
jest drogą bez tego ograniczenia: klient już udowodnił, że konto jest jego, więc
zgodność adresów nie musi tego dowodzić za niego.
Google na innym adresie: scalanie po telefonie
Klient, który zapisał się na [email protected], a potem klika „Zaloguj przez Google" jako
[email protected], dostaje od Better Auth nowe konto — bez graczy, bez płatności,
bez historii. Wyprowadza z tego dialog potwierdzenia telefonu, który i tak otwiera się
każdemu bez phoneVerified: gdy podany numer należy już do innego konta, zamiast
komunikatu „numer zajęty" pojawia się propozycja scalenia.
Kod SMS idzie na numer tamtego konta, więc przejść dalej może tylko ktoś, kto ten
numer trzyma — dokładnie ten dowód, którego wymaga zwykłe logowanie SMS-em. Po
potwierdzeniu wędruje tożsamość, nie dane: wiersz account z Google przechodzi na
konto właściciela numeru, świeże konto znika, a odpowiedź niesie sesję tego, które
zostało. Odwrotny kierunek oznaczałby przepisywanie player.owner_email, płatności,
faktur i portfela — to, co musi robić starszy merge kont lokalnych.
Scalenie jest możliwe tylko wtedy, gdy zalogowane konto nic jeszcze nie ma (żadnego gracza, żadnej płatności). Konto z historią nigdy nie jest kasowane — tam zostaje komunikat o zajętym numerze.
Zapis bez linku: /auth/register
Ten sam kreator obsługuje wejście bez kampanii. Różnice są dwie:
- miasto wybiera klient (link niesie je ze sobą), a kafelki usług doczytują się
dopiero dla wybranego miasta —
GET /api/public/trial-registration?services=<miasto>; - draft trafia na link organiczny — jeden wiersz
trial_registration_linkzis_organic = 1na miasto, zakładany przy pierwszym takim zapisie i niewidoczny w panelu: nie jest do publikowania, a lejek liczy się dla niego tak samo jak dla kampanii.
Jeżeli kreator startuje na zalogowanej sesji, draft zapamiętuje user_id i krok SMS
nie zakłada drugiego konta — dopisuje tylko potwierdzony numer i graczy. Właściciela
graczy bierzemy wtedy z adresu konta z sesji, a nie z pola w formularzu: owner_email
jest tym, po czym filtruje każdy późniejszy odczyt.
Natywna aplikacja otwiera /auth/register z markerem mobile=1, żeby przejąć sesję —
tam zostaje dotychczasowy formularz e-mail i hasło, bo kreator kończy się gdzie indziej.
Logowanie: jedno pole na e-mail i telefon
/auth/login ma jedno pole „Adres e-mail lub numer telefonu" — klient nie wybiera
metody logowania, tylko wpisuje to, co pamięta, a formularz sam rozpoznaje, co dostał:
| Co wpisze klient | Co się dzieje |
|---|---|
[email protected] | krok z hasłem (bez hasła na koncie → przekierowanie na odzyskiwanie) |
601202303, 601 202 303, 48601202303, +48 601-202-303 | numer sprowadzony do +48601202303 i wysłany kod SMS |
| cokolwiek innego | komunikat „Podaj adres e-mail albo numer telefonu” |
Rozpoznawanie i normalizacja siedzą w lib/login-identifier.ts — module bez zależności
serwerowych, więc korzysta z niego zarówno formularz, jak i endpoint.
Logowanie kodem wymaga numeru potwierdzonego na koncie (phoneVerified = 1) —
numer niezweryfikowany mógł wpisać ktokolwiek, więc nie jest czynnikiem logowania.
Limity (60 s odstępu, 5 kodów na dobę, 5 błędnych prób = blokada na kwadrans) leżą
w KV z TTL-em, więc nic nie wymaga sprzątania. Sesja powstaje przez
internalAdapter.createSession Better Auth, czyli jest zwykłą sesją — taką samą,
jaką dałoby logowanie mailem albo Google.
Do ciastka trafia podpisana wartość <token>.<base64 HMAC-SHA256(token)>
(podpis sekretem BETTER_AUTH_SECRET) — Better Auth czyta sesję przez
getSignedCookie i sam token, bez podpisu, odrzuca jako niepodpisany. Ciastko
z gołym tokenem przechodzi middleware (sprawdza tylko obecność), ale strona już
nie widzi sesji i odsyła na /auth/login.
🛠 Dokumentacja techniczna
Model danych
migrations/0186_create_trial_registration_link.sql
trial_registration_link
| Kolumna | Typ | Opis |
|---|---|---|
token | TEXT UNIQUE | 32-znakowy hex (crypto.randomBytes(16)), część publicznego URL-a. |
name | TEXT | Nazwa robocza, widoczna wyłącznie w panelu. |
description | TEXT | Tekst pokazywany klientowi na stronie zapisu. |
source | TEXT | LeadSource przenoszony na leady z tego linku. |
campaign | TEXT | Etykieta kampanii / landing page'a. |
city | TEXT | Lokalizacja, ustalana przez resolveWriteLocation. |
activity_type_id | INTEGER | Opcjonalne preselekcjonowanie usługi (activity_types). |
is_active | INTEGER | Ręczne włączenie / wyłączenie linku. |
expires_at | TEXT | ISO UTC lub NULL = bezterminowo. Panel pozwala wybrać dzień, a getDateFilterUTC(dzień, true) zamienia go na koniec tego dnia w strefie Europe/Warsaw. |
W odróżnieniu od payment_link token jest wielorazowy, więc nie ma kolumny
used_at; o użyteczności linku decydują is_active i expires_at.
trial_registration_link_event
Log lejka: jeden wiersz na zdarzenie. event_type ∈ {visit, started, lead_created, completed}. visitor_id to losowy identyfikator przeglądarki z localStorage
(brak IP i innych danych osobowych). Kolumny referrer, utm_source,
utm_medium, utm_campaign pozwalają rozbić statystyki po źródle ruchu.
lead.registration_link_id
Atrybucja leada do linku, z którego powstał. Wypełniana przez createLead,
gdy LeadInput.registration_link_id zostanie podane.
trial_registration_draft i trial_registration_draft_participant
migrations/0187_create_trial_registration_draft.sql
Stan niedokończonej rejestracji. Odwiedzający jest jeszcze anonimowy, więc dane nie
mają gdzie zamieszkać na koncie użytkownika — trzyma je draft, rozpoznawany po
własnym tokenie w ciasteczku httpOnly acepark_trial_draft (48 h).
| Kolumna | Opis |
|---|---|
token | 32-znakowy hex, jedyny identyfikator draftu; nigdy nie trafia do JS-a strony. |
step | guardian / participants / phone / slots / payment / done. Krok „E-mail” nie ma tu własnej wartości — dzieli phone i odróżnia go email_verified_at, dzięki czemu doszedł bez przebudowy CHECK-a na tej kolumnie. |
guardian_*, city | Dane opiekuna i wybrana lokalizacja. |
email_verified_at | Ustawiane po poprawnym kodzie z maila; czyszczone, gdy opiekun zmieni adres. |
phone_verified_at | Ustawiane w AP-638; czyszczone automatycznie, gdy opiekun zmieni numer telefonu. |
otp_sent_at, otp_send_count, otp_attempt_count | Liczniki anty-nadużyciowe dla SMS — lib/otp.ts jest bezstanowe i samo ich nie ma. |
email_otp_* | Te same liczniki dla kodu e-mail (migrations/0192_...), niezależne od SMS-owych. |
statute_accepted_at, rodo_accepted_at | Ten sam wzorzec zgód co obozy (0090) i kursy tenisa (0135). |
last_activity_at, completed_at | Podstawa widoku porzuconych rejestracji (AP-825). |
trial_registration_draft_participant to jeden wiersz na uczestnika (position
utrzymuje kolejność z formularza) plus game_id (wybrany termin, AP-639),
player_id i lead_id (zakładane po weryfikacji SMS).
migrations/0189_... dokłada do draftu discount_code oraz komplet pól faktury
(invoice_requested, invoice_type, invoice_company_name, invoice_tax_id,
invoice_address, invoice_email) — te same nazwy co przy obozach (0090), żeby
generowanie faktur czytało wszędzie ten sam kształt. Kod rabatowy leży tu wyłącznie
jako to, co wpisał klient; wiążąca walidacja i zapis użycia zostają w
/api/payments/initialize.
Wybór faktury z draftu jest rzutowany na wiersze invoice graczy
(syncPlayerInvoicePreferences) — to je czyta webhook płatności, decydując między
fakturą a paragonem. invoice.client_id jest NOT NULL i trzyma id konta
właściciela, więc gracz zakładany w rejestracji dostaje je od razu
(provisionPlayers), a dla graczy sprzed tej zmiany bierze się je z konta opiekuna
(COALESCE(player.client_id, user.id)). Uczestnik bez rozpoznanego właściciela jest
pomijany z ostrzeżeniem w logu — synchronizacja preferencji nigdy nie wywraca
rejestracji, bo draft ma już zapisany wybór klienta, a paragon zamiast faktury da się
poprawić ręcznie. Odznaczenie faktury zeruje classic_invoice/nominal_invoice
tylko na istniejących wierszach; graczowi, który nigdy faktury nie chciał, nie zakłada
pustego wiersza.
is_account_owner (migrations/0188_...) wskazuje uczestnika, którym jest sam
właściciel konta. Bez tej flagi dorosły zapisujący samego siebie dostałby drugiego,
zduplikowanego gracza obok tego, którego konto już posiada. Najwyżej jeden wiersz
w drafcie może mieć flagę — reguły pilnuje validateParticipants, bo SQLite nie
wyraża warunku „co najwyżej jeden w grupie” jako ograniczenia tabeli.
Przepływ
Kod
| Plik | Rola |
|---|---|
lib/trial-registration-link.ts | Moduł serwerowy (bez 'use server'): walidacja tokenu, resolveTrialRegistrationLink, recordTrialRegistrationLinkEvent, buildRegistrationUrl. |
lib/actions/trial-registration-link.ts | Server actions panelu: CRUD + getTrialRegistrationLinks ze statystykami. Wymaga roli ADMIN lub BIURO. |
lib/registration-services.ts | Moduł serwerowy: getRegistrationServiceOptions (stany kafelków), hasAvailableGroupTrialSlots, isImplementedRegistrationServiceCategory oraz wspólny filtr GROUP_TRIAL_SLOT_SOURCE_SQL. |
lib/trial-registration-slots.ts | Lista terminów per uczestnik (getTrialRegistrationSlots), walidacja wyboru (validateSlotSelections) i zapis kroku (saveSlotsStep). |
app/register/[token]/slots-step.tsx | Krok wyboru terminu: zakładka na uczestnika, karty terminów pogrupowane po dniach. |
lib/trial-registration-payment.ts | Podsumowanie płatności, walidacja i zapis danych do faktury, zapis uczestników na zajęcia (enrollTrialRegistrationParticipants), zamknięcie draftu. |
app/register/[token]/payment-step.tsx | Krok płatności: podsumowanie, kod rabatowy, dane do faktury, przycisk „Zapłać”. |
hooks/usePaymentDiscount.ts | Wspólny hook kodów rabatowych; paymentIds jest opcjonalne, żeby publiczny zapis mógł sprawdzić kod, zanim powstaną płatności. |
lib/game-availability-sql.ts | Wspólny fragment SQL activeAttendeeCountSql liczący zajęte miejsca (potwierdzone + nieprzeterminowane drafty); używany przez publiczny flow i getGamesByDateRangeForPlayer. |
app/register/[token]/page.tsx | Publiczna strona zapisu (force-dynamic). |
app/register/[token]/registration-entry-client.tsx | Wysyłka zdarzeń visit / started + sterowanie krokami flow. |
app/register/[token]/service-tiles.tsx | Kafelki wyboru rodzaju usługi (AP-636). |
lib/trial-registration-draft.ts | Moduł serwerowy draftu: walidatory validateGuardian / validateParticipants, startTrialRegistrationDraft, saveGuardianStep, saveParticipantsStep, getTrialRegistrationDraft. |
app/api/public/trial-registration/route.ts | Publiczny endpoint kreatora (start / guardian / participants / send_code / verify_code / slots / pay, GET wznawiający draft, GET ?slots=1 z terminami i GET ?summary=1 z podsumowaniem), rate limit 20 req/min na IP. |
app/register/[token]/registration-stepper.tsx | Pasek kroków kreatora. |
app/register/[token]/guardian-step.tsx | Krok danych opiekuna wraz ze zgodami. |
app/register/[token]/participants-step.tsx | Krok listy uczestników (1–5 osób), z przełącznikiem „Ja też będę grać”. |
app/register/[token]/verification-step.tsx | Wspólny krok kodu dla e-maila i SMS-a (InputOTP, cooldown, zmiana kontaktu, stan „już potwierdzony”); kanał wybiera props channel. |
lib/trial-registration-otp.ts | Wysyłka i weryfikacja kodu e-mail oraz SMS + tworzenie leadów (jeden na uczestnika). |
mails/registration-code.html | Szablon maila z kodem; wysyłany przez sendRegistrationCodeEmail z lib/auth-emails.ts. |
lib/trial-registration-account.ts | Zakładanie konta Better Auth, sesja, gracze, mail z zaproszeniem. |
lib/phone-login.ts | Logowanie kodem SMS: limity w KV, sesja przez internalAdapter. |
app/api/public/phone-login/route.ts | Publiczny endpoint logowania SMS (serwowany przez oba workery). |
lib/login-identifier.ts | Rozpoznanie e-mail vs numer telefonu + normalizacja numeru do +48XXXXXXXXX. |
app/auth/login/phone-login-form.tsx | Ekran kodu SMS na /auth/login (weryfikacja, ponowna wysyłka). |
app/auth/login/social-account-notice.tsx | Ekran konta bez hasła, ale z Google: przycisk providera + opcja wysłania linku do hasła. |
components/forms/userProfile/ | LinkGoogleAccount.tsx: przycisk „Połącz konto Google" w profilu (linkSocial() z zalogowanej sesji). |
app/api/public/registration-link/route.ts | Publiczny endpoint zliczający zdarzenia, rate limit 30 req/min na IP. |
app/(dashboard)/dashboard/statistics/crm/links/ | Panel: tabela ze statystykami + dialog tworzenia/edycji. |
types/trial-registration-link.ts | Typy współdzielone linku i kafelków. |
types/trial-registration-draft.ts | Typy kreatora: kroki, draft, uczestnicy, błędy walidacji. |
Statystyki jednym zapytaniem
getTrialRegistrationLinks liczy cały lejek w jednym LEFT JOIN +
GROUP BY l.id (SUM(CASE WHEN ...) na typ zdarzenia, COUNT(DISTINCT ...)
na unikalnych odwiedzających) — bez zapytań w pętli po linkach.
Bezpieczeństwo i integracja z infrastrukturą
- Token jest jedynym poświadczeniem: strona i endpoint są celowo nieuwierzytelnione,
dlatego
/registertrafia na listę public pages wmiddleware.ts(nagłówekx-public-page), a/api/public/registration-linkna listępublicRoutes. recordTrialRegistrationLinkEventużywaINSERT ... SELECT, więc warunki „aktywny i niewygasły” sprawdzane są w tej samej instrukcji co zapis — wyłączony lub wygasły link nie zbierze statystyk.- Zapis zdarzenia nigdy nie rzuca wyjątkiem; nieudana statystyka nie może przerwać rejestracji klienta.
app/registerjest kompilowane wyłącznie do wariantu client (scripts/route-variants.json), bo link otwierają klienci na domenie klienckiej. Adres linku w panelu budowany jest naCLIENT_ORIGIN.- Strona panelu ma wpis w
route_access(dashboardStatisticsCrmLinks, rola ADMIN).
Testy
__tests__/lib/trial-registration-link.test.ts— walidacja tokenu, rozstrzyganie statusu linku (ok / invalid / not_found / inactive / expired), zapis zdarzeń, odrzucanie nieznanych typów zdarzeń, przycinanie zbyt długich wartości.__tests__/lib/trial-registration-draft.test.ts— walidatory opiekuna i uczestników, tworzenie draftu tylko dla aktywnego linku, zapis kroków, unieważnianie weryfikacji SMS po zmianie numeru, wygaśnięcie i zakończenie draftu.__tests__/lib/trial-registration-slots.test.ts— liczenie wieku w pełnych latach, dopasowanie doage_from/age_to, liczenie wolnych miejsc (anulowani nie zajmują), odsiewanie zajęć niegrupowych, odwołanych i spoza okna widoczności, blokada draftu bez weryfikacji SMS oraz walidacja i zapis wyboru (komplet uczestników, zajęty termin, dwoje uczestników na jedno wolne miejsce).__tests__/lib/trial-registration-payment.test.ts— normalizacja NIP-u, walidacja danych do faktury (osoba prywatna vs firma, opcjonalny e-mail), wycena podsumowania ztrial_pricemiasta i fallbackiem, pomijanie uczestnika bez terminu, czyszczenie pól faktury przy zmianie typu, zapis uczestników na zajęcia (kolejność wywołań, brak gracza, zapełniony termin) oraz jednorazowe zamknięcie draftu.__tests__/lib/registration-services.test.ts— dostępność zajęć grupowych (wyłączone zapisy próbne, komplet uczestników, anulowani i przeterminowani draftowi uczestnicy, okno widoczności, archiwum, filtr miasta) oraz stany kafelków.__tests__/lib/actions/trial-registration-link.test.ts— generowanie tokenu, kontrola uprawnień, walidacja nazwy / kanału / daty, agregacja statystyk, filtr po mieście, edycja, przełączanie aktywności, usuwanie.