Przejdź do głównej zawartości

Publiczny kalendarz rezerwacji

Instrukcja (WordPress / strona firmy)

Publiczny kalendarz można osadzić na stronie AcePark przez iframe, np.:

<iframe
src="https://klient.acepark.pl/book/ace-park?city=Opole&street=Spokojna"
style="width:100%;height:760px;border:0;background:transparent"
title="Rezerwacja kortu"
allowtransparency="true"
></iframe>

Parametry city i street przypinają lokalizację — na stronie lokalizacji nie trzeba już wybierać obiektu.

Tło strony bookingu jest przezroczyste, żeby w iframe przeświecało tło WordPressa. Same karty (filtry, siatka, dialogi) zostają nieprzezroczyste.

W iframe ukrywany jest nagłówek strony („Zarezerwuj kort” / „Wybierz termin…”) — WordPress ma już swój tytuł sekcji. Przy bezpośrednim wejściu na /book/... nagłówek zostaje.

Filtry, wybór daty, wybór długości gry, wybór kortu i formularz rezerwacji otwierają się jako wyśrodkowany dialog (nie jako dolny drawer). Dzięki temu overlay wygląda poprawnie także w iframe na WordPressie.

Kolejność wyboru: najpierw godzina, potem długość

Klient nie deklaruje czasu gry przed obejrzeniem terminów. Siatka (i lista godzin na telefonie) pokazuje wszystkie godziny, od których da się zarezerwować co najmniej minimalny czas rezerwacji; długość wybiera się dopiero po kliknięciu w godzinę — dialog „Wybierz długość gry" podaje wyłącznie te długości, które ta godzina naprawdę przyjmie (wolne okno kortu i reguła bez niesprzedawalnych przerw).

Dalej jest jak dotychczas: na desktopie klient klika w konkretny kort, więc po wyborze długości od razu otwiera się formularz; na telefonie po długości pojawia się lista kortów z cenami przeliczonymi na wybraną długość.

Gdy godzina przyjmuje tylko jedną długość, dialog się nie otwiera — nie ma w nim czego wybierać, więc byłby wyłącznie dodatkowym kliknięciem. Taka godzina wraca z serwera od razu opisana tą jedyną długością (getAvailableCourtSlots liczy jej koniec i cenę, skoro inaczej i tak nie da się jej zarezerwować), więc formularz otwiera się bez ponownego czytania dnia — lokalnie ~40 ms zamiast ~500 ms. Siatka jest przygaszona tylko wtedy, gdy dzień faktycznie jest doczytywany.

Gdy lokalizacja ma wyłączony limit czasu rezerwacji, zamiast kilkudziesięciu przycisków dialog pokazuje pole na liczbę godzin z „−" i „+" (DurationHoursInput), zatwierdzane przyciskiem Dalej. Granice pola to najkrótsza i najdłuższa długość tej godziny, a wartość, której godzina nie przyjmuje, jest dociągana do najbliższej dozwolonej w stronę, w którą klient szedł (snapToAllowedDuration).

Pasek nad kalendarzem pokazuje już tylko lokalizację — a gdy jest jedna (albo city/street przypięto w adresie iframe), jest zwykłą etykietą bez rozwijania.

Sposób kontaktu (SMS / e-mail / oba)

Klient potwierdza rezerwację kodem jednorazowym. To, gdzie ten kod trafia, ustawia się w Ustawieniach rezerwacji (/dashboard/reservations-settings) w sekcji „Rezerwacje online”, polem Sposób kontaktu w formularzu publicznym:

OpcjaCo widzi klient
Telefon lub e-mail (klient wybiera)jedno pole „Numer telefonu lub e-mail” — kanał wynika z tego, co klient wpisze
Tylko telefon (kod SMS)pole „Numer telefonu”, klawiatura numeryczna, kod zawsze SMS-em
Tylko e-mail (kod e-mailem)pole „Adres e-mail”, kod zawsze na e-mail

Ustawienie jest per zakres (domyślne / miasto / lokalizacja), tak samo jak pozostałe ustawienia rezerwacji — jedna lokalizacja może wysyłać wyłącznie SMS-y, a druga wyłącznie e-maile. Domyślnie obowiązuje oba i formularz zachowuje się jak dotychczas.

Pole jest widoczne tylko wtedy, gdy dla danego zakresu włączone są rezerwacje online.

Dokumentacja techniczna

  • Trasa: app/(public)/book/[tenant]/
  • Overlaye (Dialog shadcn, bez Vaul Drawer):
    • PublicFiltersBar — lokalizacja (przy jednej lokalizacji: statyczna etykieta)
    • PublicDateCalendar — wybór dnia
    • PublicDurationDialog — wybór długości gry dla klikniętej godziny
    • PublicTimeDialog — wybór kortu dla godziny
    • ReservationDialog — OTP + potwierdzenie + płatność
  • Tło html/body i wrappera kalendarza jest przezroczyste (TransparentPageBackground + bg-transparent), żeby embed na WordPressie dziedziczył tło strony hosta.
  • Wykrywanie iframe: useIsInIframe (window.self !== window.top). W embedzie ukrywany jest <header> z tytułem/podtytułem.
  • W iframe dialogi nie używają backdrop-blur ani przyciemnienia tła (rozmazuje/przyciemnia stronę hosta pod ramką) — overlay jest przezroczysty (html[data-in-iframe]).
  • Osadzanie cross-origin wymaga CSP frame-ancestors przez env PUBLIC_BOOKING_FRAME_ANCESTORS (middleware ustawia nagłówek tylko dla /book/*). Przykład:
PUBLIC_BOOKING_FRAME_ANCESTORS='self' https://acepark.pl https://www.acepark.pl

Bez domeny hosta w allowliście przeglądarka zablokuje iframe (frame-ancestors).

Cennik

Kalendarz publiczny liczy ceny tym samym cennikiem co panel: rekordem activity_types typu booking rozwiązywanym kaskadą ulica → miasto → globalny. Ceny terminów pochodzą z getAvailableCourtSlots (osobny cennik dla każdej ulicy w wynikach), a kwota faktycznie naliczana przy rezerwacji — z getBookingActivityType(city, street), gdzie ulica pochodzi z kortu przydzielonego do terminu. Szczegóły: Ustawienia rezerwacji.

Terminy kończące się o północy

Kort czynny do północy generuje termin, którego koniec wypada 1440 minut po północy. Zapisanie tego jako zegarowego "24:00" i sparsowanie przez zonedTimeToUtc daje początek tej samej doby, a nie jej koniec — termin wychodził odwrócony (koniec przed początkiem), co dawało cenę 0,00 zł, psuło wykrywanie kolizji z istniejącymi rezerwacjami i kończyło się błędem invalid_time przy zapisie.

slotInstant(dateStr, minutesFromMidnight) z lib/utils/slot-time.ts przenosi nadmiar minut na kolejną datę, więc offset 1440 rozwiązuje się do północy następnego dnia. Arytmetyka daty idzie przez Date.UTC, więc wynik nie zależy od strefy procesu. getAvailableCourtSlots buduje przez niego oba końce terminu.

Pola startTime / endTime pozostają zegarowe — koniec o północy widnieje jako 24:00, bo 23:00–24:00 czyta się jednoznaczniej niż 23:00–00:00. Każde miejsce, które zamienia je z powrotem na instant, musi iść przez slotInstant(date, parseSlotClock(clock)), nigdy przez zonedTimeToUtc na sklejonym stringu: dotyczy to filtra w getPublicSlots i wysyłki w ReservationDialog. W panelu (court-booking-dialog, move-reservation-dialog) rolę tę pełni parseTimeToDate, które przez setHours(24, 0) przechodzi na kolejną dobę poprawnie.

Sposób kontaktu — implementacja

  • Kolumna booking_settings.public_contact_method ('sms' | 'email' | 'both', domyślnie 'both'), migracja migrations/0204_add_public_contact_method.sql. Obowiązuje ta sama reguła fallbacku co dla reszty ustawień: ulica → miasto → globalne.
  • Typ i helpery w lib/actions/public-booking-shared.ts: PublicContactMethod, parsePublicContactMethod (każda nieznana wartość → 'both') oraz isChannelAllowedByContactMethod.
  • getPublicBookingSettings zwraca public_contact_method, więc /api/public/booking/options (i endpoint dnia) podaje je do PublicBookingApp, a ten do ReservationDialog.
  • ReservationDialog przy jednym dozwolonym kanale nie zgaduje z formatu wpisanej wartości — kanał jest ustalony przez konfigurację, a pole dostaje odpowiedni type/inputMode/autoComplete i etykietę (contactLabelPhone / contactLabelEmail).
  • Walidacja serwerowa, niezależna od UI: isPublicContactChannelAllowed(channel, city, street) w lib/actions/public-booking.ts blokuje /api/public/booking/otp/send i /api/public/booking/otp/verify (oba przyjmują opcjonalne city/street), a createPublicReservation zwraca contact_method_not_allowed. Wysłanie kodu kosztuje, więc kanał wyłączony w ustawieniach nie może zostać użyty przez klienta pomijającego formularz.
  • Bez city/street w żądaniu decyduje rekord globalny.

Bez niesprzedawalnych przerw

Reguła nie ma już własnego kafelka: skoro długość wybiera się po godzinie, długość, po której zostałaby wolna resztka krótsza niż min_usable_gap_minutes (domyślnie 60), po prostu nie trafia na listę w PublicDurationDialog. Godzina, której nie ratuje żadna długość, nie pojawia się w ogóle.

Jak to jedzie przez warstwy:

  • Przeglądanie dnia/api/public/booking/day?...&withDurations=1getPublicDaySchedule({ includeDurationOptions: true }): dzień czytany na min_duration_minutes, a każdy termin niesie PublicSlot.availableDurations. Kalendarz miesiąca (getPublicAvailabilityCalendar) liczy tak samo, więc kropka przy dniu znaczy „da się coś zarezerwować", a nie „mieści się akurat ta długość".
  • Wybór długości — ten sam endpoint bez withDurations, dla wybranej długości. Ta odpowiedź przechodzi normalne filtrowanie regułą, więc długość, której serwer by nie przyjął, nie może wrócić jako wolny termin. Slot (cena, godzina końca) bierze się z odpowiedzi serwera, nigdy z arytmetyki w przeglądarce.
  • ZapiscreatePublicReservation nadal odrzuca taki termin błędem leaves_unbookable_gap wraz z allowedDurations, na wypadek wyścigu między obejrzeniem dnia a wysłaniem formularza.

Siatka pod dialogiem zostaje na najkrótszej rezerwacji, więc wybór długości niczego z niej nie chowa. Szczegóły reguły: Ustawienia rezerwacji.

Regulamin — akceptowany raz, przy płatności

Klient akceptuje Regulamin Kortów wyłącznie na stronie płatności (/pay/{token}), zaznaczając checkbox, który odblokowuje przycisk „Zawieram umowę i płacę". Formularz rezerwacji tylko nazywa i linkuje oba dokumenty (regulamin i Politykę Prywatności) przy podawaniu danych kontaktowych — nie prosi już o zgodę.

Wcześniej akceptacja była pobierana dwa razy w jednym przejściu (checkbox w formularzu rezerwacji i ponownie klik w „Zapłać"), co dawało dwa wpisy w rejestrze zgód i dwa wpisy w Historii aktywności. Zgoda pozostaje aktem afirmatywnym — przeniósł się tylko moment, w którym jest brana, na krok, w którym zawierana jest umowa.

Rezerwacja porzucona przed płatnością nie zostawia zgody — wygasa razem z holdem na termin.

W Historii aktywności publiczna rezerwacja zostawia dzięki temu dwa wpisy (zgoda i płatność) zamiast czterech: wpis o utworzeniu zastępuje wpis o płatności (niżej), a druga akceptacja regulaminu w ogóle nie powstaje.

Historia aktywności — jeden wpis na rezerwację online

Rezerwacja zrobiona przez klienta (publiczny kalendarz albo aplikacja) i jej opłacenie to jedno zdarzenie w odstępie kilkudziesięciu sekund, dlatego w Historii aktywności (/dashboard/history) pokazywany jest tylko wpis o płatności. Wpis ten niesie komplet danych rezerwacji (termin, kort, klient, cena, status i metoda płatności), więc nic nie ginie.

Co zostaje bez zmian:

  • rezerwacja online, która nie została jeszcze opłacona — widoczna jako „zarezerwował kort”, do momentu zaksięgowania płatności (potem zastępuje ją wpis o płatności),
  • rezerwacja założona przez recepcję — zawsze dwa wpisy (utworzenie i płatność), bo to faktycznie dwie osobne czynności,
  • rezerwacja online rozliczona przez pracownika (np. gotówka przy kasie) — też dwa wpisy, bo wpis o płatności mówi wtedy o pracowniku, a nie o kliencie.

Implementacja

  • BOOKING_SELF_PAID_EXCLUDE_CLAUSE w lib/actions/activity-log.ts odcina wpis „utworzono rezerwację” (BOOKING_CREATED_SQL) tylko wtedy, gdy booking.booking_source = 'online', gra ma w activity_log zdarzenie payment_paid lub paid_with_wallet dla płatności typu court_reservation, a autorem zdarzenia nie jest pracownik.
  • Wpisy płatności (buildGamePaymentPaidSQL, buildGamePaidWithWalletSQL) dociągają dane z tabeli booking (BOOKING_LINKED_DETAIL_COLUMNS_NO_PAYMENT: komentarz, notatki, cena własna, powód anulowania) i podstawiają klienta jako wykonawcę, gdy płatność nie została zaksięgowana przez pracownika.
  • Wycięcie dzieje się w SQL, a nie po stronie łączenia wyników, więc nie zależy od tego, czy oba wpisy trafiły do tej samej strony listy.

Klient bez konta — profil w panelu

Rezerwacja z publicznego kalendarza nie zakłada konta. Uczestnik dostaje wtedy właściciela w postaci adresu zbudowanego z numeru telefonu (guest+<telefon>@public.acepark.pl), pod którym nie stoi żadne konto.

W panelu taki klient jest klikalny tak samo jak każdy inny — z Finansów (nazwisko pod opisem transakcji) i z listy Uczestników. Otwiera się jego profil zbudowany z danych uczestnika: imię i nazwisko, telefon, miasto, lista uczestników, zgody i historia płatności. Profil jest oznaczony plakietką Bez konta, a akcje dotyczące konta (zmiana danych logowania, reset hasła, usunięcie konta) są ukryte, bo nie ma czego zmieniać. W polu e-mail zamiast adresu zastępczego widnieje .

Ta sama ścieżka obsługuje klienta wpisanego ręcznie przez recepcję, który nigdy nie zarejestrował konta — wcześniej oba przypadki kończyły się stroną 404.

Implementacja

  • getPlayerOwnerIdentity (lib/actions/players.ts) zwraca tożsamość właściciela adresu: najstarszy rekord player o tym owner_email w ramach tenanta (imię, nazwisko, telefon, miasto, data utworzenia, public_booking_guest).
  • app/(dashboard)/dashboard/user/[email]/profile/page.tsx sięga po nią dopiero wtedy, gdy w tabeli user nie ma konta, i buduje z niej obiekt User z pustym user_id. Brak konta i brak uczestnika to nadal notFound().
  • hasAccount (!!user.user_id) steruje widocznością ResetPasswordButton, DeleteUserButton i ModifyUserButton; isPublicGuestEmail ukrywa adres zastępczy.
  • Miasto profilu pochodzi z rekordu uczestnika, więc nagłówek przełącza się na miasto rezerwacji (np. Legionowo) zamiast zostawać przy mieście pracownika.