diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfc..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.claude/hooks/doc-guard.sh b/.claude/hooks/doc-guard.sh new file mode 100755 index 00000000..6944c783 --- /dev/null +++ b/.claude/hooks/doc-guard.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# doc-guard — pilnuje, żeby dokumentacja w docs/ nie rozjechała się z kodem. +# +# start (SessionStart) — mówi, gdzie leży dokumentacja +# record (PostToolUse) — po zmianie kodu wskazuje KONKRETNY plik do aktualizacji +# +# To jest PRZYPOMNIENIE, nie bramka. Hook nigdy nie blokuje — kończy się exit 0 +# w każdej ścieżce, a jego wyjście to tylko kontekst dla modelu. Odpowiedzialność +# za aktualizację zostaje po stronie piszącego. +# +# Żeby nie hałasować: dla danej kategorii zmian odzywa się raz na sesję, a edycja +# czegokolwiek w docs/ wycisza wszystkie kategorie do końca sesji. +set -uo pipefail + +MODE="${1:-record}" +payload="$(cat 2>/dev/null || true)" + +sid="$(printf '%s' "$payload" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)" +state="${TMPDIR:-/tmp}/bojo-doc-guard/${sid}" +mkdir -p "$state" 2>/dev/null || exit 0 + +case "$MODE" in + start) + jq -nc '{hookSpecificOutput:{hookEventName:"SessionStart", + additionalContext:"Dokumentacja projektu leży w docs/ (indeks: docs/README.md), zasady pracy w AGENTS.md. Zmiana kodu pociąga za sobą aktualizację odpowiedniego pliku w docs/ — mapowanie w AGENTS.md, sekcja \"Aktualizacja dokumentacji\". Walidator spójności: npm run check:docs. docs/wizja.md jest dokumentem nadrzędnym: jego sekcji 1 nie parafrazować."}}' 2>/dev/null + ;; + + record) + f="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // .tool_response.filePath // ""' 2>/dev/null)" + [ -n "$f" ] || exit 0 + + # Edycja dokumentacji wycisza hook do końca sesji. + case "$f" in + */docs/*|*/frontend/public/llms.txt|*/frontend/public/llm-context.md|*/AGENTS.md) + : > "$state/docs" + exit 0 ;; + esac + [ -e "$state/docs" ] && exit 0 + + # Klasyfikacja zmienionego pliku → kategoria + sugerowany plik dokumentacji. + # Flagi sprawdzane przed ogólnym lib/, bo mają węższe i ważniejsze mapowanie. + case "$f" in + */frontend/src/lib/features.ts|*/frontend/src/config/features.ts) + kat="flagi" + cel="docs/funkcje.md (sekcja \"Flagi funkcji\") — tabela flag jest jedynym miejscem poza kodem, gdzie widać, co jest ukryte" ;; + */supabase/migrations/*) + kat="migracje" + cel="docs/baza-danych.md — mapa tabela → migracja" ;; + */frontend/src/lib/*) + kat="lib" + cel="docs/domena.md (jeśli zmieniły się reguły domenowe) lub docs/funkcje.md (jeśli doszła/zniknęła funkcja); jeśli zmiana jest widoczna dla użytkownika — także docs/llm-context.md (RAG INJECTION, patrz AGENTS.md)" ;; + */frontend/src/app/*) + kat="trasy" + cel="docs/funkcje.md — a jeśli doszła lub zniknęła trasa użytkownika, także frontend/public/llms.txt oraz docs/llm-context.md (RAG INJECTION, patrz AGENTS.md)" ;; + *) + exit 0 ;; + esac + + # Raz na sesję dla danej kategorii. + [ -e "$state/seen-$kat" ] && exit 0 + : > "$state/seen-$kat" + + jq -nc --arg plik "${f##*/bojo-app/}" --arg cel "$cel" \ + '{hookSpecificOutput:{hookEventName:"PostToolUse", + additionalContext:("Zmieniłeś " + $plik + ". Przed końcem zadania sprawdź: " + $cel + ". Po zmianach uruchom: npm run check:docs. (Przypomnienie doc-guard — nie blokuje.)")}}' 2>/dev/null + ;; +esac + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..8cc2f832 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/doc-guard.sh start", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/doc-guard.sh record", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..800fcd75 --- /dev/null +++ b/.env.example @@ -0,0 +1,78 @@ +# ============================================================ +# Bojo (Boiska Poznań) — zmienne środowiskowe +# Skopiuj ten plik do .env i uzupełnij wartości. +# NIGDY nie commituj pliku .env do repozytorium. +# ============================================================ + +# ----- Frontend (Next.js) — zmienne publiczne, widoczne w przeglądarce ----- + +# Supabase: URL projektu, np. https://abcdefghijkl.supabase.co +NEXT_PUBLIC_SUPABASE_URL= + +# Supabase: klucz publiczny "anon" (bezpieczny dla frontendu — chroniony przez RLS) +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Mapbox: token publiczny — używany TYLKO do miniatur zdjęć boisk (opcjonalny). +# Bez niego mapa nadal działa (Leaflet + OpenStreetMap), znikają tylko miniaturki. +# https://account.mapbox.com/ +NEXT_PUBLIC_MAPBOX_TOKEN= + +# Adres produkcyjny aplikacji — używany w sitemap.xml, robots.txt, JSON-LD +# i znacznikach SEO. Domena kanoniczna: https://bojo.pl +# Opcjonalny — bez niego kod używa https://bojo.pl (NIE localhost), więc przy +# pracy lokalnej ustaw http://localhost:3000, jeśli testujesz linki absolutne. +NEXT_PUBLIC_SITE_URL= + +# Włącznik rezerwacji w aplikacji ("true" = pokaż UI rezerwacji globalnie). +# Można też włączyć per-obiekt flagą booking_enabled w bazie. (opcjonalny) +NEXT_PUBLIC_FEATURE_RESERVATIONS=false + +# ----- Scraper / wzbogacanie danych — TYLKO lokalnie / w GitHub Actions ----- +# Te wartości NIGDY nie trafiają do frontendu. W GitHub Actions trzymaj je w Secrets. + +# Supabase: URL projektu (ta sama wartość co NEXT_PUBLIC_SUPABASE_URL) +SUPABASE_URL= + +# Supabase: klucz service_role — pełny dostęp do bazy, OMIJA RLS. +# Settings → API → service_role. Trzymaj w sekrecie! +SUPABASE_SERVICE_ROLE_KEY= + +# Google Places API — dla enrich_google.py (telefon/strona/godziny). +# https://console.cloud.google.com/ +GOOGLE_PLACES_API_KEY= + +# Anthropic (Claude) API — dla enrich.py i enrich_booking.py. +# https://console.anthropic.com/ +ANTHROPIC_API_KEY= + +# Domyślny model Claude dla scrapera (opcjonalny). +ANTHROPIC_MODEL=claude-haiku-4-5-20251001 + +# ----- Supabase Edge Functions — sekrety (ustawiane przez `supabase secrets set`) ----- +# To NIE są zmienne .env frontendu. Ustaw je w projekcie Supabase, np.: +# supabase secrets set RESEND_API_KEY=... +# Używane przez funkcje: notify-game-alert, send-invites, send-event-sms. + +# Resend — wysyłka e-maili (powiadomienia o grach, zaproszenia). +# https://resend.com/api-keys Nadawca: noreply@bojo.app (zweryfikuj domenę w Resend). +RESEND_API_KEY= + +# SMSAPI.pl — wysyłka SMS (preferowany dostawca dla PL). +# https://ssl.smsapi.pl/ → Ustawienia → Tokeny API +SMSAPI_TOKEN= + +# Twilio — zapasowy dostawca SMS (używany, gdy SMSAPI zawiedzie). +# https://console.twilio.com/ +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_FROM= + +# ----- Logowanie / Auth (konfiguracja w panelu Supabase, nie w .env) ----- +# Authentication → Providers: +# • Google OAuth — Client ID/Secret z Google Cloud Console. +# • Email — włącz "Email" provider dla logowania e-mailem + hasłem i magic-linków. +# Authentication → URL Configuration: +# • Site URL: https://bojo.app +# • Redirect URLs: dodaj https://bojo.app/auth/callback i https://bojo.app/auth/reset +# Authentication → Emails (SMTP): podłącz Resend jako custom SMTP, inaczej maile +# potwierdzające / magic-linki są mocno limitowane (kilka/godzinę) na darmowym planie. diff --git a/.github/dopisz-wzorce.sh b/.github/dopisz-wzorce.sh new file mode 100755 index 00000000..e534e2c4 --- /dev/null +++ b/.github/dopisz-wzorce.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Odsyła wzorce zrzutów na gałąź PR-a. Uruchamiany z katalogu `frontend`. +# +# JEDNA ZASADA: nic nie trafia do repo bez świadomego zatwierdzenia — +# komentarza `/zrzuty ok` w PR-ze albo etykiety `zrzuty:zaakceptuj`. +# +# Dotyczy to tak samo widoków ZMIENIONYCH, jak i CAŁKIEM NOWYCH. Przez chwilę +# nowe wzorce dopisywały się same — z rozumowaniem „nowy zrzut nie ma z czym +# się różnić, więc nie ma czego przeglądać". To rozumowanie jest błędne: +# pierwszy zrzut widoku jest właśnie tym, który warto obejrzeć, bo to on +# staje się wzorcem na zawsze. Jeśli nowy ekran wyszedł krzywo, ciche +# dopisanie utrwala krzywy stan i nikt się o tym nie dowie. +# +# Zrzuty nowych widoków oglądasz w artefakcie (`zrzuty-raport` / +# `scenariusze-raport`), zanim nadasz etykietę. +# +# Argument: dopełniacz do wiadomości commita („zrzutów", „scenariuszy"). +set -euo pipefail + +CO="${1:-zrzutów}" +AKCEPTUJ="${AKCEPTUJ:-0}" +GALAZ="${GALAZ:?brak nazwy gałęzi w GALAZ}" + +NOWE="$(git ls-files --others --exclude-standard e2e/wzorce)" +ZMIENIONE="$(git diff --name-only -- e2e/wzorce)" + +if [[ "$AKCEPTUJ" != "1" ]]; then + if [[ -n "$NOWE$ZMIENIONE" ]]; then + echo "Są wzorce do zatwierdzenia, ale nikt ich jeszcze nie zatwierdził." + echo "Nowe: $(echo "$NOWE" | grep -c . || true)" + echo "Zmienione: $(echo "$ZMIENIONE" | grep -c . || true)" + echo "Obejrzyj obrazki w komentarzu do PR-a i odpisz /zrzuty ok, jeśli są w porządku." + else + echo "Wzorce bez zmian." + fi + exit 0 +fi + +if [[ -z "$NOWE$ZMIENIONE" ]]; then + echo "Wzorce bez zmian — nie ma czego dopisywać." + exit 0 +fi + +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" +git add e2e/wzorce +git commit -m "test: zaakceptowane wzorce $CO" + +# Oba zadania tego workflow potrafią dopisywać wzorce równocześnie, więc +# odrzucony push nie jest błędem, tylko wyścigiem — po prostu próbujemy jeszcze +# raz na świeżej gałęzi. +for PROBA in 1 2 3; do + if git push origin "HEAD:$GALAZ"; then + echo "✓ Wzorce dopisane do $GALAZ" + exit 0 + fi + echo "Push odrzucony (próba $PROBA) — pobieram gałąź i próbuję ponownie." + git pull --rebase origin "$GALAZ" +done + +echo "✗ Nie udało się dopisać wzorców po trzech próbach." >&2 +exit 1 diff --git a/.github/komentarz-zrzutow.js b/.github/komentarz-zrzutow.js new file mode 100644 index 00000000..b1867eb2 --- /dev/null +++ b/.github/komentarz-zrzutow.js @@ -0,0 +1,77 @@ +// Jeden komentarz na zestaw zrzutów, aktualizowany w miejscu. +// +// Osobny plik, a nie `script:` wklejony w YAML-u, bo ta sama treść obsługuje +// dwa zadania (widoki publiczne i scenariusze za logowaniem) — a skopiowany +// kod w dwóch miejscach rozjeżdża się przy pierwszej poprawce. +// +// Komentarz rozpoznajemy po znaczniku (🖼️ / 🎬) w treści, żeby przy każdym +// kolejnym przebiegu nadpisać ten sam wpis zamiast zasypywać wątek. +// +// Zadaniem komentarza jest JEDNO: dać odnośnik do raportu. Niczego nie trzeba +// pisać w odpowiedzi — raport jest do obejrzenia, nie do zatwierdzania. + +module.exports = async ({ github, context, znacznik, tytul, udane, akceptacja, obrazki, numer }) => { + const { owner, repo } = context.repo; + + const naglowek = `### ${znacznik} ${tytul}`; + let tresc; + + if (akceptacja) { + tresc = [ + naglowek, + '', + '**Wzorce zaktualizowane.** Nowe obrazki trafiły na gałąź tego PR-a —', + 'zobaczysz je w zakładce *Files changed*.', + '', + '**Zdejmij etykietę** `zrzuty:zaakceptuj`, żeby kolejne przebiegi znowu', + 'porównywały, zamiast nadpisywać.', + ].join('\n'); + } else if (udane) { + tresc = [ + naglowek, + '', + 'Wszystkie zrzuty zgadzają się ze wzorcami — nic się wizualnie nie ruszyło.', + ].join('\n'); + } else if (obrazki) { + tresc = [ + naglowek, + '', + 'Widoki się zmieniły. To **nie jest** błąd sam w sobie — zmiana może być', + 'dokładnie tym, co chciałeś zrobić.', + obrazki, + '', + '---', + '', + 'Nowe wzorce wejdą do repo dopiero po nadaniu etykiety `zrzuty:zaakceptuj`', + '(w aplikacji GitHuba: **ⓘ** w prawym dolnym rogu → *Labels*).', + '', + '_To zadanie nie blokuje merge\'a ani deployu._', + ].join('\n'); + } else { + // Brak obrazków przy nieudanym przebiegu znaczy coś innego niż zmiana + // wyglądu: test padł zanim doszło do porównania — na asercji zachowania, + // na braku przycisku, na wywróconym logowaniu. Mówienie wtedy „widoki się + // zmieniły" wysyła w złą stronę. + tresc = [ + naglowek, + '', + 'Testy nie doszły do porównania widoków — coś padło wcześniej', + '(asercja zachowania, brak elementu, logowanie). **Nie chodzi o wygląd.**', + '', + 'Szczegóły są w logu przebiegu i w artefakcie z raportem.', + '', + '_To zadanie nie blokuje merge\'a ani deployu._', + ].join('\n'); + } + + const { data: komentarze } = await github.rest.issues.listComments({ + owner, repo, issue_number: numer, per_page: 100, + }); + const moj = komentarze.find((k) => k.user.type === 'Bot' && k.body.includes(znacznik)); + + if (moj) { + await github.rest.issues.updateComment({ owner, repo, comment_id: moj.id, body: tresc }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: numer, body: tresc }); + } +}; diff --git a/.github/podglad-zrzutow.sh b/.github/podglad-zrzutow.sh new file mode 100755 index 00000000..762ea14d --- /dev/null +++ b/.github/podglad-zrzutow.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# Wystawia raport ze zrzutami DO OBEJRZENIA — jedna strona na PR, na telefonie. +# +# PROBLEM, KTÓRY TO ROZWIĄZUJE: raport Playwrighta jest artefaktem, czyli +# zipem. Na telefonie to znaczy: pobierz, rozpakuj, otwórz plik HTML z dysku — +# w praktyce nie do zrobienia. A oglądanie obrazków jest całym sensem tego +# workflow. +# +# JAK: obrazki i `README.md` lecą na osobną gałąź `podglad-zrzutow` (techniczną, +# nigdzie nie mergowaną). GitHub renderuje README katalogu jako stronę — więc +# wchodzisz w jeden odnośnik i przewijasz obrazki. Bez pobierania, bez +# rozpakowywania, bez logowania się na komputer. Artefakt z raportem HTML +# zostaje jako droga zapasowa. +# +# SPRZĄTANIE: raporty starsze niż 7 dni znikają przy najbliższym przebiegu. +# Gałąź nie ma rosnąć w nieskończoność, a po tygodniu PR jest dawno zmergowany. +# +# Uruchamiany z katalogu `frontend`. Zapisuje `podglad-zrzutow.md` — krótki +# markdown z odnośnikiem do raportu, gotowy do wklejenia w komentarz PR-a. +# +# Argument: nazwa zestawu, używana jako nazwa katalogu i nagłówek. +set -euo pipefail + +ZESTAW="${1:-zrzuty}" +PR="${PR:?brak numeru PR w zmiennej PR}" +GALAZ_PODGLADU="podglad-zrzutow" +DNI_WAZNOSCI=7 +WYNIK="podglad-zrzutow.md" + +: > "$WYNIK" + +TMP="$(mktemp -d)" +ZBIOR="$TMP/pliki" +mkdir -p "$ZBIOR" + +# Nowe wzorce — pliki, których nie ma jeszcze w repo. Nie mają „przed", +# więc pokazujemy sam obrazek. +NOWE="$(git ls-files --others --exclude-standard e2e/wzorce || true)" +# Zmienione — tu Playwright zostawia w `test-results` trójkę +# `-expected` / `-actual` / `-diff`. +ROZNICE="$(find test-results -name '*-diff.png' 2>/dev/null | sort || true)" + +while IFS= read -r plik; do + [[ -z "$plik" ]] && continue + # e2e/wzorce/zrzuty-telefon/logowanie.png → nowy__zrzuty-telefon__logowanie.png + # `sed`, nie `tr`: `tr` podmienia znak na znak, więc ukośnik zamieniłby się + # w POJEDYNCZY podkreślnik — a podpis pod obrazkiem rozcina nazwę właśnie + # po podwójnym. + cp "$plik" "$ZBIOR/nowy__$(echo "${plik#e2e/wzorce/}" | sed 's#/#__#g')" +done <<< "$NOWE" + +while IFS= read -r plik; do + [[ -z "$plik" ]] && continue + BAZA="${plik%-diff.png}" + KLUCZ="$(basename "$BAZA")" + for RODZAJ in expected actual diff; do + [[ -f "${BAZA}-${RODZAJ}.png" ]] || continue + cp "${BAZA}-${RODZAJ}.png" "$ZBIOR/roznica__${KLUCZ}__${RODZAJ}.png" + done +done <<< "$ROZNICE" + +# --- Osobny katalog roboczy ----------------------------------------------- +# Bieżący ma wypożyczony kod PR-a i nie chcemy go tknąć. +ADRES="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" +KOPIA="$TMP/repo" + +if git clone --depth 1 --branch "$GALAZ_PODGLADU" "$ADRES" "$KOPIA" 2>/dev/null; then + echo "Gałąź podglądu istnieje." +else + mkdir -p "$KOPIA" + git -C "$KOPIA" init -q + git -C "$KOPIA" remote add origin "$ADRES" + git -C "$KOPIA" checkout -q --orphan "$GALAZ_PODGLADU" + cat > "$KOPIA/README.md" <<'EOF' +# Podgląd zrzutów + +Gałąź techniczna — **nie mergować**. Leżą tu raporty z regresji wizualnej, +po jednym katalogu na pull request. Raporty starsze niż 7 dni kasuje sam +workflow przy najbliższym przebiegu. +EOF +fi + +# --- Sprzątanie starych raportów ------------------------------------------ +DZIS="$(date -u +%s)" +for STEMPEL in "$KOPIA"/pr-*/*/stempel.txt; do + [[ -f "$STEMPEL" ]] || continue + KIEDY="$(cat "$STEMPEL" 2>/dev/null || echo 0)" + WIEK=$(( (DZIS - KIEDY) / 86400 )) + if [[ "$WIEK" -gt "$DNI_WAZNOSCI" ]]; then + echo "Kasuję raport starszy niż ${DNI_WAZNOSCI} dni: $(dirname "$STEMPEL")" + rm -rf "$(dirname "$STEMPEL")" + fi +done +# Katalogi PR-ów, z których nic nie zostało. +find "$KOPIA" -mindepth 1 -maxdepth 1 -type d -name 'pr-*' -empty -delete + +KATALOG="pr-${PR}/${ZESTAW}" +rm -rf "${KOPIA:?}/$KATALOG" + +# Wycinki zmienionych fragmentów — liczone PRZED zbudowaniem strony, bo raport +# najpierw pyta, czy istnieją. +ls "$ZBIOR" 2>/dev/null | { grep '^roznica__.*__diff\.png$' || true; } \ + | sed 's/^roznica__//; s/__diff\.png$//' \ + | while IFS= read -r KLUCZ; do + [[ -z "$KLUCZ" ]] && continue + # Skrypt siedzi w `frontend/e2e`, a nie obok tego pliku, bo potrzebuje + # `pngjs` z `frontend/node_modules` — Node szuka pakietów od położenia + # MODUŁU, nie od katalogu roboczego. + node e2e/wytnij-zmiane.js "$ZBIOR" "$KLUCZ" || true + done + +if [[ -z "$(ls -A "$ZBIOR")" ]]; then + # Nic się nie zmieniło — kasujemy poprzedni raport tego zestawu, żeby nie + # wisiał nieaktualny, i kończymy. + echo "Brak obrazków do pokazania." +else + mkdir -p "$KOPIA/$KATALOG" + cp "$ZBIOR"/* "$KOPIA/$KATALOG/" + echo "$DZIS" > "$KOPIA/$KATALOG/stempel.txt" + + # --- Strona raportu ------------------------------------------------------ + # `|| true` przy KAŻDYM `grep`: przy `set -e` z `pipefail` grep bez trafienia + # zwraca 1 i ubija cały skrypt. Zdarzyło się dokładnie to — przebieg miał + # 40 nowych widoków i zero różnic, `grep '^roznica__'` nie znalazł nic + # i raport nigdy nie powstał, a krok zakończył się po cichu. + LICZBA_NOWYCH="$(ls "$ZBIOR" | grep -c '^nowy__' || true)" + LICZBA_ROZNIC="$(ls "$ZBIOR" | { grep '^roznica__' || true; } | sed 's/__[a-z]*\.png$//' | sort -u | wc -l)" + + { + echo "# Zrzuty — PR #${PR} · ${ZESTAW}" + echo "" + echo "Przebieg [\`${GITHUB_RUN_ID:-?}\`](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID:-0})" + echo " · [wróć do PR-a](https://github.com/${GITHUB_REPOSITORY}/pull/${PR})" + echo "" + echo "Zmienione widoki: **${LICZBA_ROZNIC}** · nowe widoki: **${LICZBA_NOWYCH}**" + echo "" + echo "Raport kasuje się sam po ${DNI_WAZNOSCI} dniach." + echo "" + + if [[ "$LICZBA_ROZNIC" -gt 0 ]]; then + echo "## Zmienione widoki" + echo "" + echo "Dla każdego widoku: najpierw **wycinek** samego zmienionego miejsca" + echo "w czytelnej skali, potem całe strony obok siebie." + echo "" + ls "$ZBIOR" | { grep '^roznica__' || true; } | sed 's/^roznica__//; s/__[a-z]*\.png$//' | sort -u \ + | while IFS= read -r KLUCZ; do + echo "### ${KLUCZ}" + echo "" + + # Wycinek zmienionego fragmentu — najczytelniejsza rzecz w całym + # raporcie. Nakładka „diff" pokazuje obie wersje tekstu jedna na + # drugiej i przy zmianie napisu jest nie do odczytania. + if [[ -f "$ZBIOR/wycinek__${KLUCZ}__expected.png" ]]; then + echo "" + echo "" + echo "" + echo "
było
" + echo "
jest
" + echo "
" + echo "" + fi + + # Całe strony bok w bok — do sprawdzenia, czy zmiana czegoś nie + # rozjechała poza samym miejscem edycji. + echo "" + echo "" + echo "" + echo "
cała strona — było
" + echo "
cała strona — jest
" + echo "
" + echo "" + + if [[ -f "$ZBIOR/roznica__${KLUCZ}__diff.png" ]]; then + echo "
nakładka z podświetlonymi pikselami" + echo "" + echo "" + echo "" + echo "
" + echo "" + fi + done + fi + + if [[ "$LICZBA_NOWYCH" -gt 0 ]]; then + echo "## Nowe widoki" + echo "" + echo "Nie było ich wcześniej, więc nie ma z czym porównywać —" + echo "to jest po prostu to, co widzi użytkownik." + echo "" + ls "$ZBIOR" | { grep '^nowy__' || true; } | sort | while IFS= read -r PLIK; do + PODPIS="$(echo "${PLIK#nowy__}" | sed 's/\.png$//; s/__/ · /g')" + echo "### ${PODPIS}" + echo "" + echo "![${PODPIS}](${PLIK})" + echo "" + done + fi + } > "$KOPIA/$KATALOG/README.md" + + ADRES_RAPORTU="https://github.com/${GITHUB_REPOSITORY}/tree/${GALAZ_PODGLADU}/${KATALOG}" + { + echo "" + echo "**[📖 Otwórz raport](${ADRES_RAPORTU})** — zmienione: ${LICZBA_ROZNIC}, nowe: ${LICZBA_NOWYCH}." + echo "" + echo "Jedna strona z obrazkami, otwiera się na telefonie." + } > "$WYNIK" +fi + +# --- Wypchnięcie ---------------------------------------------------------- +git -C "$KOPIA" config user.name "github-actions[bot]" +git -C "$KOPIA" config user.email "github-actions[bot]@users.noreply.github.com" +git -C "$KOPIA" add -A + +if git -C "$KOPIA" diff --cached --quiet; then + echo "Gałąź podglądu bez zmian." + exit 0 +fi + +git -C "$KOPIA" commit -q -m "podgląd zrzutów: PR #${PR} (${ZESTAW})" + +# Wyścig z drugim zadaniem tego samego przebiegu — ta sama historia, dwa pushe. +for PROBA in 1 2 3; do + if git -C "$KOPIA" push -q origin "HEAD:$GALAZ_PODGLADU" 2>/dev/null; then + echo "✓ Raport wystawiony." + exit 0 + fi + echo "Push podglądu odrzucony (próba $PROBA) — pobieram i próbuję ponownie." + git -C "$KOPIA" pull -q --rebase origin "$GALAZ_PODGLADU" || true +done + +echo "Nie udało się wypchnąć podglądu — raport został w artefakcie." >&2 +exit 0 diff --git a/.github/workflows/analyze-venues-satellite.yml b/.github/workflows/analyze-venues-satellite.yml new file mode 100644 index 00000000..ed5be94e --- /dev/null +++ b/.github/workflows/analyze-venues-satellite.yml @@ -0,0 +1,109 @@ +name: Analiza satelitarna boisk (AI) + +# Wykrywa typ boiska, nawierzchnię, wymiary, infrastrukturę +# na podstawie zdjęcia satelitarnego Mapbox (zoom 18). +# +# Koszt szacunkowy: +# • claude-sonnet-4-6 → ~$0.010–0.015 / boisko (vision input ~1000 tok, output ~300 tok) +# • claude-haiku-4-5 → ~$0.002–0.003 / boisko (tańszy, nieco gorszy przy skomplikowanych obiektach) +# • 400 "quality" boisk (powiat poznański, mają jakieś info) → ~$4–6 Sonnet / ~$1 Haiku +# • Ponowna analiza wszystkich co miesiąc: zalecaj Haiku, Sonnet tylko przy pierwszym przebiegu +# +# Wymagane sekrety: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY +# ANTHROPIC_API_KEY +# MAPBOX_TOKEN (publiczny token z uprawnieniami styles:tiles) + +# Bez harmonogramu — uruchamiany wyłącznie ręcznie. +# +# Poprzedni cron ('0 3 1-7 * 5') miał być pierwszym piątkiem miesiąca, ale +# GitHub łączy dzień miesiąca z dniem tygodnia przez OR, nie AND: job chodził +# przez pierwsze 7 dni miesiąca ORAZ w każdy piątek — ~20 przebiegów na miesiąc, +# każdy przepisujący oceny AI po całej bazie. Analiza kosztuje i nadpisuje dane, +# więc ma być decyzją, nie tłem. +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Tryb podglądu — nic nie zapisuje' + default: true + required: false + type: boolean + limit: + description: 'Maks. liczba boisk (0 = wszystkie kwalifikujące się)' + default: '0' + required: false + type: string + all: + description: 'Przetwórz też już przeanalizowane (--all)' + default: false + required: false + type: boolean + overwrite: + description: 'Nadpisz istniejące pola (surface, is_indoor, lit…) wartościami AI' + default: false + required: false + type: boolean + model: + description: 'Model Claude' + default: 'claude-sonnet-4-6' + required: false + type: choice + options: + - claude-sonnet-4-6 + - claude-haiku-4-5-20251001 + - claude-opus-4-8 + concurrency: + description: 'Równoległe requesty do API (1–2 dla Haiku, max 3 dla Sonnet)' + default: '1' + required: false + type: string + save_images: + description: 'Zapisz zdjęcia satelitarne do Supabase Storage (bucket: venue-satellites)' + default: false + required: false + type: boolean + +jobs: + analyze: + name: "Analiza satelitarna ${{ inputs.dry_run == true && '(DRY RUN)' || '(ZAPIS)' }}" + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Zainstaluj zależności + working-directory: scraper + run: pip install httpx anthropic python-dotenv + + - name: Zbuduj argumenty + id: args + run: | + LIMIT="${{ inputs.limit || '0' }}" + MODEL="${{ inputs.model || 'claude-sonnet-4-6' }}" + CONCURRENCY="${{ inputs.concurrency || '3' }}" + ARGS="--model $MODEL --concurrency $CONCURRENCY" + [ "$LIMIT" != "0" ] && ARGS="$ARGS --limit $LIMIT" + [ "${{ inputs.dry_run }}" = "true" ] && ARGS="$ARGS --dry-run" + [ "${{ inputs.all }}" = "true" ] && ARGS="$ARGS --all" + [ "${{ inputs.overwrite }}" = "true" ] && ARGS="$ARGS --overwrite" + [ "${{ inputs.save_images }}" = "true" ] && ARGS="$ARGS --save-images" + echo "args=$ARGS" >> $GITHUB_OUTPUT + echo "Argumenty: $ARGS" + + - name: Uruchom analizę satelitarną + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + MAPBOX_TOKEN: ${{ secrets.MAPBOX_TOKEN }} + run: python analyze_venues.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ba1a8246 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,128 @@ +# CI dla kodu aplikacji — typecheck, testy, spójność dokumentacji. +# Deterministyczna bramka: agent (i człowiek) dostaje pass/fail zamiast +# polegać na pamiętaniu o uruchomieniu testów przed pushem na produkcję. +# +# `npm run build` JEST w CI. Przez długi czas stało tu, że nie może być, bo +# wymaga kluczy Supabase — nieprawda: klient ma wartości zapasowe, a build +# potrzebuje tylko, żeby zmienne w ogóle istniały. Dwie atrapy wystarczą. +# +# To nie jest bramka dla ozdoby: `useSearchParams()` w komponencie klienckim +# wywraca WYŁĄCZNIE build produkcyjny (`missing-suspense-with-csr-bailout`), +# lokalnie i w testach przechodzi. Ta pułapka raz już zepsuła produkcję. +name: CI + +on: + push: + # Gałęzie robocze też, nie tylko master. Zdarzenia `pull_request` potrafią + # się nie uruchomić (2026-08-06: kilka PR-ów z rzędu nie dostało żadnego + # przebiegu, choć ten sam kod po merge'u przechodził na masterze). Push jest + # wyzwalaczem, który zadziałał za każdym razem — a bramka, która czasem + # milczy, jest gorsza od jej braku, bo wygląda jak zieleń. + # + # `claude/sql/**` pominięte: te gałęzie niosą wyłącznie zapytanie do bazy, + # kod aplikacji jest na nich identyczny jak na masterze. + branches: + - master + - 'claude/**' + paths-ignore: + - 'supabase/zapytania/**' + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Zależności + working-directory: frontend + run: npm ci + + - name: Typecheck + working-directory: frontend + run: npx tsc --noEmit + + - name: Lint (ESLint) + working-directory: frontend + # Ostrzeżenia dopuszczone, błędy nie. Reguły i powody: frontend/.eslintrc.js + run: npx next lint --dir src + + - name: Testy (Vitest) + working-directory: frontend + run: npm test + + - name: Build produkcyjny + working-directory: frontend + env: + # Atrapy — build nie łączy się z bazą, sprawdza kompilację i prerender. + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + run: npm run build + + - name: Spójność dokumentacji + run: node scripts/check-docs.mjs + + # Osobne zadanie, nie krok w `test`: przeglądarka i jej zależności systemowe + # kosztują ~1,5 min instalacji, a bramka `test` ma zostać szybka. Oba zadania + # biegną równolegle i oba muszą być zielone. + e2e: + name: Klikalność (Playwright) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Zależności + working-directory: frontend + run: npm ci + + - name: Przeglądarka + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Build produkcyjny + working-directory: frontend + env: + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + run: npm run build + + - name: Testy klikalności + working-directory: frontend + # Tylko projekty klikalności. Zrzuty ekranu mają własny workflow + # (`wizualne.yml`) i CELOWO nie blokują tej bramki: zmiana wyglądu + # bywa zamierzona, a wtedy czerwone CI zmusza do „naprawiania" czegoś, + # co jest w porządku. + run: npx playwright test --project=telefon --project=komputer + + - name: Ślady po nieudanych testach + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 + + # Migracje od zera. Tanie (~30 s, bez Dockera) i łapie klasę, która dwa razy + # wywróciła produkcję: migracja odwołująca się do rzeczy, którą wcześniejsza + # usunęła, albo tworząca obiekt istniejący już od poprzedniej. Na działającej + # bazie tego nie widać — wychodzi dopiero przy odtwarzaniu schematu od nowa. + schemat: + name: Migracje od zera + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Migracje, konta testowe i seed regresyjny + run: ./scripts/baza-testowa.sh diff --git a/.github/workflows/classify.yml b/.github/workflows/classify.yml new file mode 100644 index 00000000..0d6d4e9d --- /dev/null +++ b/.github/workflows/classify.yml @@ -0,0 +1,63 @@ +name: Klasyfikacja obiektów (map_visibility) + +# Ustawia map_visibility na podstawie danych w bazie: +# public — boisko do sportu zespołowego z rezerwacją lub dobrymi danymi +# organizer_only — wygląda na boisko ale mało danych (warto sprawdzić) +# hidden — siłownia, bilard, brak danych itp. +# +# Wymagane sekrety: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Tryb podglądu — nic nie zapisuje' + default: true + required: false + type: boolean + limit: + description: 'Maks. obiektów (0 = wszystkie)' + default: '0' + required: false + type: string + reset: + description: 'Nadpisz też obiekty z już ustawioną visibility (domyślnie: tylko public)' + default: false + required: false + type: boolean + +jobs: + classify: + name: Klasyfikacja (${{ inputs.dry_run == true && 'DRY RUN' || 'ZAPIS' }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }}" + [ "${{ inputs.dry_run }}" = "true" ] && ARGS="$ARGS --dry-run" + [ "${{ inputs.reset }}" = "true" ] && ARGS="$ARGS --reset" + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run classifier + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python classify.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/diagnoza-mapy.yml b/.github/workflows/diagnoza-mapy.yml new file mode 100644 index 00000000..827e1025 --- /dev/null +++ b/.github/workflows/diagnoza-mapy.yml @@ -0,0 +1,51 @@ +name: Diagnoza mapy (tylko odczyt) + +# Odtwarza zapytanie mapy warunek po warunku i wypisuje lejek do logu. +# Nic nie zapisuje — same GET-y do PostgREST. Dzięki temu diagnostykę bazy +# da się uruchomić bez przeklejania wyników SQL z Supabase ręcznie. +# +# Wymagane sekrety: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + bbox: + description: 'Prostokąt lat_min,lat_max,lng_min,lng_max (puste = cała baza)' + default: '50.20,52.30,21.50,24.20' + required: false + type: string + source: + description: 'Filtr source (osm | manual | puste = dowolny)' + default: 'osm' + required: false + type: string + +jobs: + diagnoza: + name: Diagnoza mapy + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install httpx python-dotenv + + - name: Run diagnoza + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: | + python diagnoza_mapy.py \ + --bbox "${{ inputs.bbox }}" \ + --source "${{ inputs.source }}" diff --git a/.github/workflows/enrich-all.yml b/.github/workflows/enrich-all.yml new file mode 100644 index 00000000..c820f018 --- /dev/null +++ b/.github/workflows/enrich-all.yml @@ -0,0 +1,162 @@ +name: Pełne wzbogacanie danych (wszystkie źródła) + +# Uruchamia wszystkie enrichery po kolei w optymalnej kolejności: +# 1. Nominatim → adres, kod pocztowy, dzielnica (całkowicie bezpłatne) +# 2. Google Places → telefon, strona WWW, godziny otwarcia (bezpłatne w limicie $200/mc) +# 3. Claude AI → e-mail, sposób rezerwacji, AI-summary (płatne: ~$10/1000 wyszukań) +# +# Kolejność ma znaczenie: Nominatim idzie pierwszy, żeby Claude dostał +# porządny adres ("ul. Roosevelta 18") zamiast "Poznań" — web search +# trafia wtedy w znacznie lepsze wyniki. +# +# Uruchom z dry_run=true przed pierwszym prawdziwym uruchomieniem! +# +# Wymagane sekrety: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY +# GOOGLE_PLACES_API_KEY (dla kroku Google) +# ANTHROPIC_API_KEY (dla kroku Claude) + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Tryb podglądu — nic nie zapisuje (zalecane przy pierwszym uruchomieniu)' + default: true + required: false + type: boolean + limit: + description: 'Maks. obiektów / grup na każdy krok (0 = wszystkie)' + default: '50' + required: false + type: string + skip_geocode: + description: 'Pomiń krok Nominatim (adresy/dzielnice)' + default: false + required: false + type: boolean + skip_google: + description: 'Pomiń krok Google Places' + default: false + required: false + type: boolean + skip_claude: + description: 'Pomiń krok Claude AI (najdroższy)' + default: false + required: false + type: boolean + claude_rerun_all: + description: 'Claude --all: przetwórz też już wzbogacone rekordy (fix gdy ai_enriched_at ustawione na złych rekordach)' + default: false + required: false + type: boolean + claude_model: + description: 'Model Claude dla ostatniego kroku' + default: 'claude-haiku-4-5-20251001' + required: false + type: choice + options: + - claude-haiku-4-5-20251001 + - claude-sonnet-4-6 + +jobs: + # ── KROK 1: Nominatim — adresy i dzielnice ─────────────────────────────── + geocode: + name: "1/3 · Nominatim (adresy/dzielnice) ${{ inputs.dry_run == true && '(DRY RUN)' || '(ZAPIS)' }}" + runs-on: ubuntu-latest + timeout-minutes: 60 + if: ${{ inputs.skip_geocode != true }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }}" + [ "${{ inputs.dry_run }}" = "true" ] && ARGS="$ARGS --dry-run" + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run geocode enrichment + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich_geocode.py ${{ steps.args.outputs.args }} + + # ── KROK 2: Google Places — telefon, WWW, godziny ──────────────────────── + google: + name: "2/3 · Google Places ${{ inputs.dry_run == true && '(DRY RUN)' || '(ZAPIS)' }}" + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: geocode + if: ${{ always() && inputs.skip_google != true }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }} --concurrency 5" + [ "${{ inputs.dry_run }}" = "true" ] && ARGS="$ARGS --dry-run" + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run Google enrichment + working-directory: scraper + env: + GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich_google.py ${{ steps.args.outputs.args }} + + # ── KROK 3: Claude AI — e-mail, rezerwacja, summary ────────────────────── + claude: + name: "3/3 · Claude AI ${{ inputs.dry_run == true && '(DRY RUN)' || '(ZAPIS)' }}" + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: google + if: ${{ always() && inputs.skip_claude != true }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }} --model ${{ inputs.claude_model }}" + [ "${{ inputs.dry_run }}" = "true" ] && ARGS="$ARGS --dry-run" + [ "${{ inputs.claude_rerun_all }}" = "true" ] && ARGS="$ARGS --all" || ARGS="$ARGS --require-empty" + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run Claude enrichment + working-directory: scraper + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/enrich-booking.yml b/.github/workflows/enrich-booking.yml new file mode 100644 index 00000000..f89403ec --- /dev/null +++ b/.github/workflows/enrich-booking.yml @@ -0,0 +1,81 @@ +name: Booking System Extractor (website → Claude) + +# Reads HTML from each venue's website and uses Claude to detect the booking +# system type (phone / email / own system / external platform like Hally/Booksy). +# NO web search — Claude only reads the fetched HTML, so it's very cheap. +# +# Run AFTER enrich.py has had a chance to find website URLs for venues. +# Writes to field_outreach: booking_system, booking_url, booking_provider. +# +# Required secrets: +# ANTHROPIC_API_KEY +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + limit: + description: 'Maks. obiektów (0 = wszystkie ze stroną WWW)' + default: '0' + required: false + type: string + dry_run: + description: 'Tryb podglądu — nic nie zapisuje' + default: true + required: false + type: boolean + reprocess: + description: 'Przetworz ponownie już przeanalizowane' + default: false + required: false + type: boolean + model: + description: 'Model Claude (pusty = haiku 4.5)' + default: '' + required: false + type: string + +jobs: + enrich-booking: + name: Booking (${{ inputs.dry_run == true && 'DRY RUN' || 'ZAPIS' }}, limit=${{ inputs.limit }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }} --concurrency 2" + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS="$ARGS --dry-run" + fi + if [ "${{ inputs.reprocess }}" = "true" ]; then + ARGS="$ARGS --all" + fi + if [ -n "${{ inputs.model }}" ]; then + ARGS="$ARGS --model ${{ inputs.model }}" + fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run booking enrichment + working-directory: scraper + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich_booking.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/enrich-geocode.yml b/.github/workflows/enrich-geocode.yml new file mode 100644 index 00000000..7962c177 --- /dev/null +++ b/.github/workflows/enrich-geocode.yml @@ -0,0 +1,96 @@ +name: Geocode Enrichment (Nominatim, bezpłatne) + +# Uzupełnia adres (ul. Roosevelta 18), kod pocztowy i dzielnicę dla obiektów +# z lat/lng które nie mają tych danych. Używa Nominatim (OpenStreetMap) — +# całkowicie bezpłatne, bez klucza API. +# +# Limit: 1 zapytanie/sek → ~25 min dla 1426 obiektów. +# Uruchamia się też automatycznie w każdy poniedziałek o 4:00 UTC, żeby +# nowo dodane obiekty (z importu OSM) dostały uzupełnione dane. +# +# Wymagane sekrety repozytorium: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + mode: + description: 'Co uzupełniać' + required: false + default: 'missing' + type: choice + options: + - missing # brakujące adresy LUB dzielnice + - missing_district # tylko brakujące dzielnice (zachowaj adresy) + - overwrite # nadpisz wszystko nowym wynikiem z Nominatim + limit: + description: 'Maks. obiektów do przetworzenia (0 = wszystkie)' + default: '0' + required: false + type: string + dry_run: + description: 'Tryb podglądu — wypisuje zmiany, nic nie zapisuje' + default: true + required: false + type: boolean + + schedule: + # Każdy poniedziałek 4:00 UTC — uzupełnia nowo dodane obiekty + - cron: '0 4 * * 1' + +jobs: + geocode: + name: >- + Geocode + ${{ github.event_name == 'schedule' && '(harmonogram)' || '' }} + ${{ inputs.dry_run == true && '· DRY RUN' || '· ZAPIS' }} + ${{ inputs.limit && inputs.limit != '0' && format('· limit={0}', inputs.limit) || '' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + # Scheduled run: only fill in missing data, no dry-run, no limit + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "args=" >> $GITHUB_OUTPUT + exit 0 + fi + + ARGS="" + if [ "${{ inputs.limit }}" != "0" ] && [ -n "${{ inputs.limit }}" ]; then + ARGS="$ARGS --limit ${{ inputs.limit }}" + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS="$ARGS --dry-run" + fi + if [ "${{ inputs.mode }}" = "missing_district" ]; then + ARGS="$ARGS --missing-district" + fi + if [ "${{ inputs.mode }}" = "overwrite" ]; then + ARGS="$ARGS --overwrite" + fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run geocode enrichment + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich_geocode.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/enrich-google.yml b/.github/workflows/enrich-google.yml new file mode 100644 index 00000000..2747aa1e --- /dev/null +++ b/.github/workflows/enrich-google.yml @@ -0,0 +1,77 @@ +name: Google Venue Enrichment (free) + +# Uruchamiany ręcznie z zakładki Actions. Dla istniejących obiektów pobiera z +# Google Places telefon, stronę WWW i godziny otwarcia (Find Place + Details). +# DARMOWE w ramach kredytu Google $200/mc (~11k zapytań). Uruchom to PRZED +# wzbogacaniem przez Claude — Google da telefon/www/godziny za darmo, a Claude +# dobierze tylko e-mail i sposób rezerwacji (taniej). +# +# Wymagane sekrety repozytorium: +# GOOGLE_PLACES_API_KEY (Google Cloud → APIs & Services → Credentials) +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + limit: + description: 'Maks. grup lokalizacyjnych (0 = wszystkie)' + default: '0' + required: false + type: string + dry_run: + description: 'Tryb podglądu — nic nie zapisuje' + default: true + required: false + type: boolean + require_all: + description: 'Tylko obiekty bez telefonu I strony (mniej obiektów)' + default: false + required: false + type: boolean + radius: + description: 'Promień wyszukiwania Google (metry, domyślnie 200)' + default: '200' + required: false + type: string + +jobs: + enrich-google: + name: Google (${{ inputs.dry_run == true && 'DRY RUN' || 'ZAPIS' }}, limit=${{ inputs.limit }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }} --concurrency 5 --radius ${{ inputs.radius }}" + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS="$ARGS --dry-run" + fi + if [ "${{ inputs.require_all }}" = "true" ]; then + ARGS="$ARGS --require-all" + fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run Google enrichment + working-directory: scraper + env: + GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich_google.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/enrich-photos.yml b/.github/workflows/enrich-photos.yml new file mode 100644 index 00000000..379f8676 --- /dev/null +++ b/.github/workflows/enrich-photos.yml @@ -0,0 +1,80 @@ +name: Photo Enrichment + +# Finds venue photos in priority order: +# 1. Google Places API — real, high-quality photos (needs GOOGLE_PLACES_API_KEY) +# 2. Wikimedia Commons — CC-licensed photos, free (no key needed) +# 3. Mapbox satellite — aerial fallback for every venue (needs MAPBOX_TOKEN) +# +# Stores the best URL in fields.photo_url + fields.photo_source. +# +# Required secrets: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY +# GOOGLE_PLACES_API_KEY (optional — enables Google Photos) +# MAPBOX_TOKEN (optional — enables satellite fallback) + +on: + workflow_dispatch: + inputs: + strategy: + description: 'Photo source (auto = Google → Wikimedia → satellite)' + default: 'auto' + required: false + type: choice + options: + - auto + - google + - wikimedia + - satellite + limit: + description: 'Max venues to process (0 = all missing photos)' + default: '50' + required: false + type: string + dry_run: + description: 'Preview — show found URLs, do NOT write to DB' + default: true + required: false + type: boolean + +jobs: + enrich-photos: + name: >- + Photos + · ${{ inputs.strategy }} + ${{ inputs.dry_run == true && '· DRY RUN' || '· ZAPIS' }} + ${{ inputs.limit != '0' && format('· limit={0}', inputs.limit) || '' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--strategy ${{ inputs.strategy }}" + if [ "${{ inputs.dry_run }}" = "true" ]; then ARGS="$ARGS --dry-run"; fi + if [ "${{ inputs.limit }}" != "0" ]; then ARGS="$ARGS --limit ${{ inputs.limit }}"; fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run photo enrichment + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + MAPBOX_TOKEN: ${{ secrets.MAPBOX_TOKEN }} + run: python enrich_photos.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/enrich-venues.yml b/.github/workflows/enrich-venues.yml new file mode 100644 index 00000000..913d1d65 --- /dev/null +++ b/.github/workflows/enrich-venues.yml @@ -0,0 +1,92 @@ +name: AI Venue Enrichment (Claude) + +# Uruchamiany ręcznie z zakładki Actions w GitHubie (działa też z telefonu). +# Dla każdej lokalizacji (adres) wyszukuje w sieci dane kontaktowe i sposób +# rezerwacji, a wyniki wgrywa do Supabase (fields + field_outreach). +# +# Wymagane sekrety repozytorium: +# ANTHROPIC_API_KEY (console.anthropic.com → API keys) +# SUPABASE_URL (np. https://xxxx.supabase.co) +# SUPABASE_SERVICE_ROLE_KEY (Supabase → Settings → API → service_role) +# +# ⚠ KOSZT: web search ≈ $10/1000 wyszukań. Uruchom najpierw z dry_run=true +# i małym limitem, żeby sprawdzić jakość wyników zanim puścisz całość. + +on: + workflow_dispatch: + inputs: + limit: + description: 'Maks. grup adresowych do przetworzenia (0 = wszystkie)' + default: '10' + required: false + type: string + dry_run: + description: 'Tryb podglądu — wypisuje wyniki, nic nie zapisuje' + default: true + required: false + type: boolean + require_empty: + description: 'Tylko lokalizacje bez telefonu I e-maila (tańsze)' + default: false + required: false + type: boolean + model: + description: 'Model Claude (haiku = tani, sonnet = dokładniejszy)' + default: 'claude-haiku-4-5-20251001' + required: false + type: choice + options: + - claude-haiku-4-5-20251001 + - claude-sonnet-4-6 + concurrency: + description: 'Równoległe zapytania (1 = bezpieczne dla 50k TPM, 2-3 jeśli masz wyższy limit)' + default: '1' + required: false + type: string + max_searches: + description: 'Maks. wyszukań web na grupę adresową (2 = szybsze/tańsze, 4 = więcej danych)' + default: '2' + required: false + type: string + + +jobs: + enrich: + name: Wzbogacanie danych (${{ inputs.dry_run == true && 'DRY RUN' || 'ZAPIS' }}, limit=${{ inputs.limit }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--limit ${{ inputs.limit }} --concurrency ${{ inputs.concurrency }} --max-searches ${{ inputs.max_searches }} --model ${{ inputs.model }}" + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS="$ARGS --dry-run" + fi + if [ "${{ inputs.require_empty }}" = "true" ]; then + ARGS="$ARGS --require-empty" + fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run enrichment + working-directory: scraper + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python enrich.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/fix-coords.yml b/.github/workflows/fix-coords.yml new file mode 100644 index 00000000..3bd18eb4 --- /dev/null +++ b/.github/workflows/fix-coords.yml @@ -0,0 +1,76 @@ +name: Fix GPS Coordinates (Forward Geocoding) + +# Forward-geocodes venue addresses via Nominatim and updates coordinates +# when the stored lat/lng differs by more than --threshold km. +# +# Useful after bulk-importing manually-entered venues (seeds, spreadsheets) +# where GPS was estimated rather than verified. +# +# Required secrets: +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + threshold: + description: 'Min distance (km) to trigger update' + default: '1.0' + required: false + type: string + source: + description: 'Filter by source (manual | osm | — leave blank for all)' + default: 'manual' + required: false + type: string + limit: + description: 'Max venues to process (0 = all)' + default: '100' + required: false + type: string + dry_run: + description: 'Preview mode — show mismatches, do NOT write to DB' + default: true + required: false + type: boolean + +jobs: + fix-coords: + name: >- + Fix GPS + ${{ inputs.dry_run == true && '· DRY RUN' || '· ZAPIS' }} + ${{ inputs.source != '' && format('· source={0}', inputs.source) || '' }} + ${{ inputs.limit != '0' && format('· limit={0}', inputs.limit) || '' }} + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv + + - name: Build arguments + id: args + run: | + ARGS="--threshold ${{ inputs.threshold }}" + if [ "${{ inputs.dry_run }}" = "true" ]; then ARGS="$ARGS --dry-run"; fi + if [ -n "${{ inputs.source }}" ]; then ARGS="$ARGS --source ${{ inputs.source }}"; fi + if [ "${{ inputs.limit }}" != "0" ]; then ARGS="$ARGS --limit ${{ inputs.limit }}"; fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run fix-coords + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python fix_coords.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/import-fields.yml b/.github/workflows/import-fields.yml new file mode 100644 index 00000000..be6a525a --- /dev/null +++ b/.github/workflows/import-fields.yml @@ -0,0 +1,34 @@ +name: Import boisk (OSM + Google) + +# Uruchamiany ręcznie z zakładki Actions w GitHubie (działa też z telefonu). +# Pobiera boiska z OpenStreetMap (i opcjonalnie Google Places) i wgrywa je +# do Supabase. Wymaga sekretów repozytorium: +# SUPABASE_URL (np. https://xxxx.supabase.co) +# SUPABASE_SERVICE_ROLE_KEY (Supabase → Settings → API → service_role) +# GOOGLE_PLACES_API_KEY (opcjonalnie — dla nazwanych obiektów) +on: + workflow_dispatch: + +jobs: + import: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: scraper + run: pip install -r requirements.txt + + - name: Run importer + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + GOOGLE_PLACES_API_KEY: ${{ secrets.GOOGLE_PLACES_API_KEY }} + run: python scraper.py diff --git a/.github/workflows/import-osm-polska.yml b/.github/workflows/import-osm-polska.yml new file mode 100644 index 00000000..3971a67a --- /dev/null +++ b/.github/workflows/import-osm-polska.yml @@ -0,0 +1,117 @@ +name: Import boisk z OSM — CAŁA POLSKA + +# Odpala import osobno dla każdego z 16 województw, równolegle. +# +# Dlaczego nie jednym plikiem `poland-latest.osm.pbf`. Geofabrik taki wystawia, +# ale to ~1,7 GB, a `osmium` musi przy czytaniu trzymać w pamięci indeks pozycji +# węzłów — na runnerze GitHuba (16 GB) to gra o to, czy się zmieści. Do tego +# jeden błąd w połowie przewala cały przebieg i trzeba zaczynać od nowa. +# +# Podział na województwa daje trzy rzeczy: mieści się w pamięci, kończy szybciej +# (kilka regionów naraz zamiast jednego długiego czytania) i izoluje awarie — +# jeśli padnie mazowieckie, pozostałe piętnaście i tak wejdzie. +# +# ZACZNIJ OD dry_run=true. Dostaniesz 16 raportów bez dotykania bazy. +# +# UWAGA co do Poznania: przed importem wielkopolskiego uruchom +# `supabase/ukryj-stary-poznan.sql`, inaczej na mapie staną dwie pinezki na +# każdym poznańskim boisku — stary wiersz i nowy z OSM to dla bazy dwa różne +# obiekty (klucz to `source` + `external_id`). + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Tylko raporty — nic nie zapisuje' + default: true + required: false + type: boolean + gate: + description: 'Jak szeroko publikować na mapie' + default: 'srednia' + required: false + type: choice + options: + - waska # nazwa instytucji ORAZ znana nawierzchnia + - srednia # nazwa instytucji ALBO znana nawierzchnia + - szeroka # wszystko, co ma sport zespołowy i miejscowość + rownolegle: + description: 'Ile województw naraz (Geofabrik nie lubi zbyt wielu naraz)' + default: '4' + required: false + type: choice + options: ['2', '4', '8'] + +jobs: + import: + name: ${{ matrix.region }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + # Jedno padnięte województwo nie zatrzymuje pozostałych. + fail-fast: false + # Ograniczenie równoległości jest po stronie Geofabrika, nie naszej: + # szesnaście jednoczesnych pobrań po 100–200 MB z jednego serwera to + # prosta droga do odcięcia. + max-parallel: ${{ fromJSON(inputs.rownolegle || '4') }} + matrix: + region: + - dolnoslaskie + - kujawsko-pomorskie + - lubelskie + - lubuskie + - lodzkie + - malopolskie + - mazowieckie + - opolskie + - podkarpackie + - podlaskie + - pomorskie + - slaskie + - swietokrzyskie + - warminsko-mazurskie + - wielkopolskie + - zachodniopomorskie + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: scraper/requirements.txt + + - name: Zależności + working-directory: scraper + run: pip install -r requirements.txt + + - name: Import ${{ matrix.region }} + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: | + python import_osm_pbf.py \ + --region "${{ matrix.region }}" \ + --gate "${{ inputs.gate }}" \ + ${{ inputs.dry_run == true && '--dry-run' || '' }} + + podsumowanie: + name: Podsumowanie + needs: import + if: always() + runs-on: ubuntu-latest + steps: + - name: Wynik + run: | + echo "Import zakończony ze statusem: ${{ needs.import.result }}" + echo "" + echo "Raporty per województwo są w logach zadań wyżej —" + echo "szukaj sekcji RAPORT: liczba boisk, nazwy instytucjonalne," + echo "nawierzchnia, bramka publikacji." + if [ "${{ needs.import.result }}" != "success" ]; then + echo "" + echo "Część województw padła. Nie trzeba powtarzać całości:" + echo "uruchom workflow 'Import boisk z OSM' dla pojedynczych regionów." + fi diff --git a/.github/workflows/import-osm.yml b/.github/workflows/import-osm.yml new file mode 100644 index 00000000..7de1b1a6 --- /dev/null +++ b/.github/workflows/import-osm.yml @@ -0,0 +1,94 @@ +name: Import boisk z OSM (Geofabrik, bezpłatnie) + +# Pobiera regionalny wycinek OpenStreetMap z Geofabrik i importuje boiska +# do Supabase. Zero opłat i zero limitów API — w przeciwieństwie do Overpassa, +# który przy całym województwie kończy się timeoutem. +# +# Nazwy powstają ze ZŁĄCZENIA PRZESTRZENNEGO: skrypt sprawdza, w czym leży +# boisko (ośrodek sportu, szkoła, klub) i buduje nazwę z kontekstu, zamiast +# klepać „Boisko — piłka nożna". Sport i nawierzchnia pochodzą z tagów OSM. +# ŻADNEGO udziału AI. +# +# ZACZNIJ OD dry_run=true — dostaniesz sam raport w logu, bez dotykania bazy. +# +# Wymagane sekrety (tylko przy zapisie): +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + region: + description: 'Województwo (nazwy wycinków Geofabrik — bez polskich znaków)' + default: 'lubelskie' + required: true + type: choice + options: + - dolnoslaskie + - kujawsko-pomorskie + - lubelskie + - lubuskie + - lodzkie + - malopolskie + - mazowieckie + - opolskie + - podkarpackie + - podlaskie + - pomorskie + - slaskie + - swietokrzyskie + - warminsko-mazurskie + - wielkopolskie + - zachodniopomorskie + dry_run: + description: 'Tylko raport — nic nie zapisuje' + default: true + required: false + type: boolean + limit: + description: 'Maks. obiektów do zapisu (0 = wszystkie)' + default: '0' + required: false + type: string + gate: + description: 'Jak szeroko publikować na mapie' + default: 'srednia' + required: false + type: choice + options: + - waska # nazwa instytucji ORAZ znana nawierzchnia + - srednia # nazwa instytucji ALBO znana nawierzchnia + - szeroka # wszystko, co ma sport zespołowy i miejscowość + +jobs: + import: + name: >- + Import ${{ inputs.region }} + ${{ inputs.dry_run == true && '· RAPORT' || '· ZAPIS' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: scraper/requirements.txt + + - name: Zależności + working-directory: scraper + run: pip install -r requirements.txt + + - name: Import + working-directory: scraper + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: | + python import_osm_pbf.py \ + --region "${{ inputs.region }}" \ + --limit "${{ inputs.limit }}" \ + --gate "${{ inputs.gate }}" \ + ${{ inputs.dry_run == true && '--dry-run' || '' }} diff --git a/.github/workflows/scrape-booking.yml b/.github/workflows/scrape-booking.yml new file mode 100644 index 00000000..e26db36c --- /dev/null +++ b/.github/workflows/scrape-booking.yml @@ -0,0 +1,88 @@ +name: Odwrotny scraper rezerwacji (strony → obiekty) + +# Odkrywa obiekty sportowe z linkami do rezerwacji i zapisuje je w bazie. +# Źródła: AI/web-search (Claude odkrywa organicznie) + POSiR/orliki (dane publiczne). +# Komercyjne platformy rezerwacyjne NIE są scrapowane — chronione prawem +# sui generis do baz danych (dyrektywa 96/9/WE, ustawa o prawie autorskim art. 102-104). +# +# Wymagane sekrety repozytorium: +# ANTHROPIC_API_KEY +# SUPABASE_URL +# SUPABASE_SERVICE_ROLE_KEY + +on: + workflow_dispatch: + inputs: + source: + description: 'Źródło danych' + default: 'all' + required: true + type: choice + options: + - all + - ai + - posir + limit: + description: 'Maks. obiektów (0 = wszystkie)' + default: '0' + required: false + type: string + dry_run: + description: 'Tryb podglądu — nic nie zapisuje' + default: true + required: false + type: boolean + no_add: + description: 'Nie twórz nowych obiektów — tylko wzbogacaj istniejące' + default: false + required: false + type: boolean + model: + description: 'Model Claude (pusty = haiku 4.5)' + default: '' + required: false + type: string + +jobs: + scrape-booking: + name: Reverse Booking (${{ inputs.dry_run == true && 'DRY RUN' || 'ZAPIS' }}, source=${{ inputs.source }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: scraper/requirements.txt + + - name: Install dependencies + working-directory: scraper + run: pip install httpx python-dotenv tenacity + + - name: Build arguments + id: args + run: | + ARGS="--source ${{ inputs.source }} --limit ${{ inputs.limit }}" + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS="$ARGS --dry-run" + fi + if [ "${{ inputs.no_add }}" = "true" ]; then + ARGS="$ARGS --no-add" + fi + if [ -n "${{ inputs.model }}" ]; then + ARGS="$ARGS --model ${{ inputs.model }}" + fi + echo "args=$ARGS" >> $GITHUB_OUTPUT + + - name: Run reverse booking scraper + working-directory: scraper + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: python scrape_booking.py ${{ steps.args.outputs.args }} diff --git a/.github/workflows/sql.yml b/.github/workflows/sql.yml new file mode 100644 index 00000000..735b69ac --- /dev/null +++ b/.github/workflows/sql.yml @@ -0,0 +1,161 @@ +name: SQL (tylko odczyt) + +# Uruchamia zapytanie SQL na produkcyjnej bazie i wypisuje wynik do logu. +# +# Po co: agent pracujący w tym repo nie ma sieciowego dostępu do Supabase. +# Bez tego każda diagnostyka wygląda tak, że agent pisze plik .sql, człowiek +# wkleja go do SQL Editora i odsyła wynik. Tutaj agent commituje zapytanie +# i odpala workflow sam. +# +# Bezpieczeństwo: +# - łączy się rolą TYLKO DO ODCZYTU (sekret SUPABASE_DB_URL_RO), +# - transakcja jest dodatkowo wymuszona jako READ ONLY, +# - klucz nigdy nie opuszcza sekretów GitHuba. +# Setup roli i sekretu: supabase/zapytania/README.md +# +# Wymagany sekret: +# SUPABASE_DB_URL_RO postgresql://… (rola bez prawa zapisu) + +on: + # Główny tryb pracy agenta: push zapytania na gałąź roboczą sam uruchamia + # workflow. Token agenta ma prawo czytać Actions, ale NIE odpalać ich przez + # API (`403 Resource not accessible by integration`) — push jest jedynym + # wyzwalaczem, który agent kontroluje sam. Bez tego każde sprawdzenie stanu + # bazy wymagałoby kliknięcia przez człowieka. + # + # Gałąź jest odrębna i jednorazowa, więc historia mastera zostaje czysta. + push: + branches: ['claude/sql/**'] + paths: ['supabase/zapytania/biezace.sql'] + + workflow_dispatch: + inputs: + plik: + description: 'Ścieżka do pliku .sql w repo (np. supabase/zapytania/mapa.sql)' + required: false + type: string + zapytanie: + description: 'Zapytanie SQL wprost (używane, gdy „plik" jest pusty)' + required: false + type: string + +jobs: + sql: + name: SQL + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sprawdź sekret + env: + DB_URL: ${{ secrets.SUPABASE_DB_URL_RO }} + run: | + if [ -z "$DB_URL" ]; then + echo "::error::Brak sekretu SUPABASE_DB_URL_RO. Instrukcja: supabase/zapytania/README.md" + exit 1 + fi + + # Sprawdzamy WYŁĄCZNIE hasło, nie cały kształt adresu — connection + # string z Supabase bywa z portem, parametrami (`?sslmode=require`) + # i bez nich, a zgadywanie pełnego wzorca kończy się odrzucaniem + # poprawnych adresów. + # + # Powód, dla którego hasło musi być URI-bezpieczne, nie jest + # kosmetyczny: przy `+`, `/` albo `=` psql nie rozpoznaje adresu jako + # URI, przechodzi na parsowanie „klucz=wartość" i WYPISUJE FRAGMENT + # HASŁA w komunikacie błędu. Maskowanie sekretów tego nie łapie, bo + # to część sekretu, nie całość — hasło ląduje w publicznym logu. + # Zdarzyło się raz; ten warunek jest po to, żeby nie zdarzyło się drugi. + # Obcinamy TYLKO początek i koniec. `tr -d` zjadłoby spacje + # rozdzielające pary w formacie klucz=wartość i rozwaliło adres. + CZYSTY=$(printf '%s' "$DB_URL" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + # Supabase w panelu Connect podaje adres w kilku formatach. Przyjmujemy + # dwa, które psql rozumie: + # 1. URI postgresql://uzytkownik:haslo@host:port/baza + # 2. klucz=wartość host=… port=… user=… password=… dbname=… + # Wariant 2 nie przechodzi przez parser URI, więc problem znaków + # specjalnych w haśle go nie dotyczy i nie sprawdzamy go dodatkowo. + case "$CZYSTY" in + postgres://*|postgresql://*) + HASLO=$(printf '%s' "$CZYSTY" | sed -E 's|^postgres(ql)?://[^:@/]+:([^@]*)@.*$|\2|') + if [ "$HASLO" = "$CZYSTY" ] || [ -z "$HASLO" ]; then + echo "::error::Nie umiem wyłuskać hasła z SUPABASE_DB_URL_RO — spodziewany kształt to postgresql://uzytkownik:haslo@host:port/baza" + exit 1 + fi + if ! printf '%s' "$HASLO" | grep -qE '^[A-Za-z0-9._~-]+$'; then + echo "::error::Hasło w SUPABASE_DB_URL_RO ma znaki wymagające kodowania URL (+ / = : @ itp). Wygeneruj je przez 'openssl rand -hex 32' i zaktualizuj sekret — patrz supabase/zapytania/README.md" + exit 1 + fi + ;; + *host=*) + # Format klucz=wartość — psql bierze go dosłownie, bez parsowania URI. + HASLO=$(printf '%s' "$CZYSTY" | sed -nE 's|.*password=([^ ]*).*|\1|p') + ;; + *) + # Opis KSZTAŁTU sekretu, nigdy jego treści. Bez tego jedyną + # informacją jest „nie pasuje", co nie mówi, czy w sekrecie jest + # samo hasło, adres z prefiksem `psql `, czy coś zupełnie innego. + echo "Sekret nie pasuje do żadnego znanego formatu. Jego kształt:" + echo " długość znaków: $(printf '%s' "$CZYSTY" | wc -c)" + echo " zaczyna się od postgres: $(case "$CZYSTY" in postgres*) echo tak;; *) echo NIE;; esac)" + echo " zawiera '://': $(case "$CZYSTY" in *://*) echo tak;; *) echo NIE;; esac)" + echo " zawiera '@': $(case "$CZYSTY" in *@*) echo tak;; *) echo NIE;; esac)" + echo " zawiera 'host=': $(case "$CZYSTY" in *host=*) echo tak;; *) echo NIE;; esac)" + echo " zawiera spację: $(case "$CZYSTY" in *\ *) echo tak;; *) echo NIE;; esac)" + echo " zawiera 'supabase': $(case "$CZYSTY" in *supabase*) echo tak;; *) echo NIE;; esac)" + echo "::error::SUPABASE_DB_URL_RO nie jest rozpoznanym connection stringiem — patrz kształt wyżej. W Supabase: Connect → Session pooler → skopiuj CAŁY adres (od postgresql:// do /postgres) i podmień w nim użytkownika oraz hasło na rolę claude_ro. Instrukcja: supabase/zapytania/README.md" + exit 1 + ;; + esac + + # Maskowanie samego hasła, niezależnie od maskowania całego sekretu. + # Gdyby jakiekolwiek narzędzie wypisało je w błędzie, GitHub zamieni + # je na ***. Maska obowiązuje do końca zadania. + if [ -n "$HASLO" ]; then echo "::add-mask::$HASLO"; fi + + - name: Przygotuj zapytanie + env: + ZAPYTANIE: ${{ inputs.zapytanie }} + run: | + if [ "${{ github.event_name }}" = "push" ]; then + cp supabase/zapytania/biezace.sql /tmp/zapytanie.sql + elif [ -n "${{ inputs.plik }}" ]; then + if [ ! -f "${{ inputs.plik }}" ]; then + echo "::error::Nie ma pliku ${{ inputs.plik }}" + exit 1 + fi + cp "${{ inputs.plik }}" /tmp/zapytanie.sql + elif [ -n "$ZAPYTANIE" ]; then + printf '%s\n' "$ZAPYTANIE" > /tmp/zapytanie.sql + else + echo "::error::Podaj „plik" albo „zapytanie"." + exit 1 + fi + echo "--- treść zapytania ---" + cat /tmp/zapytanie.sql + echo "-----------------------" + + - name: Uruchom + env: + DB_URL: ${{ secrets.SUPABASE_DB_URL_RO }} + run: | + # Białe znaki obcinamy tu jeszcze raz, zamiast przekazywać gotowy adres + # przez GITHUB_ENV — connection string z hasłem nie ma powodu podróżować + # między krokami. + # Obcinamy TYLKO początek i koniec. `tr -d` zjadłoby spacje + # rozdzielające pary w formacie klucz=wartość i rozwaliło adres. + CZYSTY=$(printf '%s' "$DB_URL" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + # READ ONLY jako druga linia obrony — rola i tak nie ma prawa zapisu, + # ale literówka w zapytaniu nie ma prawa niczego ruszyć. + psql "$CZYSTY" \ + -v ON_ERROR_STOP=1 \ + --single-transaction \ + --pset=pager=off \ + --pset=footer=on \ + -c 'SET TRANSACTION READ ONLY' \ + -f /tmp/zapytanie.sql diff --git a/.github/workflows/wizualne.yml b/.github/workflows/wizualne.yml new file mode 100644 index 00000000..522fdeba --- /dev/null +++ b/.github/workflows/wizualne.yml @@ -0,0 +1,296 @@ +name: Wizualne (informacyjne) + +# Regresja wizualna — CELOWO OBOK GŁÓWNEJ BRAMKI. +# +# Ten workflow NIE blokuje deployu ani merge'a. Vercel wystawia podgląd gałęzi +# niezależnie i wcześniej; tutaj tylko porównujemy zrzuty ze wzorcami w repo +# i mówimy, co się zmieniło. +# +# Dlaczego osobno, a nie krokiem w `ci.yml`: zmiana wyglądu bywa zamierzona, +# a wtedy czerwona bramka blokująca merge zmusza do „naprawiania" czegoś, co +# jest w porządku. Zmiana wyglądu ma być DO PRZEJRZENIA, nie do naprawienia. +# +# ZADANIA NIGDY NIE ŚWIECĄ NA CZERWONO. Samo `continue-on-error` nie wystarcza: +# workflow owszem kończy się zielono, ale przy PR-ze i tak widać czerwony +# znaczek przy zadaniu — a to czyta się jak zepsuty build. Dlatego same testy +# lecą z `set +e`, a ich wynik jedzie dalej jako `outputs.wynik` i ląduje +# w komentarzu. Raport jest POMOCĄ DLA CHĘTNYCH, nie bramką. +# +# JAK TO WYGLĄDA Z TELEFONU +# 1. Workflow wystawia RAPORT — jedną stronę na PR, z obrazkami: nowe widoki +# i trójki „wzorzec / teraz / różnica". Odnośnik ląduje w komentarzu do +# PR-a. Wchodzisz, przewijasz, wychodzisz. Nic nie trzeba pobierać ani +# pisać w odpowiedzi. +# 2. Raporty leżą na technicznej gałęzi `podglad-zrzutow` i kasują się same +# po tygodniu (`.github/podglad-zrzutow.sh`). +# 3. Wzorce wchodzą do repo dopiero po nadaniu etykiety `zrzuty:zaakceptuj` — +# wtedy workflow dopisze je do gałęzi PR-a i zobaczysz je w diffie. +# +# DOTYCZY TAK SAMO WIDOKÓW NOWYCH. Nic nie trafia do repo bez zatwierdzenia — +# pierwszy zrzut widoku jest właśnie tym, który warto obejrzeć, bo to on +# staje się wzorcem na zawsze. Szczegóły w `.github/dopisz-wzorce.sh`. + +on: + pull_request: + # `labeled` NIE jest w domyślnej trójce (opened, synchronize, reopened) — + # a bez niego nadanie etykiety `zrzuty:zaakceptuj` niczego nie uruchamiało. + # Cały mechanizm akceptacji działał więc wyłącznie wtedy, gdy po etykiecie + # przypadkiem doszedł jeszcze jakiś push. Instrukcja mówiła „nadaj etykietę + # i gotowe", a w rzeczywistości nic się nie działo. + types: [opened, synchronize, reopened, labeled] + paths: + - 'frontend/**' + - '.github/workflows/wizualne.yml' + # Ręczne odpalenie, gdy chcesz sprawdzić bez otwierania PR-a. + workflow_dispatch: + +# Nowy push do tej samej gałęzi anuluje poprzedni przebieg — zrzuty ze starego +# kodu i tak nie mają wartości. +concurrency: + group: wizualne-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write # dopisanie wzorców przy akceptacji + gałąź z podglądem + pull-requests: write # komentarz z podsumowaniem + +jobs: + # Jedno miejsce, w którym rozstrzygamy: na czym pracujemy i czy akceptujemy. + # Oba zadania niżej potrzebują tego samego, a `workflow_dispatch` nie ma + # ani PR-a, ani etykiet — bez tego każde radziłoby sobie z tym osobno. + kontekst: + name: Kontekst + runs-on: ubuntu-latest + outputs: + uruchamiac: ${{ steps.ustal.outputs.uruchamiac }} + galaz: ${{ steps.ustal.outputs.galaz }} + pr: ${{ steps.ustal.outputs.pr }} + akceptuj: ${{ steps.ustal.outputs.akceptuj }} + steps: + - id: ustal + uses: actions/github-script@v7 + with: + script: | + const ustaw = (k, v) => core.setOutput(k, v); + + if (context.eventName === 'pull_request') { + const pr = context.payload.pull_request; + const etykieta = (pr.labels || []).some((l) => l.name === 'zrzuty:zaakceptuj'); + ustaw('uruchamiac', 'tak'); + ustaw('galaz', pr.head.ref); + ustaw('pr', String(pr.number)); + ustaw('akceptuj', etykieta ? '1' : '0'); + return; + } + + // workflow_dispatch — bez PR-a, tylko porównanie. + ustaw('uruchamiac', 'tak'); + ustaw('galaz', context.ref.replace('refs/heads/', '')); + ustaw('pr', ''); + ustaw('akceptuj', '0'); + + zrzuty: + name: Porównanie zrzutów + needs: kontekst + if: needs.kontekst.outputs.uruchamiac == 'tak' + runs-on: ubuntu-latest + # Bez tego czerwony wynik tego zadania blokowałby merge tak samo jak CI. + # Informacja ma docierać komentarzem, nie blokadą. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.kontekst.outputs.galaz }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Zależności + working-directory: frontend + run: npm ci + + - name: Przeglądarka + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Build produkcyjny + working-directory: frontend + env: + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + run: npm run build + + # `set +e` i własne `exit 0`: to zadanie ma NIGDY nie świecić na czerwono. + # Różnica w wyglądzie nie jest awarią, tylko informacją — a czerwony + # znaczek przy PR-ze czyta się jak zepsuty build i każe „naprawiać" coś, + # co bywa dokładnie zamierzone. Wynik jedzie dalej jako `outputs.wynik` + # i ląduje w komentarzu. + - name: Zrzuty + id: test + working-directory: frontend + run: | + set +e + if [[ "${{ needs.kontekst.outputs.akceptuj }}" == "1" ]]; then + npm run zrzuty:akceptuj + else + npm run zrzuty + fi + echo "wynik=$?" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Raport i różnice + if: always() + uses: actions/upload-artifact@v4 + with: + name: zrzuty-raport + path: | + frontend/playwright-report/ + frontend/test-results/ + retention-days: 14 + + - name: Podgląd obrazków do komentarza + id: podglad + if: always() && needs.kontekst.outputs.pr != '' + working-directory: frontend + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ needs.kontekst.outputs.pr }} + # Raport jest pomocą, nie bramką — jego awaria nie ma prawa niczego + # zatrzymać ani zaczerwienić. + run: ../.github/podglad-zrzutow.sh "widoki-publiczne" || true + + - name: Dopisz wzorce do gałęzi + if: always() && needs.kontekst.outputs.pr != '' + working-directory: frontend + env: + AKCEPTUJ: ${{ needs.kontekst.outputs.akceptuj }} + GALAZ: ${{ needs.kontekst.outputs.galaz }} + run: ../.github/dopisz-wzorce.sh "zrzutów" || true + + - name: Komentarz w PR + if: always() && needs.kontekst.outputs.pr != '' + continue-on-error: true + uses: actions/github-script@v7 + env: + UDANE: ${{ steps.test.outputs.wynik }} + AKCEPTUJ: ${{ needs.kontekst.outputs.akceptuj }} + PR: ${{ needs.kontekst.outputs.pr }} + with: + script: | + const fs = require('fs'); + const czytaj = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } }; + await require(`${process.env.GITHUB_WORKSPACE}/.github/komentarz-zrzutow.js`)({ + github, context, core, + znacznik: '🖼️', + tytul: 'Widoki publiczne', + udane: process.env.UDANE === '0', + akceptacja: process.env.AKCEPTUJ === '1', + obrazki: czytaj('frontend/podglad-zrzutow.md'), + numer: Number(process.env.PR), + }); + + scenariusze: + name: Scenariusze za logowaniem + needs: kontekst + if: needs.kontekst.outputs.uruchamiac == 'tak' + runs-on: ubuntu-latest + # Tak samo nieblokujące jak zrzuty widoków publicznych. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.kontekst.outputs.galaz }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - uses: supabase/setup-cli@v1 + with: + version: latest + + - name: Stos Supabase, migracje i dane + # Skrypt dopisuje NEXT_PUBLIC_SUPABASE_* do GITHUB_ENV, więc build + # i testy niżej dostają adres oraz klucz lokalnej instancji. + run: ./scripts/stos-lokalny.sh + + - name: Zależności + working-directory: frontend + run: npm ci + + - name: Przeglądarka + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Build produkcyjny + working-directory: frontend + run: npm run build + + # Jak wyżej: to zadanie nigdy nie świeci na czerwono. + - name: Scenariusze + id: test + working-directory: frontend + run: | + set +e + if [[ "${{ needs.kontekst.outputs.akceptuj }}" == "1" ]]; then + npm run scenariusze:akceptuj + else + npm run scenariusze + fi + echo "wynik=$?" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Raport i różnice + if: always() + uses: actions/upload-artifact@v4 + with: + name: scenariusze-raport + path: | + frontend/playwright-report/ + frontend/test-results/ + retention-days: 14 + + - name: Podgląd obrazków do komentarza + if: always() && needs.kontekst.outputs.pr != '' + working-directory: frontend + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ needs.kontekst.outputs.pr }} + run: ../.github/podglad-zrzutow.sh "scenariusze-za-logowaniem" || true + + - name: Dopisz wzorce do gałęzi + if: always() && needs.kontekst.outputs.pr != '' + working-directory: frontend + env: + AKCEPTUJ: ${{ needs.kontekst.outputs.akceptuj }} + GALAZ: ${{ needs.kontekst.outputs.galaz }} + run: ../.github/dopisz-wzorce.sh "scenariuszy" || true + + - name: Komentarz w PR + if: always() && needs.kontekst.outputs.pr != '' + continue-on-error: true + uses: actions/github-script@v7 + env: + UDANE: ${{ steps.test.outputs.wynik }} + AKCEPTUJ: ${{ needs.kontekst.outputs.akceptuj }} + PR: ${{ needs.kontekst.outputs.pr }} + with: + script: | + const fs = require('fs'); + const czytaj = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } }; + await require(`${process.env.GITHUB_WORKSPACE}/.github/komentarz-zrzutow.js`)({ + github, context, core, + znacznik: '🎬', + tytul: 'Scenariusze za logowaniem', + udane: process.env.UDANE === '0', + akceptacja: process.env.AKCEPTUJ === '1', + obrazki: czytaj('frontend/podglad-zrzutow.md'), + numer: Number(process.env.PR), + }); diff --git a/.gitignore b/.gitignore index 3ae21f6c..df11960d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,121 @@ -node_modules/**/* -.expo/* +# ============================================================ +# Boiska Poznań — combined .gitignore +# ============================================================ + +# ---- Next.js ---- +frontend/.next/ +frontend/out/ +frontend/build/ +frontend/.vercel +frontend/node_modules/ +frontend/.env*.local +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* +frontend/.pnp +frontend/.pnp.js + +# ---- Node (root & generic) ---- +node_modules/ npm-debug.* -package-lock.json -yarn.lock +yarn-debug.* +yarn-error.* +.pnpm-debug.log* + +# ---- Python / FastAPI ---- +__pycache__/ +**/__pycache__/ +*.py[cod] +*$py.class +*.pyo +*.pyd +.Python +.venv/ +venv/ +env/ +ENV/ +backend/.env +scraper/.env +.env +*.egg-info/ +dist/ +*.egg +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +*.cover +.hypothesis/ + +# ---- Docker ---- +**/docker-data/ +**/volumes/ +.docker/ + +# ---- Supabase local ---- +.supabase/ +supabase/.temp/ + +# ---- OS ---- +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# ---- IDE ---- +.idea/ +.vscode/ +!.vscode/settings.json +!.vscode/extensions.json +*.swp +*.swo +*~ +.project +.classpath + +# ---- Env files (keep .env.example) ---- +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +!.env.example +!**/.env.example + +# ---- Expo / React Native (legacy files in repo root) ---- +.expo/* *.jks *.p12 *.key -*.mobileprovision \ No newline at end of file +*.mobileprovision + +# ---- Misc ---- +*.log +*.tmp +*.temp +.cache/ + +# ---- TypeScript (auto-generated) ---- +frontend/next-env.d.ts + +# TypeScript build cache +*.tsbuildinfo +frontend/tsconfig.tsbuildinfo +# ---- Claude Code ---- +# Konfiguracja hooka doc-guard jest współdzielona — musi trafić do repo, +# inaczej przypomnienie o dokumentacji nie zadziała u nikogo poza autorem. +# Ignorujemy tylko rzeczy osobiste i lokalne. +.claude/* +!.claude/settings.json +!.claude/hooks/ +.claude/settings.local.json +rewizja-do-wklejenia.txt + +# Playwright +frontend/test-results/ +frontend/playwright-report/ diff --git a/.watchmanconfig b/.watchmanconfig deleted file mode 100644 index 0967ef42..00000000 --- a/.watchmanconfig +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1b4cf5a4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,386 @@ +# Bojo — zasady pracy w repo + +Kontekst projektu i pułapki, które warto znać przed pierwszą zmianą. + +- Baza wiedzy: [docs/README.md](./docs/README.md) — wizja, funkcje, domena, baza danych +- Opis funkcji dla ludzi: [PRZEWODNIK.md](./PRZEWODNIK.md) +- Stack i architektura w skrócie: [README.md](./README.md) + +## Szybki start + +```bash +cd frontend +npm install +npm run dev # http://localhost:3000 +``` + +Wymaga `.env` w katalogu głównym (skopiuj z `.env.example`) z kluczami Supabase. + +## Weryfikacja zmian — uruchamiaj przed każdym commitem + +```bash +cd frontend +npx tsc --noEmit # typecheck — musi być czysto +npm run lint # ESLint — błędy blokują CI, ostrzeżenia nie +npm test # Vitest, 463 testy +npm run build # build produkcyjny (potrzebuje tylko atrap kluczy, patrz niżej) +``` + +**`npm run lint` DZIAŁA** — konfiguracja jest w `frontend/.eslintrc.js`. Wcześniej stało +tu, że wymaga interaktywnej konfiguracji; brakowało wyłącznie tego pliku i wtyczki +`@typescript-eslint`. Pierwsze uruchomienie znalazło 39 miejsc z martwym kodem. + +**Build da się uruchomić bez prawdziwych kluczy Supabase** — wystarczą atrapy: + +```bash +NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \ +NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-anon-key npm run build +``` + +To ważne, bo `useSearchParams()` na trasie prerenderowanej wywraca **wyłącznie** build +produkcyjny — `tsc` i Vitest tego nie widzą. + +**Baza testowa lokalnie** — migracje od zera na gołym Postgresie: + +```bash +./scripts/baza-testowa.sh # postaw, zwaliduj, posprzątaj +./scripts/baza-testowa.sh --zostaw # zostaw działającą bazę na porcie 55432 +``` + +Sprawdza to, czego nie widać na działającej bazie: czy migracje aplikują się +**od zera**. Pierwsze uruchomienie znalazło `005`, która tworzyła politykę +istniejącą już od `001` — na świeżej bazie odtworzenie schematu było niemożliwe. +Atrapy Supabase (schemat `auth`, `storage`, pgcrypto) siedzą w `supabase/test/shim.sql`. + +**Ikony PWA** generuje `frontend/scripts/generuj-ikony.mjs` z logo w +`components/Logo.tsx` (rasteryzuje Chromium z Playwrighta, bez dodatkowych paczek): + +```bash +cd frontend && node scripts/generuj-ikony.mjs +``` + +Po podmianie logo uruchom ponownie i zacommituj wynik. `ikonyPwa.test.ts` pilnuje, +żeby ścieżka litery w skrypcie nie rozjechała się z logo — bez tego podmiana logo +zostawiłaby starą ikonę na ekranie telefonu i nikt by tego nie zauważył, bo ikonę +widzi się raz, przy instalacji. + +**Regresja wizualna (zrzuty ekranu):** + +```bash +cd frontend && npm run build +npm run zrzuty # porównaj ze wzorcami +npm run zrzuty:akceptuj # nadpisz wzorce świadomie +``` + +Wzorce leżą w `frontend/e2e/wzorce/` i idą do repo, więc zmiana widoku pokazuje +się w PR-ze jako różnica obrazków. Workflow `wizualne.yml` **celowo nie blokuje** +merge'a ani deployu — zmiana wyglądu bywa zamierzona i ma być do przejrzenia, +nie do naprawienia. + +Co więcej, **te zadania nigdy nie świecą na czerwono**. Samo `continue-on-error` +nie wystarcza: workflow kończy się wtedy zielono, ale przy PR-ze i tak widać +czerwony znaczek przy zadaniu — a to czyta się jak zepsuty build. Dlatego testy +lecą z `set +e`, a wynik jedzie do komentarza jako informacja. To jest pomoc dla +chętnych, nie bramka. + +**Raport na PR — jedna strona do obejrzenia, działa na telefonie:** + +`.github/podglad-zrzutow.sh` wystawia raport na technicznej gałęzi +`podglad-zrzutow`, pod adresem `…/tree/podglad-zrzutow/pr-/`. +GitHub renderuje `README.md` katalogu jako stronę, więc wchodzisz w odnośnik +z komentarza i przewijasz obrazki. Nic nie trzeba pobierać ani odpisywać. +Raporty **kasują się same po 7 dniach** — gałąź nie ma rosnąć w nieskończoność. +Artefakt z raportem HTML zostaje jako droga zapasowa. + +Zmieniony widok pokazuje się **jako wycinek samego zmienionego miejsca** +(`frontend/e2e/wytnij-zmiane.js` liczy prostokąt obejmujący podświetlone piksele +i tnie po nim oba zrzuty), a pod nim całe strony **bok w bok**. Nakładka „diff" +od Playwrighta ląduje w zwijanej sekcji: przy zmianie tekstu rysuje obie wersje +jedna na drugiej i jest nie do odczytania. + +Wzorce wchodzą do repo dopiero po nadaniu etykiety `zrzuty:zaakceptuj` +(w aplikacji GitHuba: **ⓘ** w prawym dolnym rogu PR-a → *Labels*). Dotyczy to +**tak samo widoków nowych, jak zmienionych** (`.github/dopisz-wzorce.sh`) — +pierwszy zrzut widoku jest właśnie tym, który warto obejrzeć, bo to on staje +się wzorcem na zawsze. + +`e2e/wizualne.spec.ts` chodzi **bez bazy** — na atrapach kluczy, w tym samym +przebiegu co build. Komunikaty, które normalnie przychodzą z serwera (złe hasło, +e-mail zajęty, limit prób, rejestracja wyłączona), podstawia `page.route()`: +przechwytuje odpowiedź GoTrue i oddaje tę, którą chcemy zobaczyć. Ścieżka kodu +w aplikacji jest prawdziwa, atrapa siedzi wyłącznie w sieci. Tak samo powstaje +widok „Google zablokowane w tej przeglądarce" — przez podstawiony `User-Agent` +Facebooka, bo w zwykłej przeglądarce nie da się go zobaczyć. + +Na końcu `wizualne.spec.ts` siedzi **przemiał po wszystkich trasach** — lista +`TRASY` z każdym adresem, który da się otworzyć bez bazy. Pojedyncze scenariusze +pilnują miejsc, o których ktoś pomyślał; ta lista pilnuje całej aplikacji, więc +zmiana w nagłówku, stopce czy odstępach pokazuje się wszędzie tam, gdzie realnie +ją widać. **Dodajesz trasę w `src/app` → dopisz ją do `TRASY`.** + +Dwie pułapki przy pisaniu nowych zrzutów: + +- `/wydarzenia` renderuje listę **dwa razy** (gałąź `hidden md:block` i `md:hidden`), + więc `.first()` trafia na kopię ukrytą przez CSS — filtruj `filter({ visible: true })`. +- Atrapa PostgREST musi kłamać tak jak serwer: zapytanie z `.single()` wysyła + `Accept: …pgrst.object+json` i przy zerze wierszy dostaje **406 PGRST116**. + Pusta tablica w tej sytuacji jest gorsza niż nic — supabase-js bierze ją za + wiersz i strona meczu rysuje nagłówek „undefined" oraz „Zostało NaN miejsc". + +**Scenariusze za logowaniem (pełny stos Supabase, wymaga Dockera):** + +```bash +./scripts/stos-lokalny.sh # Postgres + GoTrue + PostgREST, migracje, dane +cd frontend && npm run build && npm run scenariusze +``` + +Przechodzą przejścia realnego gracza na realnej bazie: dołączenie, rezerwa, +dwa tryby miejsc dla bramkarzy, prośby o akceptację, płatności, obserwowanie, +okno na telefonie. Dane z `supabase/seed_wizualne.sql` mają **daty na sztywno**, +a zegar przeglądarki jest zamrożony (`page.clock`) — inaczej etykiety „Dzisiaj" +i „za 2 dni" zmieniałyby zrzuty każdego dnia. + +**Testy klikalności (Playwright):** + +```bash +cd frontend && npm run build && npm run e2e +``` + +W tym środowisku przeglądarka jest już w obrazie, więc: +`PLAYWRIGHT_CHROMIUM=/opt/pw-browsers/chromium-1194/chrome-linux/chrome npm run e2e`. +Sprawdzają jedno: **czy da się kliknąć**. Modal przykryty paskiem nawigacji nie jest +widoczny dla żadnego innego narzędzia w repo — Playwright zgłasza go wprost. + +## Architektura w skrócie + +- **Brak własnego backendu.** Frontend (Next.js 14 App Router) rozmawia z Supabase + bezpośrednio, dostęp pilnowany przez RLS. +- Logika domenowa siedzi w `frontend/src/lib/` (`events.ts`, `groups.ts`, `api.ts`, + `payments.ts`…). Komponenty tego nie omijają. +- Wyjątek: `frontend/src/app/api/geocode/` — serwerowy proxy do Nominatim (przeglądarka + nie może ustawić `User-Agent`). +- Interfejs jest **po polsku**. Komentarze w kodzie po angielsku. + +Uzasadnienia i granice → [docs/domena.md](./docs/domena.md#granice-architektury). + +## Strefy podwyższonego ryzyka + +Zmiany w tych miejscach wymagają testu i zielonego CI (uruchamia `tsc`, Vitest +i `npm run check:docs` przy każdym PR i push na master): + +- **Auth i RLS** — `lib/auth.tsx`, polityki w migracjach. Pamiętaj: niepasująca polityka + nie rzuca błędu, tylko po cichu aktualizuje 0 wierszy. +- **Płatności** — cenę zawsze liczy `priceForParticipant()` (`lib/payments.ts`). +- **Migracje** — uruchamiane ręcznie na produkcji; błąd w SQL trafia do bazy na żywo. +- **Kasowanie danych** — `deleteEvent`, `deleteGroup`, usuwanie konta. + +## Zanim uznasz, że funkcja nie istnieje — sprawdź flagi + +Najczęstsze nieporozumienie w tym repo: funkcja jest zbudowana, ale schowana. +`SHOW_CUP`, `SHOW_GAME_ALERTS`, `SHOW_SMS_FEATURES` (`frontend/src/lib/features.ts`) +oraz `FEATURE_RESERVATIONS` (`frontend/src/config/features.ts`) są dziś wyłączone. +`SHOW_RECURRING` jest **włączona** od migracji `073` — gry cykliczne działają. + +Flagi ukrywają **wejścia w nawigacji**, nie trasy. Pełna tabela z miejscami użycia → +[docs/funkcje.md](./docs/funkcje.md#flagi-funkcji). + +## Pułapki, które już nas ugryzły + +**Migracje SQL uruchamia się RĘCZNIE.** Pliki w `supabase/migrations/` (numerowane) +trzeba wkleić do Supabase → SQL Editor. Nic nie robi tego automatycznie. Dodanie kolumny +w migracji ≠ kolumna istnieje w bazie — jeśli apka rzuca błędem o nieznanej kolumnie, +najpewniej migracja nie została puszczona. + +**Do UPDATE-ów używaj `zaktualizujJedenWiersz()`, do dużych list `pobierzWszystkie()`** +(`frontend/src/lib/zapytania.ts`). Oba istnieją po to, żeby cisza opisana w dwóch +pułapkach niżej zamieniła się w wyjątek. Nowy kod, który omija te helpery, odtwarza +dokładnie te same błędy. + +**RLS po cichu unieważnia UPDATE.** Gdy polityka RLS nie pasuje, Postgres nie zgłasza +błędu — po prostu aktualizuje 0 wierszy i zwraca sukces. Objaw: „przycisk nic nie robi". +Realny przypadek: brakowało polityki pozwalającej użytkownikowi zmienić własny wpis +w `event_participants` (naprawione w `053`). Jeśli zapis „nie działa" bez błędu — najpierw +sprawdź polityki, nie kod. + +**Nie prerenderuj niczego per obiekt z katalogu boisk.** `/boisko/[id]` miało +`generateStaticParams()` zwracające slug każdego boiska — build generował tyle stron, +ile wierszy w `fields`. Do tego `resolveField()` po slugu robiło `select('*')` na całej +tabeli, raz na `generateMetadata` i raz na komponent. Koszt rósł kwadratowo: przy +poznańskim katalogu (~1500) build się jeszcze mieścił, po imporcie z OSM (~4600) +ciągnął się **ponad 40 minut** i nie kończył. Dziś trasa renderuje się na żądanie +(`generateStaticParams` zwraca `[]`, `revalidate = 86400`), a slug→id rozwiązuje +wspólny indeks z TTL. Katalog docelowo ma dziesiątki tysięcy obiektów — cokolwiek +liniowego względem niego przy buildzie jest z góry spalone. + +**`useSearchParams()` wywala build produkcyjny na trasach prerenderowanych.** +`useSearchParams()` w komponencie klienckim wymusza na trasie prerenderowanej +bail-out do CSR i build kończy się błędem `missing-suspense-with-csr-bailout`. +**Lokalnie się nie powtórzy** — bez prawdziwych kluczy Supabase `generateStaticParams()` +zwraca pustą listę, więc strony w ogóle nie powstają i błąd nie ma jak wyjść. Wychodzi +dopiero na Vercelu. Zamiast hooka czytaj `window.location.search` w `useEffect` po +montażu (patrz `backHref` w `boisko/[id]/VenueDetailClient.tsx`) albo opakuj +w ``. Samo `/boisko/[id]` nie jest już prerenderowane (patrz wyżej), ale +`/boiska/[sport]` nadal jest. + +**`truncate` w kontenerze flex wymaga `min-w-0`.** Bez tego element odmawia się skurczyć +poniżej szerokości treści i rozpycha całą kartę w bok, zamiast obciąć tekst. + +**Nie ma auto-awansu z listy rezerwowej.** To świadoma decyzja produktowa, nie brak: gdy +ktoś się wypisze, rezerwowy nie wskakuje automatycznie — ktoś musi go powiadomić. +Nie „naprawiaj" tego. + +**`/gracze` to `redirect('/wydarzenia')`** — nie ma listy graczy, mimo że trasa istnieje. + +**Martwy kod:** `components/map/MapView.tsx`, `LeafletMapImpl.tsx`, `EventsMapView.tsx`, +`EventsMapImpl.tsx` — nic ich nie importuje. Aktywna mapa to `VenueExplorer.tsx` +(strona `/mapa`) i pickery lokalizacji. + +## Modele domenowe + +Przed zmianą w `lib/events.ts`, `lib/payments.ts` lub logice zapisów przeczytaj +[docs/domena.md](./docs/domena.md) — dwie osie relacji do meczu, reguły pojemności, +semantyka zniżki `null`, pułapka nazw `grosz`/`grosze`. To ~5 minut, które oszczędza +błędną „naprawę" świadomej decyzji produktowej. + +## Aktualizacja dokumentacji + +Zmiana kodu pociąga za sobą aktualizację dokumentu: + +| Zmieniasz | Zaktualizuj | +|---|---| +| `lib/features.ts`, `config/features.ts` | [docs/funkcje.md](./docs/funkcje.md#flagi-funkcji) | +| `frontend/src/lib/*` | [docs/domena.md](./docs/domena.md), [docs/funkcje.md](./docs/funkcje.md) | +| `frontend/src/app/*` (nowa/usunięta trasa) | [docs/funkcje.md](./docs/funkcje.md), `frontend/public/llms.txt` | +| `supabase/migrations/*` | [docs/baza-danych.md](./docs/baza-danych.md) | +| cokolwiek zmienia zachowanie widoczne dla użytkownika | [docs/llm-context.md](./docs/llm-context.md) — patrz „RAG INJECTION" niżej | + +Po zmianach uruchom **`npm run check:docs`** (z katalogu głównego) — walidator mówi +deterministycznie, czy dokumentacja rozjechała się z kodem (trasy w `llms.txt`, flagi, +linki, migracje). CI odrzuci PR, w którym walidator jest czerwony. + +Hook `.claude/hooks/doc-guard.sh` przypomina o tym w trakcie pracy. **Nie blokuje** — +to przypomnienie, nie bramka. + +## RAG INJECTION — obowiązkowe przy zmianie widocznej dla użytkownika + +[docs/llm-context.md](./docs/llm-context.md) to jedyny plik pisany dla modelu, który +czyta **na zimno**, bez dostępu do repo (zewnętrzny asystent odpowiadający na pytanie +o bojo.pl, baza wiedzy w narzędziu). Zmiana zachowania widocznego dla użytkownika +aktualizuje ten plik, po czym **`npm run sync:llm-context`** odświeża kopię publiczną +serwowaną pod `bojo.pl/llm-context.md`. CI odrzuca PR, w którym kopia się rozjechała. + +Wpis w sekcji „Ostatnie zmiany" ma format: + +``` +PROBLEM: jaki ból użytkownika to rozwiązuje +ROZWIĄZANIE BOJO: co zostało zbudowane +MECHANIKA: komponenty, funkcje w lib/, tabele, migracje +``` + +Sekcja opisująca funkcję dokłada do tego **PYTANIA** — 3–5 pytań w naturalnym języku, +na które ta sekcja odpowiada. + +Zasady, których nie łamiemy: + +- **Gęsty, faktograficzny Markdown. Zero języka marketingowego.** Piszesz instrukcję + dla modelu, nie opis dla klienta. +- **Każda sekcja broni się sama** — nazywaj encje wprost („Bojo", nie „aplikacja", + nie „to"). W RAG sekcja trafia do modelu wyrwana z kontekstu pliku. +- **Nie dopisuj list słów kluczowych.** Badania GEO (Aggarwal i in., KDD 2024) pokazują, + że keyword stuffing wypada najsłabiej ze wszystkich testowanych metod i obniża ocenę + gęstości informacyjnej. Zamiast słów kluczowych — pytania w naturalnym języku. +- **Nie kopiuj treści z `docs/`.** Tabela flag, mapa tabela → migracja i ścieżki plików + żyją w `docs/`; `llm-context.md` odsyła do nich linkiem. Dwie kopie = gwarantowany + rozjazd. +- **Log „Ostatnie zmiany" ma limit 10 wpisów** (pilnuje go walidator). Najstarsze + usuwasz — pełną historią jest `git log`, a rosnący log rozmywa cały plik. +- Znacznik `**Stan na:**` w nagłówku aktualizujesz razem z migracjami. + +`llms.txt` to indeks, nie changelog — **nie dopisuj tam logu zmian.** + +**[docs/wizja.md](./docs/wizja.md) jest dokumentem nadrzędnym.** Sekcja 1 to dokument +strategiczny wklejony werbatim — nie parafrazować i nie „poprawiać" przy okazji innych +zmian. Gdy kod nie zgadza się z wizją, to kod nie nadążył: rozbieżność trafia do +[BACKLOG.md](./BACKLOG.md) jako zadanie. + +## Dane testowe + +`supabase/seed_test_data.sql` — 25 wydarzeń pokrywających wszystkie kombinacje ustawień. +Uruchamiany ręcznie w SQL Editor, bezpieczny do wielokrotnego użycia (czyści po markerze +`[TEST]` w opisie). Konta testowe tworzy `supabase/seed-test-users.sql` +(`test1..test10@example.com`, hasło `test1234`). + +`supabase/seed_test_groups.sql` — 4 grupy i 11 meczów **prywatnych** wokół przepływów +grupowych: mecze ekipy na stronie głównej, zaproszenia, przypinanie meczu do grupy. +Marker `[TEST-G]`. Wymaga konta `franekks@gmail.com` w `auth.users`. + +`supabase/seed_regresja.sql` — **43 scenariusze regresyjne** po refaktorze (etapy 1–3): +dołączanie, kolejka rezerwowa, trzy tryby miejsc dla bramkarzy, prośby o akceptację, +płatności, goście, warstwy okien, sortowanie list. Każdy mecz sprawdza jedną rzecz, +tytuł niesie numer (`R01`…`R43`), a opis zaczyna się od „SPRAWDŹ:" i kończy oczekiwanym +wynikiem. Marker `[REG]`. Na końcu pliku zapytanie, które wypisuje całość jako listę +kontrolną z adresami. + +`supabase/seed_test_jan.sql` — 19 wydarzeń pokrywających obszary, których nie ruszają +poprzednie seedy: wyniki meczów z golami, mecze z przeszłości i statystyki gracza, +odwołanie meczu, goście dopisani przez uczestnika, miejsce spoza katalogu, komentarze, +składy nieopublikowane, 18-osobowy skład. Marker `[TEST-J]`. Wymaga konta +`j4n.brz0@gmail.com`. + +Komplet do postawienia bazy od zera: `supabase/bundles/` (3 paczki migracji + seedy), +generowane przez `node scripts/build-db-bundles.mjs` — po dodaniu migracji uruchom +ponownie i zacommituj wynik. + +## Konwencje + +- **NIE pushuj bezpośrednio na `master`. Każda zmiana idzie przez pull request** — + branch → PR → **merge przez agenta** → deploy. Powód: PR zostaje jako czytelny + zapis zmiany (diff, opis, preview z Vercela), nawet jeśli nikt go nie recenzuje + na żywo. Merge do mastera to deploy na produkcję — środowisko jest jedno. +- **Agent sam mergueje swój PR**, gdy CI jest zielone. Nie czekaj na potwierdzenie + właściciela — decyzja z 2026-08-05, gdy aplikacja nie była jeszcze publiczna. + Warunki, bez których NIE wolno mergować: + - CI zielone (`tsc`, Vitest, `check:docs`), + - **wszystkie poprawki dopchnięte PRZED otwarciem PR-a**. Dwa razy zdarzyło się, + że PR został zmergowany chwilę przed dosłaniem poprawki — raz kosztowało to + zepsuty build produkcyjny (patrz pułapka o `useSearchParams` niżej). + Otwieraj PR dopiero, gdy zmiana jest kompletna i sprawdzona. + - migracja SQL w PR → napisz WPROST w opisie i w odpowiedzi, że trzeba ją + uruchomić ręcznie w Supabase. Merge jej nie uruchamia. +- Commity i wiadomości do użytkownika po polsku. +- Migracje: kolejny numer + krótka nazwa, np. `058_nazwa_zmiany.sql`, z komentarzem + **dlaczego** powstała. +- Nie commituj `.env` (jest w `.gitignore`). +- Domena kanoniczna to `bojo.pl` — jeśli dodajesz miejsce z fallbackiem URL, użyj tej + samej wartości co `layout.tsx`, `robots.ts` i `sitemap.ts`. +- **Mobile-first bezwzględnie.** Style bazowe (bez media query) opisują najmniejszy + telefon; rozszerzanie widoku do tabletu/desktopu wyłącznie progresywnie, przez + warianty `min-width` (`sm:`/`md:`/`lg:`/`xl:` Tailwinda). Breakpointy `max-*:` + (`max-sm:`, `max-md:`…) i `@media (max-width: …)` są w nowym kodzie zabronione — + pilnuje tego `npm run check:docs` (sekcja 10), skanując cały `frontend/src`. +- **Copy stron treści i landingu żyje w `frontend/src/content/*.ts`**, osobno od JSX + (wzorem dawnego `components/home/landing/content.ts`) — żeby dało się testować bez + renderowania, m.in. zakazane frazy w `content/zakazaneFrazy.ts` + (`landingContent.test.ts`, `tresciStron.test.ts`). +- **Kolorystyka niesie stałe znaczenie w całej apce** — trzy kolory mają dziś + zarezerwowane, wyłączne odczytanie, żeby budować podświadome skojarzenie: + - **Różowy (`pink-*`)** — zawsze i wyłącznie odniesienie do wiadomości: kropka na + dolnej nawigacji, plakietka z liczbą nieprzeczytanych na zakładce Rozmowa/Tablica, + ikona wiadomości na karcie meczu/ekipy, kropka na ikonie ekipy (karta na `/grupy`). + Nigdy nic innego. + - **Niebieski (`blue-*`)** — zawsze i wyłącznie „wymaga akceptacji uczestnictwa": + prośba o dołączenie, oferta zwolnionego miejsca z rezerwy, pytanie o udział + (`WYMAGA_AKCJI` w `lib/notifications.ts`), plakietka „Wymaga akceptacji" na karcie + meczu. Nigdy nic innego. + - **Pomarańczowy (`orange-*`)** — zawsze i wyłącznie „nowość, o której jeszcze nie + wiesz" (bez konkretnej wiadomości do przeczytania ani decyzji do podjęcia): kropka + na ikonie ekipy, gdy pojawił się nowy mecz od ostatniej wizyty na `/grupy/[id]` + (`kluczGrupyWidziano` w `lib/groups.ts`), kropka przy „Znajdź grę" na dolnej + nawigacji, gdy w promieniu 5 km pojawiło się nowe wydarzenie + (`KLUCZ_WYDARZENIA_WIDZIANO` w `lib/events.ts`). Nigdy nic innego. + + Nowy wskaźnik/plakietka w UI ma sprawdzić, czy mieści się w jednym z tych trzech + znaczeń, zanim sięgnie po `pink-*`/`blue-*`/`orange-*` — i **nie** używać ich do + niczego innego (inny kolor niż zwykle też jest sygnałem). Przy dodawaniu nowego + typu wskaźnika warto od razu rozważyć, czy zasługuje na własny, konsekwentnie + używany kolor w całej apce, zamiast doraźnego wyboru per ekran. diff --git a/App.js b/App.js deleted file mode 100644 index 0c999ffd..00000000 --- a/App.js +++ /dev/null @@ -1,93 +0,0 @@ -/*! - - ========================================================= - * Material Kit React Native - v1.4.0 - ========================================================= - * Product Page: https://demos.creative-tim.com/material-kit-react-native/ - * Copyright 2019 Creative Tim (http://www.creative-tim.com) - * Licensed under MIT (https://github.com/creativetimofficial/material-kit-react-native/blob/master/LICENSE) - ========================================================= - * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -*/ - -import React from 'react'; -import { Platform, StatusBar, Image } from 'react-native'; -import AppLoading from 'expo-app-loading'; -import { Asset } from 'expo-asset'; -import { Block, GalioProvider } from 'galio-framework'; - -import { Images, products, materialTheme } from './constants/'; - -import { NavigationContainer } from '@react-navigation/native'; -import Screens from './navigation/Screens'; - -// Before rendering any navigation stack -import { enableScreens } from 'react-native-screens'; -enableScreens(); - -// cache app images -const assetImages = [ - Images.Pro, - Images.Profile, - Images.Avatar, - Images.Onboarding, -]; - -// cache product images -products.map(product => assetImages.push(product.image)); - -function cacheImages(images) { - return images.map(image => { - if (typeof image === 'string') { - return Image.prefetch(image); - } else { - return Asset.fromModule(image).downloadAsync(); - } - }); -} - -export default class App extends React.Component { - state = { - isLoadingComplete: false, - }; - - render() { - if (!this.state.isLoadingComplete && !this.props.skipLoadingScreen) { - return ( - - ); - } else { - return ( - - - - {Platform.OS === 'ios' && } - - - - - ); - } - } - - _loadResourcesAsync = async () => { - return Promise.all([ - ...cacheImages(assetImages), - ]); - }; - - _handleLoadingError = error => { - // In this case, you might want to report the error to your error - // reporting service, for example Sentry - console.warn(error); - }; - - _handleFinishLoading = () => { - this.setState({ isLoadingComplete: true }); - }; -} diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 00000000..be35a82e --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,690 @@ +# BOJO — backlog + +Co jest **zbudowane, ale schowane**, gdzie **kod nie nadążył za wizją** oraz pomysły +jeszcze niezrobione. + +- Kierunek produktu: [docs/wizja.md](./docs/wizja.md) — **dokument nadrzędny** +- Stan implementacji: [docs/funkcje.md](./docs/funkcje.md) +- Roadmapa fazowa: [docs/strategia.md](./docs/strategia.md#6-roadmapa-fazowa) +- Audyt ścieżki organizatora: [docs/przeplyw-organizatora.md](./docs/przeplyw-organizatora.md) + +_Ostatnia aktualizacja: 2026-08-15_ + +--- + +## PRZESŁANKA STRATEGICZNA (2026-08-15) — czytaj przed planowaniem + +**Mięsem na start są ekipy grające regularnie. Otwarte gry to „później".** +Decyzja właściciela, świadomie inna niż założenie +[rewizji 2026-08](./docs/rewizja-2026-08.md), która traktowała stałe ekipy jako segment +„Marcina", czyli takiego, co nie przyjdzie. Rewizja zostaje jako zapis analizy z tamtą +przesłanką — ale tam, gdzie obie się rozjeżdżają, **obowiązuje ta sekcja**. + +Co z tego wynika, wprost: + +| Pozycja | Rewizja mówiła | Teraz | +|---|---|---| +| Gry cykliczne (`SHOW_RECURRING`) | do skasowania | **zostają** — flaga włączona od migracji `073`, kod już poszedł w tę stronę | +| „Półka, która nie umie być pusta" + `SHOW_GAME_ALERTS` | jedna z pięciu rzeczy do zbudowania | **schodzi na później** — to agenda otwartych gier | +| Przejęcie profilu gościa (claim) | pierwsze | **nadal kluczowe** — w stałej ekipie ci sami goście wracają co tydzień, więc ta sama strata powtarza się 50× w roku, nie raz | +| Web-push (PWA) | do skasowania („kanał powrotu dla użytkowników, których nie ma") | **wraca jako priorytet** — stała ekipa to dokładnie kohorta, którą jest po co przypominać: te same 10 osób, ten sam czwartek, jedno pytanie „grasz?". Plan: [§8 „PWA + web-push"](#pwa--web-push--plan-priorytet-od-2026-08-15) | + +Otwarte gry sprawdzamy tanio i ręcznie: właściciel wynajmuje orlik albo boisko do siatki +i organizuje kilka gier sam. To lepszy test popytu niż liczenie postów w grupach na +Facebooku — sprawdzany realnym meczem, nie szacunkiem. + +--- + +## 0. Przepływ organizatora — co zostało po audycie 2026-08-08 + +Pełny audyt (26 ustaleń `O-1`…`O-26`, wraz z rozdziałem „co zostaje bez zmian i dlaczego") +→ [docs/przeplyw-organizatora.md](./docs/przeplyw-organizatora.md). Tutaj wyłącznie to, +czego jeszcze nie zrobiono — bez kopiowania treści, żeby obie listy się nie rozjechały. + +`O-20`, `O-23`, `O-24`, `O-25` zrobione w drugiej rundzie (duplikat „Zaproś z ekipy" +usunięty, karta „Twoja płatność" dla uczestnika, sekcja „Brakuje graczy" na `/moje-gry`, +karta „Zaproszeni" ze statusem odpowiedzi). `O-26` zrobione trzecią rundą (2026-08-10): +usunięto nieosiągalny modal „Zgłoś uczestnika" (`submitReport`/`getEventReports`, +typy `ReportType`/`PlayerReport`), martwe `handleSendSms`/`smsBusy` w +`EventDetailClient.tsx` i cały plik `lib/invites.ts` (zero importów). Ta sama runda +naprawiła też goły link w zaproszeniu do przejęcia wpisu gościa — `kopiujLinkPrzejecia` +kopiował sam URL bez argumentu, ten sam błąd co `O-18`, tu nienaprawiony do teraz — +i dodała sygnał „N gości bez konta" nad składem oraz przycisk „Zaproś do Bojo" w +widoku po starcie meczu (`ParticipantsList`), gdzie wcześniej znikał całkowicie. +`O-28`…`O-31` zrobione czwartą rundą (2026-08-12): wyzwalacz powiadamiający organizatora +o zmianie stanu kompletu składu (migracja `079`), przycisk „Wyślij rozliczenie ekipie" +w panelu kosztów, powrót z logowania na stronę meczu otwiera od razu okno zapisu +(`?dolacz=1`), a zaproszenie do przejęcia wpisu gościa może wysłać też ten, kto +konkretnego gościa dopisał, nie tylko organizator. Zostaje: + +| # | Co zostało | Gdzie | +|---|---|---| +| **O-10** | Krok 2 kreatora nadal niesie do 15 kontrolek przy 2 na kroku 1. „Więcej opcji" zdjęło jedną decyzję; osobnej przebudowy świadomie nie zakładamy — do rewizji, gdy będzie feedback od realnych organizatorów | `app/wydarzenia/nowe/page.tsx` | + +Świadomie poza zakresem audytu i tej rundy: trzeci poziom widoczności (§1.1), +odmrażanie flag (§2), doręczanie powiadomień poza aplikacją (e-mail/push — wymaga +weryfikacji domeny `bojo.pl` w Resend, poza repo), cron dla wygasania oferty +zwolnionego miejsca (nie da się z repo sprawdzić, czy `pg_cron` jest włączony na +produkcji), domknięcie RLS na `events` (patrz §5 niżej). + +--- + +## 1. Luki wobec wizji + +Pozycje, w których dokument strategiczny obiecuje coś, czego kod nie robi. **Dokument +jest nadrzędny** — to są zadania, nie błędy w dokumencie. + +### 1.1 Trzeci poziom widoczności meczu — ZDECYDOWANE bez zmiany schematu + +Wizja wymienia trzy poziomy: prywatny / widoczny dla grupy / publiczny. +Kod ma dwa: `events.visibility` to CHECK `('private','public')` (`002_events_and_auth.sql`). + +Decyzja z rundy „Grupy jako magnes na organizatora" (2026-08-14): **nie dokładamy** +trzeciej wartości do CHECK-a ani osobnej polityki RLS na `events` dla tego przypadku. +Zamiast tego nazwaliśmy i utrwaliliśmy dokumentacyjnie zachowanie, które już istniało: +`getMyGroupEvents()` (`lib/events.ts`) celowo pokazuje członkom grupy prywatne mecze +tej grupy na ich dashboardzie, a `getEventsByGroup()` listuje wszystkie mecze grupy +niezależnie od widoczności — czyli **prywatny mecz przypięty do grupy jest zawsze +widoczny dla jej członków**. Kreator meczu, edycja i strona meczu mówią to teraz wprost +pod kartą widoczności (`opisWidocznosciWGrupie()`, `lib/eventFeatures.ts`) — wcześniej +aplikacja tego nie mówiła, `llms.txt` twierdziło wprost „trzeciego poziomu widoczności +nie ma", a organizator musiał się domyślać. Pełny opis → [docs/domena.md § +Grupy](./docs/domena.md#grupy). + +**Wciąż otwarte, świadomie poza tym zakresem:** ogólna polityka `Events readable by +all` na `events` ma nadal warunek `USING (true)` — każdy, także niezalogowany, może +odczytać wszystkie mecze, w tym prywatne. `getMyGroupEvents()` działa dziś **wyłącznie +dzięki tej luźnej polityce**. Domknięcie RLS na `events` bez jednoczesnej przebudowy tej +funkcji po cichu urwałoby mecze grupowe z list, bez błędu — patrz §5 niżej. To osobne +zadanie z osobnym ryzykiem, nie robimy go przy okazji. + +### 1.2 Powiadomienie dla członków grupy o utworzeniu gry — ZROBIONE +Wizja stawia to jako część propozycji „Grupy — zastąpienie facebook/whatsapp". Bez +powiadomienia grupa nie zastępuje czatu, bo nikt nie wie, że gra powstała. + +~~Kanał powiadomień istnieje — brakuje wyzwalacza przy `createEvent` z `group_id`~~ +— migracja `072` (2026-08-09) dodała trigger `powiadom_o_nowym_meczu_w_grupie`: +każdy `INSERT` do `events` z ustawionym `group_id` wstawia powiadomienie +wszystkim członkom grupy poza organizatorem. Ten sam mechanizm (`SECURITY +DEFINER`, patrz `065`/`070`) powiadamia też organizatora o nowej prośbie o +dołączenie. Zostaje jako otwarte tylko to, co dokument opisywał osobno: +`game_alerts` (promień + sport, oparte o lokalizację, nie o członkostwo) wciąż +za flagą `SHOW_GAME_ALERTS` — to inna funkcja, nie ta sama luka. + +### 1.3 Gry cykliczne ukryte flagą +Wizja wymienia je w pierwszej propozycji wartości, na równi z grami pojedynczymi. +`SHOW_RECURRING = false` nadal ukrywa wejścia w `Header.tsx`, `app/page.tsx`, +`app/moje-gry` — decyzja do podjęcia: odmrozić czy zapisać uzasadnienie ukrycia. + +Kod **nie jest już kompletny w takim stopniu, jak wcześniej zapisano tutaj**: kreator +jednorazowego meczu (`app/wydarzenia/nowe/page.tsx`, krok 2) ma dziś kafelek „Wydarzenie +cykliczne" (`components/events/RecurringSettingsDialog.tsx`), który tworzy szablon +w `recurring_events` niezależnie od jednorazowego meczu — celowo minimalny zakres, patrz +[docs/funkcje.md#czego-nie-ma](./docs/funkcje.md#czego-nie-ma). Brakuje: + +- kolumny `events.recurring_event_id` i realnego linkowania „następnego wydarzenia" + (`lib/recurring.ts#getNextEventsForRecurring` dziś cicho zwraca puste — kolumna, + której szuka to zapytanie, nigdy nie powstała w żadnej migracji), +- realnego ekranu `/cykliczne/[id]/edytuj` — dziś zaślepka „Ta funkcja jest jeszcze + w przygotowaniu". + +Zakres pełnej integracji: migracja dodająca `events.recurring_event_id`, naprawa +`spawnEventInstance()` żeby faktycznie linkowała, realny formularz edycji szablonu +(mógłby ponownie użyć komponentów pól z `components/events/`, tak jak dziś robią to +kreator i edycja jednorazowego meczu). + +### 1.4 Rozliczenie po meczu — ZROBIONE +Propozycja brzmi „Rozliczysz ekipę w minutę". Wpis opisywał dwa problemy, oba naprawione: + +- ~~po zakończeniu meczu panel „Podział kosztów" znikał~~ — warunek `!eventStarted` + został zdjęty; panel renderuje się dziś przy `costGrosze > 0 && isOwner` + (`EventDetailClient.tsx`). +- ~~uczestnik nie widział, ile ma zapłacić~~ — nowa karta „Twoja płatność" (kwota po + uwzględnieniu zniżki kartowej przez `priceForParticipant()`, sposób płatności, status + opłacone/nieopłacone), gated przez `event.showPaymentStatus` — pierwsze miejsce, które tę + flagę odczytuje. To było ustalenie `O-23` z [audytu ścieżki organizatora](./docs/przeplyw-organizatora.md). + +--- + +## 2. Ukryte za flagami + +Jedno miejsce, jeden przełącznik. Pełna tabela z miejscami użycia → +[docs/funkcje.md](./docs/funkcje.md#flagi-funkcji). + +| Flaga | Co chowa | Dlaczego schowane | +|---|---|---| +| `SHOW_CUP` | Turniej BOJO Cup — pasek ogłoszeń, TrustBar, link w nagłówku | Brak gotowego turnieju; nie obiecywać na zapas | +| `SHOW_GAME_ALERTS` | „Ustaw alert" o pasującej grze w okolicy | Historycznie: brak kanału dostarczania. **Powód nieaktualny** — kanał istnieje (§3), do ponownej decyzji | +| `SHOW_SMS_FEATURES` | Potwierdzenie SMS + przypomnienia | Brak podpiętej bramki SMS | +| `SHOW_RECURRING` | Gry cykliczne | Skupienie na meczach jednorazowych — patrz §1.3 | +| `FEATURE_RESERVATIONS` | Rezerwacje obiektów, panel menedżera | Brak partnerstw z obiektami. Można włączyć per obiekt przez `fields.booking_enabled` | + +--- + +## 3. Kanał powiadomień — jest zbudowany + +Wcześniejsze wersje tego pliku i `PRZEWODNIK.md` twierdziły, że powiadomień nie ma. +**To była nieprawda.** Istnieje: + +| Element | Gdzie | +|---|---| +| Tabela `notifications` | migracja `025` | +| Logika | `lib/notifications.ts` | +| UI (dzwonek) | `components/layout/NotificationBell.tsx`, renderowany w `Header.tsx` | +| E-mail | Edge function `notify-game-alert` → Resend | +| SMS | Edge function `send-event-sms` → SMSAPI + Twilio | +| Zaproszenia cykliczne | Edge function `send-invites` | + +Czego brakuje: **web-push (PWA)** oraz wyzwalaczy dla zdarzeń innych niż alerty +(dołączenie do meczu, awans z rezerwy, nowa gra w grupie — §1.2). + +--- + +## 4. Zbudowane, nieużywane, martwy kod + +- **`components/home/NearbyGames.tsx`** — kompletny komponent „gry w pobliżu + alert", + nigdzie nie renderowany. Do decyzji: wpiąć (po włączeniu alertów) albo usunąć. +- **`components/map/{MapView,LeafletMapImpl,EventsMapView,EventsMapImpl}.tsx`** — nic + ich nie importuje. Aktywna mapa to `VenueExplorer.tsx`. +- **Tabela `games`** (`001`) — zastąpiona przez `events` (`002`), żaden kod jej nie używa. +- **`/gracze`** — trasa istnieje, ale to `redirect('/wydarzenia')`. Albo zbudować listę + graczy, albo usunąć trasę. +- **`RemindersSection` / `AlertSetupDialog`** — renderowane tylko za flagami z §2. + +--- + +## 5. Zadania techniczne + +### 5.0 Katalog boisk — ~~naprawa danych, potem cała Polska~~ ZROBIONE (2026-08-06/07) + +**Import całej Polski wszedł PR-em #109** (`scraper/import_osm_pbf.py` + workflow +`import-osm-polska.yml`, województwo po województwie). Przyczyny problemów 1–3 i 6 +z audytu poniżej są usunięte **u źródła**, a nie łatane po fakcie: + +- **ZERO AI** — sport i nawierzchnia wyłącznie z tagów OSM. Punkt 1 („sport skażony + sąsiedztwem") brał się z `analyze_venues.py`, który doklejał sporty rozpoznane + przez model z kafelka o boku ~150 m. Tego mechanizmu w nowym imporcie nie ma. +- **Nazwa ze złączenia przestrzennego** — „SP nr 12 — boisko piłkarskie, Świdnik" + zamiast „Boisko sportowe" (punkt 6). +- **Miejscowość w adresie** — „ul. Poznańska" przestaje wyglądać na duplikat 12× + (punkt 6, druga połowa). +- **Wymiary z geometrii poligonu**, mierzone zamiast zgadywanych ze zdjęcia. + +Audyt niżej zostaje jako **zapis historyczny** — opisuje stan sprzed importu i tłumaczy, +dlaczego importer wygląda tak, jak wygląda. Nie jest listą zadań. + +**Czego NIE da się sprawdzić z repo:** czy stare wiersze z `scraper.py` i analizy +satelitarnej nadal leżą obok tych z OSM. Właściciel potwierdził (2026-08-15), że stary +Poznań został ukryty. + +
Audyt 2026-08-03 — stan sprzed importu (zapis historyczny) + +**Stan bazy (audyt 2026-08-03, 1484 obiekty: 723 publiczne, 702 organizer_only, +59 ukrytych).** Skrypty diagnostyczne i naprawcze: `supabase/audyt/`. + +Znalezione, w kolejności ważności: + +1. **`sport` skażony sąsiedztwem.** `analyze_venues.py` robi + `update["sport"] = merged` — dokleja sporty wykryte przez AI do sportów z OSM. + Model dostaje kafelek Mapbox zoom 18 (~150 m boku), czyli cały kompleks, i opisuje + wszystko, co widzi. Kort obok boiska do koszykówki wpada do sportów tego boiska. + Skutek: filtr „koszykówka" zwraca korty tenisowe. Odwracalne — `name` pochodzi + z oryginalnego tagu OSM i nie było nadpisywane (`audyt/11-naprawa-sportow.sql`). +2. **Kontakty rozdane sąsiadom.** 56 maili na 1484 obiekty, część fałszywa: + `osrodekrataje@posir.poznan.pl` siedzi m.in. na boisku w Dopiewie. enrich z web + search szukał kontaktu dla obiektu bez nazwy własnej (`audyt/12-naprawa-kontaktow.sql`). + Do tego artefakty modelu w bazie: ``, `[email protected]`, markdown w URL. +3. **Adresy:** `park owa` (rozbite „Parkowa"), `ul. 187` / `ul. 32` (numery dróg + wojewódzkich), samo `Poznań` przy obiekcie pod Środą Wlkp. (`audyt/13-naprawa-adresow.sql`). +4. **~13% obiektów to nie boiska** — model napisał to w `ai_notes`, ale + `_ai_visibility()` chowa tylko przy jawnym `is_verified_venue = false` + (`supabase/sprzatanie-boisk.sql`). +5. **Bramka publikacji za hojna** — `public` wymaga dziś „AI coś napisało", nie + „wiemy coś pewnego". Do przepisania po naprawach 1–4. +6. **Nazwy generyczne** — ~35 z 60 obiektów w próbce to „Boisko sportowe" / + „Boisko — piłka nożna". Duplikaty nazwa+adres (12× „ul. Poznańska") to NIE są + duplikaty: to różne boiska w różnych gminach. Po współrzędnych klastrów jest tylko 8. + +**Cel: cała Polska najniższym kosztem.** Analiza satelitarna AI na ~50 tys. obiektów +to $150–200 za przebieg i tak czy siak zgadywanka — **nie tędy droga na tym etapie**. + +Tańsza ścieżka, do rozpisania: +- import z **Geofabrik `poland-latest.osm.pbf`** + `pyosmium` zamiast Overpass + (Overpass na całą Polskę = timeout albo ban). Koszt: zero, tylko CPU +- PBF ma **geometrię**, nie sam punkt → wymiary i powierzchnia liczone z poligonu, + a nie zgadywane ze zdjęcia; pin w centroidzie boiska +- nazwa własna, sport, nawierzchnia, oświetlenie, operator — z tagów OSM, za darmo +- AI **tylko** do jednego pytania („czy to w ogóle boisko"), na nowych obiektach, + Haiku, i **bez prawa zapisu** do `sport` / `surface` / `map_visibility` +- zdjęcia: **Mapillary** (darmowe API, CC-BY-SA) zamiast Google Places Photos + +Cron analizy satelitarnej zdjęty 2026-08-03 (chodził ~20×/mies. przez OR +w harmonogramie GitHuba). `fix-coords.yml` do usunięcia — forward-geocoding adresu +przesunął 59 pinezek nawet o 3 km (`supabase/przywroc-wspolrzedne.sql` cofa). + +
+ +**Copy landingu i dashboardu (2026-08-04) już mówi o „całej Polsce", z wyprzedzeniem +względem tego zadania.** Świadoma decyzja: tworzenie meczu z pinezką gdziekolwiek na +mapie działa już dziś, więc obietnica ma pokrycie — tylko katalog boisk jeszcze nie. +Jedyne miejsce, które nazywa Poznań wprost, to FAQ (`components/home/landing/content.ts`, +pytanie „Gdzie działa Bojo?") — do zaktualizowania (albo pozostawienia bez zmian, jeśli +gęstość poza Poznaniem wciąż będzie odstawać), gdy ten import się domknie. + +- [ ] **Zweryfikować stan migracji na produkcji.** W repo jest 60 migracji; stanu bazy + nie da się odczytać z repo. Patrz [docs/baza-danych.md](./docs/baza-danych.md). +- [ ] **Adresy kontaktowe wciąż na `bojo.app`** — `kontakt@bojo.app` w `/regulamin`, + `/prywatnosc`, `/turniej` oraz nadawca `noreply@bojo.app` w edge functions, przy + domenie kanonicznej `bojo.pl`. Wymaga potwierdzenia, że skrzynki na `bojo.pl` + istnieją, oraz weryfikacji domeny w Resend (SPF + DKIM). +- [ ] **Czyszczenie bazy boisk** — odsianie siłowni, kortów tylko-tenisowych, kartingów. + Dziś filtr `RELEVANT_SPORTS` jest **po stronie klienta** (`VenueExplorer`). + Docelowo: `map_visibility = 'hidden'` w bazie + panel admina do przeglądu. +- [ ] **Zod — walidacja danych z bazy** (mappery `toEvent`, `toField`). +- [ ] **Domknąć reguły dostępu w RLS na `events`** — część sprawdzana dziś po stronie + przeglądarki. **Kolejność ma znaczenie:** naprawić `getMyGroupEvents()` PRZED + dociągnięciem polityki na `events` (patrz ustalenie z 2026-08-04 niżej) — + funkcja dziś zależy wyłącznie od luźnego warunku `true`, więc domknięcie RLS bez + jednoczesnej przebudowy tej funkcji po cichu urwie mecze grupowe z list, bez błędu. + **Nadal otwarte** po rundzie „Grupy jako magnes na organizatora" (2026-08-14) — + świadomie poza jej zakresem, patrz §1.1 wyżej. +- [x] **`group_members` przyjmowało INSERT od każdego, kto znał UUID grupy — + naprawione.** (2026-08-14) Polityka `"Users join groups"` z migracji `044` + sprawdzała wyłącznie `auth.uid() = user_id`; `join_code` filtrował dopiero + interfejs (`GroupsClient.handleJoin`), a same grupy są publicznie czytelne — + każdy zapisany UUID wystarczał, żeby dołączyć bez znajomości kodu. Migracja `094` + zdejmuje tę politykę: jedyne drogi wejścia to `dolacz_do_grupy_kodem()` (trzeba + znać kod) i `dodaj_czlonka_do_grupy()` (trzeba mieć `can_manage_members`). +- [ ] **Numer do BLIKA nadal przyjeżdża w całym wierszu `events`.** `canSeeBlikPhone()` + (`lib/payments.ts`) chowa numer w UI (organizator zawsze, uczestnik dopiero + godzinę przed meczem), ale `toEvent()` robi `select('*')`, a RLS na `events` jest + wierszowe — numer da się odczytać z ruchu sieciowego mimo że interfejs go nie + pokazuje. Twarde odcięcie wymaga osobnego widoku bez `blik_phone` albo uprawnień + kolumnowych, i przepisania selectów, które dziś czytają cały wiersz. +- [ ] **Build w CI** — po dodaniu sekretów `NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` do + repo dołożyć `npm run build` do `.github/workflows/ci.yml` (dziś build wymaga + kluczy i dlatego jest poza CI). +- [x] **Mignięcie landingu u zalogowanego — naprawione ciasteczkiem-wskazówką.** + (2026-08-04) Serwer wciąż nie ma prawdziwej sesji, ale `lib/auth.tsx` ustawia + malutkie ciasteczko `bojo_sess=1` (bez tokenu — patrz `lib/sessionHint.ts`) + przy `SIGNED_IN`/`TOKEN_REFRESHED`, kasuje przy `SIGNED_OUT`. `app/page.tsx` + czyta je przez `cookies()` i renderuje szkielet dashboardu (`AppHomeSkeleton.tsx`) + zamiast landingu, gdy wskazówka mówi „zalogowany". Nieaktualna wskazówka + samo-naprawia się przy pierwszym `getSession()`. Prawdziwa sesja serwerowa + (`@supabase/ssr` + `middleware.ts`) to wciąż osobny, większy krok — patrz niżej. +- [ ] **`@supabase/ssr` + `middleware.ts`** — właściwe zamknięcie tematu sesji + serwerowej, otwierające drogę do prawdziwego SSR redirectu i serwerowego + renderowania danych dashboardu. Odłożone od ciasteczka-wskazówki wyżej, bo + dotyka klienta Supabase używanego przez ~44 pliki i może wymusić przepisanie + callbacku Google (`app/auth/callback`) na route handler z PKCE — flow logowania + to zbyt duże ryzyko, żeby jechało w tym samym PR-ze co UI. Warunek wstępny: + `npm run build` w CI (dziś poza pipeline'em — patrz pozycja wyżej) — inaczej + błąd w `middleware.ts` wyjdzie dopiero na produkcji (jedno środowisko, każdy + merge idzie na żywo). +- [ ] **`syncReserveClaim` nie ma crona.** Kolejkę ofert dla rezerwowych (`733cf49`, + `058_reserve_claim.sql`) rusza wyłącznie wejście na stronę meczu + (`/wydarzenia/[id]`) — jeśli nikt nie wejdzie, oferta nie wygasa i miejsce nie + trafia do kolejnej osoby. Dashboard świadomie NIE woła tego RPC przy każdym + otwarciu `/` (zapis do bazy przy każdej wizycie każdego użytkownika byłby zbyt + kosztowny) — liczniki miejsc na kartach dashboardu mogą więc być nieaktualne, + dopóki ktoś nie wejdzie w konkretny mecz. +- [ ] **Potwierdzone na produkcji przez Supabase MCP (2026-08-04):** polityka RLS + `Events readable by all` na `events` ma warunek `true` — każdy, także + niezalogowany, może odczytać WSZYSTKIE mecze, w tym prywatne. Kod dołączenia + (`join_code`) chroni tylko w UI, nie w bazie — potwierdza to punkt „Domknąć + reguły dostępu w RLS" wyżej, tym razem z realnym zapytaniem, nie tylko + podejrzeniem. `getMyGroupEvents()` (mecze grup, w tym prywatne — `lib/events.ts`) + działa dziś **wyłącznie dzięki tej luźnej polityce**: gdy ktoś RLS domknie, ta + funkcja po cichu zacznie zwracać mniej wierszy, bez błędu. +- [ ] **`event_participants.claim_token` jest czytelny dla każdego.** Polityka + `Participants readable by all` ma warunek `USING (true)`, więc token przejęcia + wpisu gościa (migracja `066`) da się odczytać z ruchu sieciowego dla dowolnego + meczu, nie tylko przez link, który wysłał dopisujący. Token jest projektowany + jako sekret na okaziciela — jak `join_code` — więc samo to nie jest regresją, ale + warto to uwzględnić przy domykaniu RLS na `event_participants` (spójne z pozycją + o `events` wyżej). + +### Bugi UI — zgłoszone z testów na urządzeniu (2026-08-03) + +- [x] **Sticky CTA „Stwórz mecz" zasłaniało stopkę na landingu.** (2026-08-03) + Przyczyna: `Landing` dopełnia własne sekcje (`pb-24`), ale `SiteFooter` jest + rodzeństwem `
` w `app/page.tsx` — poza tym dopełnieniem. Naprawione: + `StickyCta` obserwuje teraz również stopkę (`#site-footer`) i chowa się, gdy + ta wejdzie w widok. Na dole strony jest już `LandingFinalCta`, więc sticky bar + nic tam nie wnosił. + +- [ ] **Strona główna wygląda pusto, gdy nie ma otwartych meczów.** + `LandingOpenGames` zwraca `null` przy zerze pasujących gier (świadoma decyzja: + pusty stan gorszy niż brak sekcji) — ale wtedy landing traci sekcję i robi się + rzadki. Do przemyślenia razem z pozycją niżej. + + **Uwaga strukturalna, ważniejsza niż sam wygląd:** sekcja filtruje + `taken < maxPlayers`, czyli **pokazuje tylko mecze z wolnymi miejscami**. + Skoro celem produktu jest, żeby mecze zdobywały komplet, to przy powodzeniu + ta sekcja będzie pusta **tym częściej, im lepiej idzie**. Sam filtr trzeba + przemyśleć — np. pokazywać też pełne mecze (jako dowód, że apka żyje, z jasnym + oznaczeniem „komplet"), albo zastąpić sekcję czymś innym, gdy brak wolnych miejsc. + +### Bezpieczeństwo (wdrożone — pilnować) +- Telefony i e-maile zescrapowane z OSM **ukryte domyślnie**; widoczność per obiekt + włącza admin (`contact_visible`, migracja `033`). Egzekwowane na poziomie DB. +- `/admin`, `/api`, `/d/`, `/g/` wyłączone z indeksowania (`robots.ts`). Kody dołączenia + są jedyną kontrolą dostępu do prywatnych meczów — nie mogą trafić do wyszukiwarki. +- Mecze prywatne nie emitują JSON-LD (`lib/structuredData.ts`, pokryte testem). + +--- + +## 6. Turniej — stan i co zostało + +**Zbudowane** (wbrew wcześniejszej wersji tego pliku, która listowała to jako TODO): +`lib/tournaments.ts` (455 linii), 6 tabel `tournament_*` (`029`–`031`), trasy `/turniej`, +`/turniej/rejestracja`, `/turniej/drabinka`, `/turniej/druzyna/[teamId]`, +`/turniej/druzyna/[teamId]/dolacz`, RPC `tournament_team_count`, `shared_availability_days`, +`admin_team_contacts`. Rejestracja drużyn, składy, terminarz, drabinka, zgłaszanie +i potwierdzanie wyników — działa. Ukryte flagą `SHOW_CUP`. + +**Zakres: 3 sporty — piłka nożna, koszykówka, siatkówka plażowa.** + +> ⭐ **Siatkówka plażowa to główny przypadek użycia** — więcej osób robi turnieje +> plażówki niż hali. Halową siatkówkę traktujemy jako zwykły sport meczowy. + +| Sport | Format domyślny | Rozmiar | Uwaga | +|---|---|---|---| +| **Siatkówka plażowa** ⭐ | pucharowy | 2v2 / 4v4 | główny przypadek; boiska już na mapie | +| Piłka nożna | grupy → puchar | 5v5 / 7v7 | trzeba wielu boisk lub slotów czasowych | +| Koszykówka | pucharowy | 3v3 | streetball, najpopularniejszy format amatorski | + +### Zostało do decyzji +- [ ] Wizualizacja drabinki na mobile (drzewko jest trudne na małym ekranie) +- [ ] Powiadomienia: wylosowano drabinkę / kiedy następny mecz +- [ ] Płatność za drużynę (re-użyć `trackPayments`) +- [ ] Lista wielu turniejów — dziś model zakłada jeden aktywny (`getActiveTournament`) + +--- + +## 7. SEO / GEO — treści (praca ludzka, nie kod) + +Warstwa techniczna (JSON-LD, canonical, robots, sitemap, metadata) jest w kodzie. +Poniższe wymagają pisania treści lub działań poza repo — wg badań GEO (Princeton, +KDD 2024) to one dają największy wzrost cytowalności w wyszukiwarkach AI: + +- [x] **Sekcja FAQ na stronie głównej** — widoczne pytania i odpowiedzi z FAQPage + schema, dodane w ramach redesignu landingu (2026-08-03): + `components/home/landing/{LandingFaq,content}.tsx`, `lib/structuredData.ts` + (`faqJsonLd`). Treść widoczna i schema dzielą jedno źródło (`LANDING_FAQ`), + pilnowane testem `src/__tests__/landingContent.test.ts`. +- [x] ~~Strona `/o-nas`~~ (E-E-A-T) — kim jesteśmy, dlaczego budujemy Bojo, kontakt, model + biznesowy, atrybucja OSM. Doszły też trzy siostrzane strony pod tę samą strategię: + `/jak-dziala-bojo`, `/dlaczego-bojo`, `/faq` (2026-08-13). **Usunięta** — strona + treści bez wystarczającej wartości dla organizatora, sprzątnięta w ramach szlifowania + przepływu organizatora. +- [x] **Dane własne jako treść** (GEO: statystyki podnoszą cytowalność ~40%) — sekcja + `LandingStats` na stronie głównej pokazuje liczby zaszyte w `LANDING_STATS` + (`components/home/landing/content.ts`), aktualizowane ręcznie. + ⚠️ **Sprostowanie (2026-08-13):** ten wpis wcześniej twierdził, że liczbę boisk liczy + `lib/landingStats.ts`/`getPublicVenueCount()` przy renderze — **te pliki nie istnieją**. + `LANDING_STATS.sportsValue` i spółka to statyczne literały, nie zapytanie do bazy. + Licznik z bazy zostaje pomysłem, nie zrobioną rzeczą. +- [ ] **Obecność zewnętrzna / backlinki** — profile w katalogach, grupy FB, prasa + lokalna, współprace z obiektami. Najsilniejszy sygnał klasycznego SEO; buduje + się miesiącami, poza repo. +- [ ] **Core Web Vitals** — zmierzyć po wdrożeniu (PageSpeed Insights) i dopiero na + podstawie pomiaru decydować o optymalizacjach. + +## 8. Pomysły jeszcze niezbudowane + +### ~~Przejęcie profilu gościa (claim)~~ — ZROBIONE (PR #104, migracja `066`) +Zrealizowane inaczej niż w szkicu niżej: token nadaje wyzwalacz w bazie (nie kod +aplikacji), trasa to `/gracz/przejmij/[token]`, a przejęcie obejmuje JEDEN wiersz, +nie klaster po `added_by + name`. Klaster zostaje jako możliwe rozszerzenie — +dziś człowiek dostaje link per mecz. Szkic oryginalny zostawiony niżej jako zapis +decyzji. + +
Pierwotny projekt + +Projekt rozpisany w [docs/rewizja-2026-08.md](./docs/rewizja-2026-08.md) (dlaczego) +i w rozmowie 2026-08-04 (jak). Skrót mechaniki: + +- `event_participants.claim_token` (unikalny, tylko wiersze gości) + + `claimed_at`; token generowany przy `addGuest` i backfillem dla istniejących +- publiczna strona podglądu `/przejmij/[token]` — **bez logowania** pokazuje + dorobek gościa (mecze, gole, kto dopisał) przez RPC `guest_claim_preview(token)` + (`SECURITY DEFINER`, zwraca agregat, nie wiersze — nie okrąża RLS prywatnych meczów) +- przejęcie: RPC `claim_guest_profile(token)` — ustawia `user_id = auth.uid()`, + `is_guest = false`, `claimed_at = now()`, nadpisuje `name` nazwą profilu; + obejmuje CAŁY klaster wierszy `same added_by + lower(name)` (lista pokazana + do potwierdzenia na stronie podglądu); wiersz w meczu, w którym przejmujący + już ma własny udział, jest pomijany +- pojemność bez zmian (wiersz już liczony); statystyki zaczynają się liczyć + same, bo `get_player_stats` filtruje `is_guest = false`, a `player_goals` + idzie po `participant_id` +- dystrybucja: przycisk „Wyślij mu jego profil" przy wierszu gościa (widzi + dopisujący i organizator) + baner po meczu „N gości bez konta"; ŻADNEJ + automatycznej wysyłki — link niesie człowiek, który gościa zna +- miara sukcesu całego produktu: % wierszy gości z `claimed_at` (north star + z rewizji — konwersja zaproszony → użytkownik) +- anty-nadużycia: token = sekret na okaziciela (model jak `join_code`), + rate limit na RPC przez `check_rate_limit`; regeneracja tokenu — later + +
+ + +### PWA + web-push — plan (priorytet od 2026-08-15) + +Po co: stała ekipa to te same dziesięć osób w ten sam czwartek. Jedyne, czego brakuje +w ich tygodniu, to **doręczenie poza aplikacją** — dziś zaproszenie „z ekipy" czeka, +aż zaproszony sam otworzy Bojo. Push to jedyny darmowy kanał, który to załatwia, +i przy okazji domyka dziurę SMS-ów (`SHOW_SMS_FEATURES`) bez płacenia za wiadomość. + +**Kanał powiadomień JUŻ ISTNIEJE** (§3): tabela `notifications`, `lib/notifications.ts`, +dzwonek, wyzwalacze w migracjach. Push nie jest nowym systemem — to **druga końcówka +doręczania** dla wierszy, które i tak powstają. + +#### Etap 1 — instalowalność („apka na ekranie") + +- [ ] `app/manifest.ts` (Next 14 App Router generuje `/manifest.webmanifest`): + `name`, `short_name: "Bojo"`, `start_url: "/"`, `display: "standalone"`, + `theme_color: "#15663E"` (ten sam co w `layout.tsx`), `background_color`. +- [ ] **Ikony — dziś ich nie ma w `public/`.** Potrzebne: 192×192, 512×512, + 512×512 `maskable` (Android przycina do koła — bez wariantu maskable logo + dostaje obcięte rogi) oraz `apple-touch-icon` 180×180. +- [ ] `appleWebApp` w `metadata` — **iOS ignoruje ikony z manifestu** i czyta wyłącznie + `apple-touch-icon`. Bez tego na iPhonie na ekranie głównym ląduje zrzut strony. +- [ ] Minimalny service worker w `public/sw.js` + rejestracja po montażu. + +**Świadomie BEZ trybu offline.** SW ma obsłużyć `push` i `notificationclick`, nic więcej. +Service worker cache'ujący HTML potrafi serwować stary build po deployu, a aplikacja +żyjąca z bazy pokazywałaby wtedy nieaktualne składy — gorsze niż brak offline'u. +Z tego samego powodu **nie bierzemy `next-pwa`** (słabo utrzymywany pod App Router) +ani Serwista: do samego pusha żaden z nich nie jest potrzebny. + +#### Etap 2 — infrastruktura push + +- [ ] Para kluczy **VAPID**. Publiczny → `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, + prywatny → sekret Edge Functions (**nie do repo**, patrz `.env` w `.gitignore`). +- [ ] Migracja `092_push_subscriptions.sql`: `user_id`, `endpoint` (UNIQUE), + `p256dh`, `auth`, `created_at`, `last_seen`. RLS: właściciel widzi i kasuje + wyłącznie swoje wiersze. +- [ ] Zapis subskrypcji przez `zaktualizujJedenWiersz()` / zwykły insert z `lib/` — + **nie z komponentu** (granica z AGENTS.md). +- [ ] Edge function `send-push` (Deno + `web-push`), wzorowana na `send-event-sms`. + Endpoint, który zwróci `410 Gone`, kasujemy z tabeli — inaczej lista subskrypcji + puchnie o martwe wpisy. + +#### Etap 3 — podpięcie i moment proszenia o zgodę + +- [ ] Wyzwalacz na `INSERT INTO notifications` → `send-push`. Dzięki temu push dziedziczy + wszystkie istniejące powody powiadomień, bez dublowania logiki. +- [ ] **Kiedy prosić o zgodę.** Nie przy wejściu. Przeglądarka daje jedno pytanie — + po odmowie nie da się zapytać ponownie bez grzebania w ustawieniach. Prosimy + w chwili, gdy powód jest oczywisty: **tuż po dołączeniu do meczu** albo po + utworzeniu ekipy. Musi to być reakcja na kliknięcie — bez gestu użytkownika + przeglądarka odrzuci prośbę. +- [ ] Zachęta do instalacji: własny przycisk na `beforeinstallprompt` (Android/Chrome) + oraz instrukcja „Udostępnij → Do ekranu początkowego" dla iOS, gdzie tego + zdarzenia nie ma. + +#### Czego to NIE załatwi + +**iOS dostaje web-push dopiero od 16.4 i wyłącznie dla PWA dodanej do ekranu +głównego.** Użytkownik iPhone'a w Safari nie dostanie powiadomienia, dopóki nie +zainstaluje — i o tym trzeba mówić wprost w interfejsie, zamiast obiecywać push +wszystkim. To jest też powód, dla którego etap 1 musi być zrobiony porządnie, a nie +„jakoś": na iOS instalacja jest warunkiem kanału, nie ozdobnikiem. + +#### Weryfikacja + +`e2e/wizualne.spec.ts` sprawdzi, że manifest się serwuje i SW rejestruje; samego +doręczenia pusha Playwright nie pokaże — to test ręczny na dwóch telefonach +(Android + iPhone po instalacji). + +--- + +### Zamykanie zapisów po komplecie (decyzja produktowa do wdrożenia) +Dziś: gdy ktoś się wypisze, miejsce natychmiast wraca do puli i zajmuje je **pierwsza +osoba, która kliknie „Dołącz"** — również ktoś z zewnątrz, z pominięciem listy rezerwowej. + +Docelowo: **komplet = koniec zapisów.** Po zwolnieniu miejsca zapisy zostają zamknięte, +a organizator decyduje — pyta rezerwowych albo klika **„Otwórz zapisy"**. Spójne +z istniejącą decyzją o **braku auto-awansu z rezerwy** (patrz `docs/domena.md`): to gracz +musi wiedzieć, że wchodzi do gry, a nie wskoczyć tam po cichu. + +Zakres: flaga na `events` (np. `signups_open`), zamknięcie przy osiągnięciu kompletu, +przycisk dla organizatora, komunikat dla wchodzących („zapisy zamknięte — zapytaj +organizatora"), przemyślenie interakcji z listą rezerwową. + +**Styka się z „Otwórz dla okolicy" (migracja `097`, `docs/domena.md § Czy gramy`)** — +oba dotyczą tego, co dzieje się z wolnym miejscem w meczu ekipy. Otwarcie dla okolicy +zamienia PRYWATNY mecz z brakiem ludzi w PUBLICZNY, żeby dosięgnąć kogoś spoza ekipy; +to zadanie dotyczy tego, kto dostaje pierwszeństwo do zwolnionego miejsca WEWNĄTRZ +already-otwartego zapisu. Niezależne mechanizmy, ale przy projektowaniu warto sprawdzić, +czy „Otwórz dla okolicy" na skompletowanym meczu (mało sensowne — nie ma wolnych miejsc) +powinno być wyłączone tak samo, jak to zadanie chce wyłączyć zapisy po komplecie. + +**Priorytet #1 z rundy „Czy gramy?" (2026-08-15): Web-push (PWA).** Jedyny element, +który jednocześnie odblokowuje: powiadomienia o meczu ekipy poza aplikacją (dziś panel +„Kto milczy" i tak kończy na obejściu — kopiowaniu gotowego tekstu na WhatsAppa, bo +kanału w samym Bojo nie ma), realne działanie „Zaproś z ekipy" (patrz niżej), `SHOW_GAME_ALERTS` +(powód ukrycia już nieaktualny) i większość zastosowań SMS-a. Dopiero z nim zdanie +„grupa zastępuje WhatsAppa" (`docs/wizja.md`) przestaje być obietnicą bez pokrycia. +Rozpisany plan: [§8 „PWA + web-push"](#pwa--web-push--plan-priorytet-od-2026-08-15). + +- **Onboarding / pierwsza gra** — co widzi świeży user bez gier w okolicy +- **Rankingi publiczne** i **odznaki** (strzelec miesiąca, 100h na boisku) — wizja §B +- **Ocena umiejętności i dopasowywanie gier do poziomu** — wizja §B +- **MVP meczu** — wizja wymienia obok goli i asyst, w kodzie nie istnieje +- **Realny przepływ pieniędzy** (BLIK/Stripe) — dziś tylko rejestrowanie, kto zapłacił +- **Wynajem sędziego** — wizja §A +- **Wyszukiwarka** boisk po nazwie/dzielnicy na mapie +- **Statystyki sezonowe** dla stałych ekip +- **Agent kontaktowy** — automat wysyłający maile do obiektów i podpowiadający następny + ruch w CRM + +### Zaproszenia „z ekipy" nie mają doręczenia poza aplikacją +Organizator klika „Zaproś z ekipy" na stronie meczu (`lib/playerInvites.ts`) i wygląda +to na wysłane zaproszenie — w rzeczywistości to tylko wiersz w `event_player_invites`, +widoczny zaproszonemu dopiero, gdy sam otworzy Bojo. Zero SMS-a, e-maila czy pusha. +Dziś obchodzone przez to, że organizator i tak rozsyła link ręcznie (`navigator.share`), +ale to znaczy, że „Zaproś z ekipy" nie skraca nic ponad to, co już robi „Udostępnij". +Realny fix to ten sam kanał, którego brakuje SMS-om i alertom gry (`SHOW_SMS_FEATURES`, +`SHOW_GAME_ALERTS`) — patrz pozycja „Web-push (PWA)" wyżej. + +Osobno, już naprawione: sam przycisk **dublował się na stronie meczu** (`O-20` +w [audycie ścieżki organizatora](./docs/przeplyw-organizatora.md)) — dwa wejścia, różne +ikony, różne warunki widoczności. Zostaje jeden, przy liczniku wolnych miejsc. Ślepy +zaułek dialogu przy braku jakiejkolwiek grupy (tekst „załóż grupę" bez linku) też +naprawiony wcześniej. + +### Rewizja `SHOW_RECURRING` pod kątem strategii „organizator" +Mecze cykliczne (`lib/recurring.ts`, `app/cykliczne/*`) są w pełni zbudowane: szablon +tygodniowy, imienna lista zaproszeń, statystyki niezawodności gracza +(`getGroupPlayerStats`), wysyłka przez edge function `send-invites`. Ukryte celowo +(„focus na jednorazowe mecze", `lib/features.ts:24-29`), ale to funkcja, która wprost +redukuje cotygodniową pracę organizatora — najbliższą kategorię wartości do strategii +„organizator, nie targowisko" z rewizji 2026-08. Przed odkryciem: zweryfikować, czy +`send-invites` faktycznie doręcza (nieznany status wdrożenia), potem świadoma decyzja +o priorytecie, nie cichy flip flagi. + +### `docs/wizja.md` sekcja 1 nie odzwierciedla zwrotu na organizatora +Sekcja 1 (dokument nadrzędny, werbatim) opisuje dwustronny rynek — „organizowanie i +dołączanie" na równi, plus propozycja wartości czysto graczowa („Znajdź grę w 2 minuty"). +Landing i dashboard już zrobiły zwrot na organizatora (`docs/llm-context.md`, wpis +2026-08-04 „Landing i dashboard: zwrot na organizatora"), więc kod wyprzedził dokument +źródłowy. Sekcji 1 nie wolno parafrazować przy okazji innych zmian — to wymaga świadomej +rewizji przez właściciela produktu, nie automatycznej edycji. + + +### Zgłaszanie błędów: w aplikacji i w danych obiektu +Dwa różne zgłoszenia, celowo rozdzielone — mają inny odbiorcę i inny cykl życia. + +**Błąd w aplikacji.** Coś nie działa, coś się rozjeżdża. Odbiorcą jesteśmy my. +Minimalna wersja: formularz z opisem + automatycznie doklejony adres strony, +przeglądarka i id użytkownika. Bez tego zgłoszenia są nie do odtworzenia. + +**Błąd w danych obiektu.** „Tu już nie ma bramek", „nawierzchnia jest sztuczna, +nie trawa", „ten obiekt w ogóle nie istnieje". To jest cenniejsze i trudniejsze, +bo dotyczy danych, których **nie jesteśmy właścicielem** — pochodzą z OSM na +licencji ODbL. Do przemyślenia przy projektowaniu: + +- czy poprawka nadpisuje wartość z OSM w naszej bazie, czy tylko ją przykrywa + (kolumna `override_*` obok oryginału) — druga opcja pozwala ponownie + zaimportować region bez kasowania pracy użytkowników; +- czy i jak oddajemy poprawki do OSM. Zgłoszenie „nawierzchnia jest inna" jest + wartościowe dla całego OSM, a odsyłanie ich z powrotem to najtańszy sposób, + żeby katalog poprawiał się sam. Najprostsza forma: przycisk otwierający + gotową notatkę w OSM (`https://www.openstreetmap.org/note/new`); +- ile zgłoszeń wystarczy, żeby zmienić dane bez naszej moderacji. Przy jednym + zgłoszeniu ktoś złośliwy psuje katalog; przy trzech niezależnych — raczej nie; +- „obiekt nie istnieje" to osobny przypadek: nie poprawka pola, tylko wniosek + o zdjęcie z mapy. Powinien wymagać naszej decyzji. + +Wartość: to jedyny mechanizm, który sprawia, że katalog **poprawia się sam** +w miarę używania, zamiast starzeć się między importami. + +### Zgodność z licencją ODbL — dopiąć +Dane z OpenStreetMap są na licencji ODbL. Wymaga ona uznania autorstwa +i udostępnienia bazy pochodnej na tych samych warunkach. Co mamy, a czego nie: + +- **jest**: atrybucja na mapie (`components/map/MapAttribution.tsx`, standardowa + stopka Leafleta); +- **brakuje**: atrybucji na stronie obiektu, gdzie pokazujemy dane z OSM poza + mapą — nazwę, nawierzchnię, wymiary, udogodnienia. Tam też należy się „Dane + © autorzy OpenStreetMap"; +- **do decyzji**: w jakiej formie udostępniamy bazę pochodną. Najprostsza droga + to publiczny zrzut kolumn pochodzących z OSM (`source = 'osm'`) pod stałym + adresem, z informacją o licencji. Nie dotyczy meczów, komentarzy ani kont — + te są nasze i nie są bazą pochodną; +- **osobne ryzyko**: zdjęcia z Google Places zebrane dla Poznania. Ich warunki + są znacznie bardziej restrykcyjne niż ODbL i **nie pozwalają na dowolne + przechowywanie i serwowanie**. Do sprawdzenia przed pokazaniem ich gdziekolwiek + poza kontekstem, w którym je pobrano. + + +### Odpowiadanie na zaproszenie z listy — do zaprojektowania +Dziś karta zaproszenia na stronie głównej i w Moich grach jest **samą kartą**, +bez żadnej akcji. Odpowiada się wchodząc na stronę meczu. + +Problem jest prawdziwy i wart rozwiązania: **„tak" kosztuje więcej kliknięć niż +„nie"** — a raczej kosztowałoby, bo dziś nie ma nawet jak odmówić z listy. +Przy funkcji, której sensem jest ściągnięcie ludzi na mecz, to odwrócone +proporcje. + +Próbowaliśmy dwóch układów, oba odrzucone jako zbyt ciężkie wizualnie: + +1. **Obwódka + nagłówek „ZAPROSZENIE"** nad kartą — przy trzech zaproszeniach + pod rząd lista robiła się ścianą ramek. +2. **Para przycisków „Dołączam" / „Odrzuć"** pod kartą — dokładała dwa duże + elementy na każdą pozycję listy. + +Kierunki do rozważenia przy następnym podejściu: +- akcja ukryta do gestu (przesunięcie karty w bok), zamiast stale widocznych + przycisków; +- jedna ikona w rogu karty zamiast dwóch przycisków — ale prawy górny róg jest + zajęty przez cenę; +- odpowiedź nie na liście, tylko w powiadomieniu pod dzwonkiem, gdzie karta + jest mniejsza i akcja nie konkuruje z resztą treści; +- rozróżnienie wizualne kartą samą w sobie (inny odcień tła), bez dokładania + elementów. + +Kod odpowiedzi (`joinEvent` z listy, `dismissInvite`) jest napisany i działał — +patrz PR #107. Wróci, gdy będzie wiadomo, jak ma wyglądać. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a00748da..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,148 +0,0 @@ -## [1.9.0] 2022 - 04 - 18 -### Updated dependencies -- dependencies updated -- `expo` module core added -- `stackNavigator` and `drawerNavigation` changes ( color scheme set ) -- `expo` updated -- syntax update in `header Shown` - -## [1.8.0] 2021 - 03 - 04 -### Updated dependencies -- updated `expo-asset@8.2.0` to `expo-asset@8.2.1` -- updated `expo-font@8.3.0` to `expo-font@8.4.0` -- updated `expo-linear-gradient@8.3.0` to `expo-linear-gradient@8.4.0` -- updated `react-native-gesture-handler@1.7.0` to `react-native-gesture-handler@1.8.0` -- updated `react-native SDK@39.0.3` to `react-native SDK@40.0.1` -- updated `Expo @39.0.0` to `Expo @40.0.0` -- updated `jest-expo@39.0.0` to `jest-expo@40.0.0` -- updated `react-native-screens@2.10.1` to `react-native-screens@2.15.2` -- updated `react-native-safe-area-context@3.1.4` to `react-native-safe-area-context@3.1.9` -- updated `@react-navigation/drawer@5.8.2` to `@react-navigation/drawer@5.12.4` -- updated `@react-navigation/native@5.5.0` to `@react-navigation/native@5.9.3` -- updated `@react-navigation/stack@5.4.1` to `@react-navigation/stack@5.14.3` -- added `expo-app-loading@1.01` - -## [1.7.0] 2020 - 10 - 30 -### Updated dependencies -- updated `expo-asset@8.1.5` to `expo-asset@8.2.0` -- updated `expo-font@8.1.0` to `expo-font@8.3.0` -- updated `expo-linear-gradient@8.1.5` to `expo-linear-gradient@8.3.0` -- updated `react-native-gesture-handler@1.6.0` to `react-native-gesture-handler@1.7.0` -- updated `react-native SDK@37.0.1` to `react-native SDK@39.0.3` -- updated `babel-preset-expo@8.2.1` to `babel-preset-expo@8.3.0` -- updated `Expo @37.0.0` to `Expo @39.0.0` -- updated `jest-expo@37.0.0` to `jest-expo@39.0.0` -- updated `react-native-reanimated@1.7.0` to `react-native-reanimated@1.13.0` -- updated `react-native-screens@2.2.0` to `react-native-screens@2.10.1` -- updated `react-native-safe-area-context@0.7.3` to `react-native-safe-area-context@3.1.4` -- updated `@react-native-community/masked-view@0.1.6` to `@react-native-community/masked-view@0.1.10` -- updated `react@16.9.0` to `react@16.13.1` -- updated `galio-framework@0.6.3` to `galio-framework@0.7.1` -- changed fork for `react-native-modal-dropdown` - -### Updated files -- updated `Onboarding.js` -> fixed background color -- updated `Pro.js` -> fixed background color -- updated `Components.js` -> fixed ScrollView bug, error related to button color, added marginTop for the Dropdown and removed unnecessary styles - -## [1.6.0] 2020 - 06 - 11 -### Updated dependencies -- updated `expo-asset@8.0.0` to `expo-asset@8.1.5` -- updated `expo-font@8.0.0` to `expo-font@8.1.0` -- updated `react-native-gesture-handler@1.5.0` to `react-native-gesture-handler@1.6.0` -- updated `react-native-screens@2.0.0-beta.8` to `react-native-screens@2.2.0` -- updated `react-native SDK@36.0.0` to `react-native SDK@37.0.0` -- updated `babel-preset-expo@8.0.0` to `babel-preset-expo@8.2.1` -- updated `Expo @36.0.0` to `Expo @37.0.0` -- updated `@react-navigation/native@5.0.5` to `@react-navigation/native@5.5.0` -- updated `@react-navigation/stack@5.0.6` to `@react-navigation/stack@5.4.1` -- updated `@react-navigation/compat@5.0.5` to `@react-navigation/compat@5.1.25` -- updated `@react-navigation/drawer@5.0.5` to `@react-navigation/drawer@5.8.2` -- updated `jest-expo@36.0.0` to `jest-expo@37.0.0` - -### Updated files -- updated `Settings.js` and removed a warning which was showing up because of `ScrollView` - -## [1.5.0] 2019 - 02 - 20 -### Removed dependencies -- removed `react-navigation@3.11.0` -### Added dependencies -- added `@react-navigation/compat@5.0.0` -- added `@react-navigation/drawer@5.0.0` -- added `@react-navigation/native@5.0.0` -- added `@react-navigation/stack@5.0.0` -- added `@react-native-community/masked-view@0.1.5` -- added `react-native-reanimated@1.4.0` -- added `react-native-safe-area-context@0.6.0` -- added `react-native-screeens@2.0.0-alpha.12` -### Updated dependencies -- updated `expo@35.0.0` to `expo@36.0.0` -- updated `expo-asset@7.0.0` to `expo-asset@8.0.0` -- updated `expo-font@7.0.0` to `expo-font@8.0.0` -- updated `expo-cli@2.4.0` to `expo-cli@3.11.7` -- updated `expo-linear-gradient@7.0.0` to `expo-linear-gradient@8.0.0` -- updated `react@16.8.3` to `react@16.9.0` -- updated `babel-preset-expo@7.0.0` to `babel-preset-expo@8.0.0` -- updated `cross-env@5.2.0` to `cross-env@7.0.0` -- updated `jest-expo@35.0.0` to `jest-expo@36.0.0` -### Updated files -- changed the whole routing from `Screens.js` because `react-navigation@5.0.0` has a new dynamic API -- changed `Menu.js` for a new Drawer custom component -- changed `Drawer.js` for a new type of `` -- changed props and variables so that the new `react-navigation` API could work with the following files: `Pro.js`, `Header.js`, `Product.js`, `Onboarding.js` - -## [1.4.0] 2019 - 10 - 27 -### Updated dependencies -- `expo@34.0.3` to `expo@35.0.0` -- `expo-asset@6.0.0` to `expo-asset@7.0.0` -- `expo-font@6.0.1` to `expo-font@7.0.0` -- `expo-linear-gradient@6.0.0` to `expo-linear-gradient@7.0.0` -- `galio-framework@0.6.1` to `galio-framework@0.6.3` -`react-native SDK@34.0.0` to `react-native SDK@35.0.0` -### Updated devDependencies -- `babel-preset-expo@5.0.0` to `babel-preset-expo@7.0.0` -- `jest-expo@31.0.0` to `jest-expo@35.0.0` - -## [1.3.0] 2019 - 09 - 19 -### Updated dependencies -- `expo@33.0.0` to `expo@34.0.3` -- `expo-font@5.0.1` to `expo-font@6.0.1` -- `expo-linear-gradient@5.0.1` to `expo-linear-gradient@6.0.0` -- `galio-framework@0.5.3` to `galio-framework@0.6.1` -- `react-native SDK@33.0.0` to `react-native SDK@34.0.0` -- added `expo-asset@6.0.0` -- added `react-native-gesture-handler@1.3.0` -### Updated files -- updated `Header.js` because of galio's new version -- updated `App.js` because of `expo-asset` - -## [1.2.0] 2019 - 06 - 19 -### Updated dependencies -- `expo@32.0.0` to `expo@33.0.0` -- `galio-framework@0.4.3` to `galio-framework@0.5.3` -- `react-native SDK@32.0.0` to `react-native SDK@33.0.0` -- `react-navigation@2.18.2` to `react-navigation@3.11.0` -- `react@16.5.0` to `react@16.8.3` -### Updated files -- Changed screen icons and local components to be fully supported by the newest `galio-framework` version -- `Screens.js` got a new update which fixed the previous bug related to #2 -- `.gitignore` got updated with new files - -## [1.1.2] 2019-03-05 -### Updated license -- added license inside App.js - -## [1.1.1] 2019-02-18 -### Updated dependencies -- `galio-framework@0.4.3` to `galio-framework@0.4.4` - -## [1.1.0] 2019-02-18 -### Added files -- Added .gitignore file -### Updated dependencies -- `expo@31.0.2` to `expo@32.0.0` -- `galio-framework@0.4.2` to `galio-framework@0.4.3` -- `react-native SDK@31.0.0` to `react-native SDK@32.0.0` - -## [1.0.0] 2019-02-06 -### Initial Release diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4a4099c3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +# Bojo — Claude Code + +Zasady pracy, komendy, pułapki i mapa dokumentacji — wszystko w jednym, wspólnym pliku: + +@AGENTS.md + +## Specyfika Claude Code: hook doc-guard + +`.claude/hooks/doc-guard.sh`, konfiguracja w `.claude/settings.json`. Dwa zdarzenia: + +- **`SessionStart`** — wstrzykuje przypomnienie, gdzie leży dokumentacja +- **`PostToolUse`** (`Edit|Write|NotebookEdit`) — po zmianie kodu bez tknięcia + dokumentacji podpowiada **konkretny plik** do aktualizacji + +**Hook nie blokuje.** Odzywa się raz na sesję dla danej kategorii zmian i milknie po +edycji czegokolwiek w `docs/`. Stan w `${TMPDIR:-/tmp}/bojo-doc-guard/` — +poza repo. Jeśli hook nie reaguje po świeżym sklonowaniu: `.claude/settings.json` +ładuje się przy starcie sesji — otwórz `/hooks` albo zrestartuj sesję. diff --git a/PRZEWODNIK.md b/PRZEWODNIK.md new file mode 100644 index 00000000..29357e90 --- /dev/null +++ b/PRZEWODNIK.md @@ -0,0 +1,131 @@ +# Bojo — przewodnik dla współpracownika + +> **Bojo** to aplikacja webowa, która pomaga zorganizować mecz gdziekolwiek w Polsce +> i zebrać skład — katalog boisk obejmuje całą Polskę. Działa w przeglądarce, +> logowanie przez Google. + +Ten dokument w 5 minut wprowadza Cię w to, co aplikacja potrafi i jak jest zbudowana. + +--- + +## 1. Co widzi użytkownik + +Kolumna **Widoczne?** mówi, czy użytkownik trafi tam z interfejsu. „nie" znaczy, że kod +działa, ale flaga ukryła wejścia w nawigacji — pełna tabela flag w +[docs/funkcje.md](./docs/funkcje.md#flagi-funkcji). + +| Strona | Co robi | Widoczne? | +|---|---|---| +| **Start** (`/`) | Niezalogowani: landing (obietnica + „jak to działa" + otwarte mecze + FAQ). Zalogowani: dashboard — zaproszenia, najbliższy mecz, Twoje mecze, mecze Twoich ekip, Twoje grupy, otwarte mecze, „jak to działa" i FAQ na dole. Na mobile dolna nawigacja z szybkimi akcjami (Gry / Mapa / **+** / Moje / Grupy) — Profil jest w menu hamburgera | tak | +| **Mapa** (`/mapa`) | Interaktywna mapa z dwiema zakładkami: **Boiska** i **Mecze**. Filtry po sporcie, dostępności, nawierzchni. Klik w pinezkę → szczegóły | tak | +| **Boiska wg sportu** (`/boiska/pilka-nozna`) | Lista boisk dla danego sportu (przyjazne adresy pod Google) | tak | +| **Szczegóły boiska** (`/boisko/...`) | Adres, sporty, zdjęcie, opis, dane kontaktowe, nadchodzące mecze. Strona zoptymalizowana pod wyszukiwarki (JSON-LD) | tak | +| **Wydarzenia** (`/wydarzenia`) | Lista meczów — publiczne i „moje". Filtry po sporcie, sortowanie po odległości od Ciebie | tak | +| **Nowy mecz** (`/wydarzenia/nowe`) | Tworzysz mecz: sport, miejsce (z mapy lub adresu), data, godzina, liczba graczy, widoczność (publiczny/prywatny link) + opcje zaawansowane (obecność, płatności, termin potwierdzenia) | tak | +| **Mecz** (`/wydarzenia/...`) | Dołącz / dodaj gościa / kopiuj link / podział na drużyny / wynik / lista rezerwowa / zgłoszenie gracza. Organizator może wymagać **akceptacji** dołączeń | tak | +| **Grupy** (`/grupy`) | Stałe ekipy graczy (sport, miasto). Zakładanie, **link z zaproszeniem** (`/g/kod`), członkowie, mecze grupy, okładka. Edycja przez założyciela | tak | +| **Profil gracza** (`/gracz/...`) | Publiczny profil gracza: awatar, statystyki (rozegrane mecze, frekwencja), znaczek „rzetelny gracz", historia | tak | +| **Moje gry** (`/moje-gry`) | Mecze, które organizujesz lub na które się zapisałeś + historia | tak | +| **Profil** (`/profil`) | Imię, awatar, telefon (za zgodą), usunięcie konta | tak | +| **Cykliczne** (`/cykliczne`) | Szablony powtarzalnych meczów (np. „każdy wtorek 18:00") z zapisami | **nie** — `SHOW_RECURRING` | +| **Turniej** (`/turniej`) | Rejestracja drużyn, składy, drabinka i terminarz meczów | **nie** — `SHOW_CUP` | +| **Rezerwacje** (`/rezerwacje`) | Twoje rezerwacje terminów | **nie** — `FEATURE_RESERVATIONS` | + +⚠️ **`/gracze` nie jest listą graczy** — to przekierowanie na `/wydarzenia`. Listy graczy +w aplikacji nie ma. + +**Logowanie:** Google OAuth oraz e-mail (hasło, magic link, reset) — przez Supabase. +Bez logowania można przeglądać mapę i boiska; do tworzenia i dołączania trzeba się zalogować. + +--- + +## 2. Co może menedżer obiektu + +Właściciel/zarządca boiska (przypisany do obiektu) dostaje panel: + +| Strona | Co robi | +|---|---| +| **Moje obiekty** (`/obiekt`) | Lista zarządzanych boisk + dodawanie nowego | +| **Pulpit obiektu** (`/obiekt/...`) | Skróty do harmonogramu, cennika i rezerwacji | +| **Harmonogram / Cennik** | Godziny otwarcia, długość slotów, ceny wg dnia i pory | +| **Rezerwacje obiektu** | Zatwierdzanie / odrzucanie rezerwacji graczy | + +--- + +## 3. Co może admin + +Admin = pole `is_admin = true` w tabeli `profiles`. Nadajesz je w panelu użytkowników albo SQL-em w Supabase. + +| Strona | Co robi | +|---|---| +| **Użytkownicy** (`/admin/uzytkownicy`) | Lista kont, nadawanie/odbieranie roli admina, szukanie | +| **Analityka** (`/admin/analityka`) | Aktywni użytkownicy, retencja, log akcji (logowania, tworzenie/dołączanie do meczów i grup) | +| **Kontakt z obiektami** (`/admin/outreach`) | **CRM do pozyskiwania boisk** — najważniejszy panel admina (opis niżej) | +| **Rezerwacje obiektu** (`/admin/...`) | Zarządzanie rezerwacjami i konfiguracją systemu rezerwacji dowolnego boiska | + +### Panel „Kontakt z obiektami" (CRM) + +Tu prowadzimy rozmowy z obiektami, żeby podłączyć je do rezerwacji. Dla każdego boiska: + +- **Status w lejku:** nowy → do kontaktu → w toku → czeka na odpowiedź → zainteresowany → umówiony / odrzucony +- **Pełne dane kontaktowe** (telefon, e-mail, strona, operator, godziny, opis) — klik rozwija kartę +- **Przypisanie** — „Przejmij", żeby wziąć obiekt na siebie; widać, kto się nim zajmuje +- **Notatki, osoba kontaktowa, data oddzwonienia, ostatni kontakt** +- **Sekcja „AI znalazł"** — dane, które wyszukała automatyzacja (podsumowanie, link do rezerwacji) +- **Wykrywanie duplikatów** — jeśli ten sam telefon/e-mail jest w 3+ obiektach (zwykle błąd danych), pojawia się ostrzeżenie i przycisk „Wyczyść" +- **Filtry** (sport, kontakt, przypisanie, status, duplikaty) z licznikiem wyników + **eksport CSV** + +--- + +## 4. Skąd się biorą dane o boiskach + +Boisk jest ~1400. Dane uzupełniamy **automatycznie**, uruchamiając skrypty z zakładki **GitHub → Actions** (ręcznie, „Run workflow"). Workflowów jest 11; poniżej cztery główne, w kolejności użycia: + +| # | Workflow (Actions) | Co dorzuca | Koszt | +|---|---|---|---| +| 1 | **Import boisk** (`scraper.py`) | Boiska z OpenStreetMap + Google (z odsiewaniem duplikatów po GPS) | darmowe | +| 2 | **Google Venue Enrichment** | Telefon, strona, godziny — z Google Places (po współrzędnych) | w ramach $200/mc Google | +| 3 | **AI Venue Enrichment** (Claude) | E-mail, operator, opis, sposób rezerwacji — wyszukiwarka Claude | ~grosze/obiekt | +| 4 | **Booking System Extractor** (Claude) | Czyta stronę WWW i wykrywa system rezerwacji (telefon/własny/zewnętrzny) | ~grosze | + +> Każdy job ma tryb **dry_run** (podgląd bez zapisu) — zawsze warto najpierw sprawdzić jakość na małym `limit`. + +Wyniki trafiają do tabel `fields` (dane boiska) i `field_outreach` (status kontaktu + AI). + +--- + +## 5. Stack i uruchomienie + +- **Frontend:** Next.js 14 (App Router), TypeScript, Tailwind CSS → hosting na **Vercel** +- **Dane/Auth:** **Supabase** (PostgreSQL, logowanie Google, RLS). Migracje w `supabase/migrations/` +- **Mapa:** Leaflet + OpenStreetMap (bez tokenu). Mapbox tylko do miniaturek zdjęć (opcjonalny) +- **Automatyzacja:** Python (`scraper/`) + API Google i Claude, odpalane w GitHub Actions + +```bash +# lokalnie +cp .env.example .env # uzupełnij klucze Supabase +cd frontend +npm install +npm run dev # http://localhost:3000 +npm test # testy jednostkowe (Vitest) +``` + +Struktura: `frontend/` (apka), `scraper/` (skrypty danych), `supabase/` (schema + migracje). + +--- + +## 6. Pomysły na rozwój + +Pełna lista z priorytetami: [BACKLOG.md](./BACKLOG.md). W skrócie: + +- **Web-push (PWA)** — darmowy kanał przypomnień. Powiadomienia in-app, e-mail i SMS + **już istnieją** (dzwonek w nagłówku, Resend, SMSAPI); brakuje pushu oraz wyzwalaczy + dla zdarzeń takich jak dołączenie do meczu czy nowa gra w grupie +- **Agent kontaktowy** — automat, który wysyła maile do obiektów, zbiera odpowiedzi i podpowiada następny ruch w CRM (szkic gotowy, do uzgodnienia) +- **Wyszukiwarka** boisk po nazwie/dzielnicy na mapie +- **Płatności** — realne zbieranie składek (BLIK/Stripe). Dziś aplikacja rejestruje, kto zapłacił, ale nie przelewa pieniędzy +- **Twardsze zabezpieczenia** (część reguł dostępu sprawdzana dziś po stronie przeglądarki — do domknięcia w RLS) + +--- + +*Pytania? Najszybciej ogarnąć kod zaczynając od `frontend/src/app` (strony) i `frontend/src/lib` (logika + zapytania do Supabase). Szczegółowa dokumentacja: [docs/README.md](./docs/README.md).* diff --git a/README.md b/README.md index 653d1d41..83ddcf15 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,112 @@ -# [Material Kit React Native](https://creativetimofficial.github.io/material-kit-react-native/docs/#) [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social&logo=twitter)](https://twitter.com/home?status=Material%20Kit%20React%20Native,%20a%20cool%20Material%20Kit%20React%20Native%20App%20Template%20%E2%9D%A4%EF%B8%8F%20https%3A//bit.ly/2HObENt%20%23reactnative%20%23material%20%23design%20%23developers%20via%20%40CreativeTim) +# ⚽ Bojo — Boiska Poznań +> Znajdź boisko w Poznaniu, zorganizuj mecz i zbierz skład. Aplikacja webowa, logowanie przez Google. - ![version](https://img.shields.io/badge/version-1.9.0-blue.svg) [![GitHub issues open](https://img.shields.io/github/issues/creativetimofficial/material-kit-react-native.svg?style=flat)](https://github.com/creativetimofficial/material-kit-react-native/issues?q=is%3Aopen+is%3Aissue) [![GitHub issues closed](https://img.shields.io/github/issues-closed-raw/creativetimofficial/material-kit-react-native.svg?maxAge=2592000)](https://github.com/creativetimofficial/material-kit-react-native/issues?q=is%3Aissue+is%3Aclosed) +📖 **Nowy w projekcie? Zacznij od [PRZEWODNIK.md](./PRZEWODNIK.md)** — opis wszystkich funkcji (użytkownik + admin) w 5 minut. +| Chcesz… | Idź do | +|---|---| +| poznać funkcje aplikacji | [PRZEWODNIK.md](./PRZEWODNIK.md) | +| zacząć pisać kod | [AGENTS.md](./AGENTS.md) — komendy, konwencje, pułapki | +| zrozumieć projekt w szczegółach | [docs/README.md](./docs/README.md) — wizja, funkcje, domena, architektura, baza | +| zobaczyć, co jest do zrobienia | [BACKLOG.md](./BACKLOG.md) | -![Product Gif](https://raw.githubusercontent.com/creativetimofficial/public-assets/master/material-kit-react-native/opt_mkrn_thumbnail.jpg) +--- -Material Kit React Native is a fully coded app template built over [Galio.io](https://galio.io/?ref=creativetim), [React Native](https://facebook.github.io/react-native/?ref=creativetim) and [Expo](https://expo.io/?ref=creativetim) to allow you to create powerful and beautiful e-commerce mobile applications. We have redesigned all the usual components in Galio to make it look like Google's material design, minimalistic and easy to use. +## Architektura -Start your development with a badass material UI Kit for React Native inspired by Material Design. If you like Google's Material Design, you will love this react native kit! It features a huge number of components and screens built to fit together and look amazing. - -### FULLY CODED COMPONENTS - -Material Kit React Native features over 200 variations of components like buttons, inputs, cards, navigations etc, giving you the freedom of choosing and combining. All components can take variations in colour, that you can easily modify inside our theme file. - -You will save a lot of time going from prototyping to full-functional code, because all elements are implemented. We wanted the design process to be seamless, so switching from image to the real page is very easy to do. - -### Components & Cards -Material Kit React Native comes packed with a large number of components and cards. Putting together a mobile app has never been easier than matching together different components. From the profile screen to a settings screen, you can easily customise and build your screens. We have created multiple options for you to put together and customise into pixel perfect screens. - -View [ all components/cards here](https://demos.creative-tim.com/material-kit-react-native/index.html#cards). - -### Example Screens -If you want to get inspiration or just show something directly to your clients, you can jump start your development with our pre-built example screens. From onboarding screens to profile or discover screens, you will be able to quickly set up the basic structure for your React Native mobile project. - -View [all screens here](https://demos.creative-tim.com/material-kit-react-native/index.html#screens). - - -Let us know your thoughts below. And good luck with development! - - -## Table of Contents - -* [Versions](#versions) -* [Demo](#demo) -* [Quick Start](#quick-start) -* [Documentation](#documentation) -* [File Structure](#file-structure) -* [OS Support](#os-support) -* [Resources](#resources) -* [Reporting Issues](#reporting-issues) -* [Technical Support or Questions](#technical-support-or-questions) -* [Licensing](#licensing) -* [Useful Links](#useful-links) - -## Versions - -[](https://www.creative-tim.com/product/material-kit)[](https://www.creative-tim.com/product/vue-material-kit)[](https://www.creative-tim.com/product/material-kit-react)[](https://www.creative-tim.com/product/material-kit-react-native)[](https://demos.creative-tim.com/material-kit-figma/presentation.html)[](https://themeisle.com/themes/hestia/?ref=creativetim)[](https://github.com/creativetimofficial/material-kit/tree/photoshop)[](https://github.com/creativetimofficial/material-kit/tree/sketch) - - - - - -| HTML | React | Vue | -| --- | --- | --- | -| [![Material Kit HTML](https://github.com/creativetimofficial/public-assets/blob/master/material-kit/material-kit.jpeg?raw=true)](https://www.creative-tim.com/product/material-kit) | [![Material Kit React](https://github.com/creativetimofficial/public-assets/blob/master/material-kit-react/material-kit-react.jpeg?raw=true)](https://www.creative-tim.com/product/material-kit-react) | [![Vue Material Kit](https://github.com/creativetimofficial/public-assets/blob/master/vue-material-kit/vue-material-kit.jpeg?raw=true)](https://www.creative-tim.com/product/vue-material-kit) - -| React Native | Figma | WordPress | -| --- | --- | --- | -| [![Material Kit React Native](https://github.com/creativetimofficial/public-assets/blob/master/material-kit-react-native/opt_mkrn_thumbnail.jpg?raw=true)](https://www.creative-tim.com/product/material-kit-react-native) | [![Material Kit Figma](https://github.com/creativetimofficial/public-assets/blob/master/material-kit-figma/material-kit-figma.jpg?raw=true)](https://demos.creative-tim.com/material-kit-figma/presentation.html) | [![Material Kit WordPress](https://github.com/creativetimofficial/public-assets/blob/master/material-kit-wordpress/opt_smd_thumbnail.jpg?raw=true)](https://themeisle.com/themes/hestia/?ref=creativetim) - -## Demo - -| Home Screen | Profile Screen | Chat Screen | Product Screen | -| --- | --- | --- | --- | -| [![Home Screen](https://raw.githubusercontent.com/creativetimofficial/public-assets/master/material-kit-react-native/home-screen.png)](https://demos.creative-tim.com/material-kit-react-native/) | [![Profile Screen](https://raw.githubusercontent.com/creativetimofficial/public-assets/master/material-kit-react-native/profile-screen.png)](https://demos.creative-tim.com/material-kit-react-native/) | [![Chat Screen](https://raw.githubusercontent.com/creativetimofficial/public-assets/master/material-kit-react-native/chat-screen.png)](https://demos.creative-tim.com/material-kit-react-native/) | [![Product Screen](https://raw.githubusercontent.com/creativetimofficial/public-assets/master/material-kit-react-native/product-screen.png)](https://demos.creative-tim.com/material-kit-react-native/) | - -- [Start page](https://demos.creative-tim.com/material-kit-react-native) -- [How to install our free demo](https://demos.creative-tim.com/material-kit-react-native/docs/#/install) - -[View more](https://demos.creative-tim.com/material-kit-react-native) - -## Quick start -- Try it out on Expo (Simulator for iOS or even your physical device if you have an Android) -- Buy from [Creative Tim](https://www.creative-tim.com/product/material-kit-pro-react-native) - - -## Documentation -The documentation for the Material Kit React Native is hosted at our [website](https://demos.creative-tim.com/material-kit-react-native/docs/). - - -## File Structure -Within the download you'll find the following directories and files: +Bez osobnego backendu — frontend rozmawia z Supabase bezpośrednio (chronione przez RLS). +Dane o boiskach uzupełniają skrypty Pythona uruchamiane ręcznie z GitHub Actions. ``` -material-kit-react-native/ -├── App.js -├── README.md -├── app.json -├── assets -├── babel.config.js -├── components -│   ├── Button.js -│   ├── Drawer.js -│   ├── Header.js -│   ├── Icon.js -│   ├── Product.js -│   ├── Select.js -│   ├── Switch.js -│   ├── Tabs.js -│   └── index.js -├── constants -│   ├── Images.js -│   ├── Theme.js -│   ├── index.js -│   ├── products.js -│   └── utils.js -├── navigation -│   ├── Menu.js -│   └── Screens.js -├── package-lock.json -├── package.json -├── screens -│   ├── Components.js -│   ├── Home.js -│   ├── Onboarding.js -│   ├── Pro.js -│   ├── Profile.js -│   └── Settings.js - +┌──────────────────┐ ┌──────────────────────┐ +│ Frontend │ REST │ Supabase │ +│ Next.js 14 │ ─────▶ │ PostgreSQL + Auth │ +│ (Vercel) │ │ (Google OAuth, RLS) │ +│ Leaflet + OSM │ └──────────▲───────────┘ +└──────────────────┘ │ service_role + │ + ┌─────────────┴────────────┐ + │ Scraper (GitHub Actions) │ + │ OSM + Google Places + │ + │ Claude (wzbogacanie) │ + └───────────────────────────┘ ``` +| Warstwa | Technologia | +|---|---| +| Frontend | Next.js 14 (App Router), TypeScript, Tailwind CSS | +| Dane / Auth | Supabase (PostgreSQL, Google OAuth, Row Level Security) | +| Mapa | Leaflet + OpenStreetMap (bez tokenu); Mapbox tylko do miniaturek | +| Hosting | Vercel | +| Dane boisk | Python (`scraper/`) + Google Places API + Claude, w GitHub Actions | -## OS Support - -At present, we officially aim to support the last two versions of the following operating systems: - -[](https://www.creative-tim.com/product/material-kit-pro-react-native)[](https://www.creative-tim.com/product/material-kit-pro-react-native) - - - -## Resources -- Demo: -- Download Page: -- Documentation: -- License Agreement: -- Support: -- Issues: [Github Issues Page](https://github.com/creativetimofficial/ct-material-kit-react-native/issues) -- [Material Kit](https://www.creative-tim.com/product/material-kit?ref=mkprn-readme) - For Front End Development -- [Buy our PRO version](https://www.creative-tim.com/product/material-kit-pro-react-native) -- **Dashboards:** +--- -| HTML | React | Vue | Angular | -| --- | --- | --- | --- | -| [![Material Dashboard HTML](https://github.com/creativetimofficial/public-assets/blob/master/material-dashboard-html/material-dashboard.jpeg?raw=true)](https://www.creative-tim.com/product/material-dashboard) | [![Material Dashboard React](https://github.com/creativetimofficial/public-assets/blob/master/material-dashboard-react/material-dashboard-react.jpeg?raw=true)](https://www.creative-tim.com/product/material-dashboard-react) | [![Vue Material Dashboard](https://github.com/creativetimofficial/public-assets/blob/master/vue-material-dashboard/vue-material-dashboard.jpeg?raw=true)](https://www.creative-tim.com/product/vue-material-dashboard) | [![ Material Dashboard Angular](https://github.com/creativetimofficial/public-assets/blob/master/material-dashboard-angular/material-dashboard-angular.jpg?raw=true)](https://www.creative-tim.com/product/material-dashboard-angular2) +## Uruchomienie lokalne -| HTML Dark | Vuetify | -| --- | --- | -| [![Material Dashboard Dark](https://github.com/creativetimofficial/public-assets/blob/master/material-dashboard-dark/material-dashboard-dark.jpg?raw=true)](https://www.creative-tim.com/product/material-dashboard-dark) | [![Material Dashboard Vuetify](https://github.com/creativetimofficial/public-assets/blob/master/material-dashboard-vuetify/material-dashboard-vuetify.jpg?raw=true)](https://www.creative-tim.com/product/vuetify-material-dashboard) +```bash +git clone && cd bojo-app +cp .env.example .env # uzupełnij klucze Supabase (patrz .env.example) +cd frontend +npm install +npm run dev # http://localhost:3000 +npm test # testy jednostkowe (Vitest) +npm run build # build produkcyjny +``` -## Reporting Issues - -We use GitHub Issues as the official bug tracker for the Material Kit React Native. Here are some advices for our users that want to report an issue: - -1. Make sure that you are using the latest version of the Material Kit React Native. -2. Providing us reproducible steps for the issue will shorten the time it takes for it to be fixed. -3. Some issues may be browser specific, so specifying in what browser you encountered the issue might help. - - -### Technical Support or Questions - -If you have questions or need help integrating the product please [contact us](https://www.creative-tim.com/contact-us) instead of opening an issue. - +**Wymagania:** Node.js 18+, konto Supabase. Python 3.11+ tylko jeśli pracujesz przy scraperze. -## Licensing +--- -- Copyright 2019 Creative Tim (https://www.creative-tim.com/) +## Baza danych -- Licensed under MIT (https://github.com/creativetimofficial/material-kit-react-native/blob/master/LICENSE) +Schema i migracje: `supabase/migrations/`. Wgrywasz je w Supabase → SQL Editor +(kolejno wg numeracji). Dane startowe: `supabase/seed.sql`. +Najważniejsze tabele: `fields` (boiska), `events` (mecze), `event_participants`, +`recurring_events` (cykliczne), `bookings` (rezerwacje), `field_outreach` (CRM kontaktu), +`profiles` (użytkownicy + flaga `is_admin`). +--- -## Useful Links +## Dane o boiskach (scraper) -- [Tutorials](https://www.youtube.com/channel/UCVyTG4sCw-rOvB9oHkzZD1w) -- [Affiliate Program](https://www.creative-tim.com/affiliates/new) (earn money) -- [Blog Creative Tim](http://blog.creative-tim.com/) -- [Free Products](https://www.creative-tim.com/bootstrap-themes/free) from Creative Tim -- [Premium Products](https://www.creative-tim.com/bootstrap-themes/premium) from Creative Tim -- [React Products](https://www.creative-tim.com/bootstrap-themes/react-themes) from Creative Tim -- [Angular Products](https://www.creative-tim.com/bootstrap-themes/angular-themes) from Creative Tim -- [VueJS Products](https://www.creative-tim.com/bootstrap-themes/vuejs-themes) from Creative Tim -- [More products](https://www.creative-tim.com/bootstrap-themes) from Creative Tim -- Check our Bundles [here](https://www.creative-tim.com/bundles?ref="mk-github-readme") -- [Buy our PRO version](https://www.creative-tim.com/product/material-kit-pro-react-native) +Uruchamiane ręcznie z **GitHub → Actions**. Kolejność i opis: patrz +[PRZEWODNIK.md, sekcja 4](./PRZEWODNIK.md#4-skąd-się-biorą-dane-o-boiskach). +Każdy workflow ma tryb `dry_run` (podgląd bez zapisu). +``` +scraper.py → import boisk (OSM + Google) +enrich_google.py → telefon / strona / godziny (Google Places, darmowe) +enrich.py → e-mail / operator / opis / rezerwacja (Claude web search) +enrich_booking.py → wykrycie systemu rezerwacji ze strony WWW (Claude) +``` -### Social Media +--- -Twitter: +## Struktura projektu -Facebook: +``` +bojo-app/ +├── frontend/ # Aplikacja Next.js (całość UI + logika) +│ └── src/ +│ ├── app/ # App Router — strony i trasy +│ ├── components/ # Komponenty React (mapa, layout, ui) +│ ├── lib/ # Klient Supabase, zapytania, walidacja +│ ├── config/ # Flagi funkcji +│ └── types/ # Typy TypeScript +├── scraper/ # Skrypty Pythona do danych o boiskach +├── supabase/migrations/ # Migracje SQL +├── .github/workflows/ # Importy/wzbogacanie danych (Actions) +├── docs/ # Dokumentacja projektu (wizja, funkcje, domena, baza) +├── AGENTS.md # Zasady pracy w repo +└── PRZEWODNIK.md # Opis funkcji dla współpracowników +``` -Dribbble: +--- -Instagram: +## Licencja +MIT © 2026 Bojo diff --git a/app.json b/app.json deleted file mode 100644 index 2253a8e3..00000000 --- a/app.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "expo": { - "name": "rn-material-kit-free", - "description": "React Native - Material Kit Free", - "slug": "rn-material-kit-free", - "privacy": "unlisted", - "platforms": [ - "ios", - "android" - ], - "version": "1.8.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "splash": { - "image": "./assets/images/splash.png", - "resizeMode": "cover", - "backgroundColor": "#6C24AA" - }, - "updates": { - "fallbackToCacheTimeout": 0 - }, - "assetBundlePatterns": [ - "**/*" - ], - "ios": { - "supportsTablet": true - } - } -} diff --git a/assets/fonts/galioExtra.json b/assets/fonts/galioExtra.json deleted file mode 100644 index 22243fa5..00000000 --- a/assets/fonts/galioExtra.json +++ /dev/null @@ -1 +0,0 @@ -{"IcoMoonType":"selection","icons":[{"icon":{"paths":["M1014.627 969.373c12.497 12.497 12.497 32.758 0 45.255s-32.758 12.497-45.255 0l-192-192c-12.497-12.497-12.497-32.758 0-45.255s32.758-12.497 45.255 0l192 192z","M416 832c-229.75 0-416-186.25-416-416s186.25-416 416-416c229.75 0 416 186.25 416 416s-186.25 416-416 416zM416 768c194.404 0 352-157.596 352-352s-157.596-352-352-352c-194.404 0-352 157.596-352 352s157.596 352 352 352z"],"attrs":[{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["zoom-split"]},"attrs":[{},{}],"properties":{"order":5,"id":8,"name":"zoom-split","prevSize":32,"code":59648},"setIdx":0,"setId":1,"iconIdx":0},{"icon":{"paths":["M192 384h-160c-17.673 0-32-14.327-32-32v-256c0-17.673 14.327-32 32-32h323.2c15.218 0 28.332 10.718 31.36 25.632 12.114 59.656 64.566 102.528 125.44 102.528s113.326-42.872 125.44-102.528c3.028-14.914 16.142-25.632 31.36-25.632h323.2c17.673 0 32 14.327 32 32v256c0 17.673-14.327 32-32 32h-160v352c0 17.673-14.327 32-32 32-88.366 0-160 71.634-160 160 0 17.673-14.327 32-32 32h-192c-17.673 0-32-14.327-32-32 0-88.366-71.634-160-160-160-17.673 0-32-14.327-32-32v-352zM512 256.16c-82.331 0-154.393-52.282-181.089-128.16h-266.911v192h160c17.673 0 32 14.327 32 32v354.268c98.102 14.032 175.699 91.629 189.732 189.732h132.537c14.032-98.102 91.629-175.699 189.732-189.732v-354.268c0-17.673 14.327-32 32-32h160v-192h-266.911c-26.697 75.879-98.758 128.16-181.089 128.16z"],"attrs":[{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["baby"]},"attrs":[{}],"properties":{"order":6,"id":7,"name":"baby","prevSize":32,"code":59649},"setIdx":0,"setId":1,"iconIdx":1},{"icon":{"paths":["M192 512c-106.057 0-192-85.943-192-192 0-5.438 1.386-10.787 4.027-15.541l160-288c5.644-10.159 16.352-16.459 27.973-16.459h640c11.621 0 22.329 6.301 27.973 16.459l160 288c2.641 4.754 4.027 10.102 4.027 15.541 0 106.057-85.943 192-192 192-66.792 0-125.606-34.086-160-85.815-34.394 51.729-93.208 85.815-160 85.815s-125.606-34.086-160-85.815c-34.394 51.729-93.208 85.815-160 85.815zM210.829 64l-146.591 263.865c4.061 67.046 59.693 120.135 127.763 120.135 70.711 0 128-57.289 128-128 0-42.667 64-42.667 64 0 0 70.711 57.289 128 128 128s128-57.289 128-128c0-42.667 64-42.667 64 0 0 70.711 57.289 128 128 128 68.070 0 123.702-53.090 127.763-120.135l-146.591-263.865h-602.342z","M832 960v-352c0-17.673 14.327-32 32-32s32 14.327 32 32v384c0 17.673-14.327 32-32 32h-704c-17.673 0-32-14.327-32-32v-384c0-17.673 14.327-32 32-32s32 14.327 32 32v352h640z","M448 768v224c0 17.673-14.327 32-32 32s-32-14.327-32-32v-256c0-17.673 14.327-32 32-32h192c17.673 0 32 14.327 32 32v256c0 17.673-14.327 32-32 32s-32-14.327-32-32v-224h-128z"],"attrs":[{},{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["shop"]},"attrs":[{},{},{}],"properties":{"order":7,"id":6,"name":"shop","prevSize":32,"code":59650},"setIdx":0,"setId":1,"iconIdx":2},{"icon":{"paths":["M511.766 123.060l-119.701 242.501c-4.663 9.447-13.676 15.995-24.102 17.51l-267.577 38.882 193.636 188.73c7.545 7.354 10.988 17.949 9.208 28.334l-45.701 266.561 239.34-125.84c9.326-4.904 20.469-4.904 29.795 0l239.34 125.84-45.701-266.561c-1.78-10.384 1.663-20.98 9.208-28.334l193.636-188.73-267.577-38.882c-10.426-1.515-19.439-8.063-24.102-17.51l-119.701-242.501zM342.103 322.133l140.958-285.564c11.743-23.79 45.667-23.79 57.41 0l140.958 285.564 315.116 45.79c26.257 3.815 36.741 36.084 17.74 54.603l-228.036 222.259 53.817 313.902c4.483 26.15-22.965 46.091-46.449 33.743l-281.851-148.192-281.851 148.192c-23.484 12.347-50.932-7.593-46.449-33.743l53.817-313.902-228.036-222.259c-19.001-18.519-8.517-50.788 17.74-54.603l315.116-45.79z"],"attrs":[{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["shape-star"]},"attrs":[{}],"properties":{"order":8,"id":5,"name":"shape-star","prevSize":32,"code":59651},"setIdx":0,"setId":1,"iconIdx":3},{"icon":{"paths":["M0 352c0-195.813 187.407-352 416-352s416 156.187 416 352c0 195.813-187.407 352-416 352-51.765 0-102.282-8.117-149.766-23.69l-151.032 113.289c-21.095 15.824-51.202 0.772-51.202-25.599v-228.337c-41.47-55.633-64-120.203-64-187.663zM128 703.995l113.47-85.114c8.702-6.527 20.123-8.187 30.322-4.407 45.157 16.736 93.942 25.526 144.207 25.526 195.564 0 352-130.376 352-288s-156.436-288-352-288c-195.564 0-352 130.376-352 288 0 56.258 19.917 110.249 57.028 156.828 4.514 5.666 6.972 12.696 6.972 19.94v175.227z","M608 928c-111.883 0-217.038-37.531-294.674-103.333-13.482-11.427-15.148-31.62-3.721-45.102s31.62-15.148 45.102-3.721c65.92 55.872 156.373 88.155 253.294 88.155 45.338 0 89.716-7.269 131.707-21.164 9.35-3.094 19.604-1.71 27.799 3.751l128.494 85.632v-179.451c0-7.244 2.458-14.274 6.972-19.94 37.128-46.601 57.028-100.53 57.028-156.828 0-50.198-15.748-98.555-45.449-141.469-10.058-14.532-6.43-34.466 8.102-44.524s34.466-6.43 44.524 8.102c36.959 53.402 56.823 114.397 56.823 177.891 0 67.501-22.516 132.018-64 187.664v228.336c0 25.556-28.48 40.801-49.746 26.629l-165.53-110.314c-43.915 12.946-89.892 19.685-136.724 19.685z"],"attrs":[{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["chat-33"]},"attrs":[{},{}],"properties":{"order":9,"id":4,"name":"chat-33","prevSize":32,"code":59652},"setIdx":0,"setId":1,"iconIdx":4},{"icon":{"paths":["M576 832c-141.385 0-256-114.615-256-256s114.615-256 256-256c141.385 0 256 114.615 256 256s-114.615 256-256 256zM576 768c106.039 0 192-85.961 192-192s-85.961-192-192-192c-106.039 0-192 85.961-192 192s85.961 192 192 192z","M160 384c-17.673 0-32-14.327-32-32s14.327-32 32-32h64c17.673 0 32 14.327 32 32s-14.327 32-32 32h-64z","M819.777 192h108.223c53.001 0 96 42.999 96 96v576c0 53.001-42.999 96-96 96h-832c-53.001 0-96-42.999-96-96v-576c0-53.001 42.999-96 96-96h236.223l55.155-110.311c5.421-10.841 16.501-17.689 28.622-17.689h320c12.121 0 23.201 6.848 28.622 17.689l55.155 110.311zM435.777 128l-55.155 110.311c-5.421 10.841-16.501 17.689-28.622 17.689h-256c-17.655 0-32 14.345-32 32v576c0 17.655 14.345 32 32 32h832c17.655 0 32-14.345 32-32v-576c0-17.655-14.345-32-32-32h-128c-12.121 0-23.201-6.848-28.622-17.689l-55.155-110.311h-280.446z"],"attrs":[{},{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["camera-18"]},"attrs":[{},{},{}],"properties":{"order":10,"id":3,"name":"camera-18","prevSize":32,"code":59653},"setIdx":0,"setId":1,"iconIdx":5},{"icon":{"paths":["M763.026 942.813l167.611-502.834h-855.608l167.611 502.834h520.385zM785.678 1005.668h-565.688c-13.527 0-25.537-8.656-29.814-21.489l-188.563-565.688c-6.783-20.35 8.364-41.365 29.814-41.365h942.813c21.451 0 36.598 21.015 29.814 41.365l-188.563 565.688c-4.278 12.833-16.287 21.489-29.814 21.489z","M248.099 296.899c-7.762 15.524-26.64 21.817-42.164 14.055s-21.817-26.64-14.055-42.164l125.708-251.417c7.762-15.524 26.64-21.817 42.164-14.055s21.817 26.64 14.055 42.164l-125.708 251.417z","M813.787 268.789c7.762 15.524 1.47 34.402-14.055 42.164s-34.402 1.47-42.164-14.055l-125.708-251.417c-7.762-15.524-1.47-34.402 14.055-42.164s34.402-1.47 42.164 14.055l125.708 251.417z"],"attrs":[{},{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["basket-simple"]},"attrs":[{},{},{}],"properties":{"order":11,"id":2,"name":"basket-simple","prevSize":32,"code":59654},"setIdx":0,"setId":1,"iconIdx":6},{"icon":{"paths":["M256 288c0 17.673-14.327 32-32 32s-32-14.327-32-32v-256c0-17.673 14.327-32 32-32s32 14.327 32 32v256z","M256 992c0 17.673-14.327 32-32 32s-32-14.327-32-32v-128c0-17.673 14.327-32 32-32s32 14.327 32 32v128z","M768 736c0-17.673 14.327-32 32-32s32 14.327 32 32v256c0 17.673-14.327 32-32 32s-32-14.327-32-32v-256z","M768 32c0-17.673 14.327-32 32-32s32 14.327 32 32v128c0 17.673-14.327 32-32 32s-32-14.327-32-32v-128z","M224 896c-123.712 0-224-100.288-224-224s100.288-224 224-224c123.712 0 224 100.288 224 224s-100.288 224-224 224zM224 832c88.366 0 160-71.634 160-160s-71.634-160-160-160c-88.366 0-160 71.634-160 160s71.634 160 160 160z","M800 576c-123.712 0-224-100.288-224-224s100.288-224 224-224c123.712 0 224 100.288 224 224s-100.288 224-224 224zM800 512c88.366 0 160-71.634 160-160s-71.634-160-160-160c-88.366 0-160 71.634-160 160s71.634 160 160 160z"],"attrs":[{},{},{},{},{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["preferences-circle-rotate"]},"attrs":[{},{},{},{},{},{}],"properties":{"order":12,"id":1,"name":"preferences-circle-rotate","prevSize":32,"code":59655},"setIdx":0,"setId":1,"iconIdx":7},{"icon":{"paths":["M512 256c-70.692 0-128 57.308-128 128v64c0 70.692 57.308 128 128 128s128-57.308 128-128v-64c0-70.692-57.308-128-128-128zM512 192c106.039 0 192 85.961 192 192v64c0 106.039-85.961 192-192 192s-192-85.961-192-192v-64c0-106.039 85.961-192 192-192z","M891.098 837.721c2.637 17.475-9.391 33.78-26.866 36.417s-33.78-9.391-36.417-26.866c-10.616-70.343-44.438-135.12-96.090-184.037-12.832-12.152-13.383-32.406-1.231-45.238s32.406-13.383 45.238-1.231c62.014 58.73 102.62 136.501 115.366 220.955z","M248.416 616.747c12.842-12.142 33.095-11.574 45.237 1.268s11.574 33.095-1.268 45.237c-51.728 48.907-85.614 113.709-96.265 184.095-2.644 17.474-18.953 29.496-36.428 26.852s-29.496-18.953-26.852-36.428c12.788-84.506 53.47-162.307 115.575-221.025z","M512 1024c-282.77 0-512-229.23-512-512s229.23-512 512-512c282.77 0 512 229.23 512 512s-229.23 512-512 512zM512 960c247.424 0 448-200.576 448-448s-200.576-448-448-448c-247.424 0-448 200.576-448 448s200.576 448 448 448z"],"attrs":[{},{},{},{}],"isMulticolor":false,"isMulticolor2":false,"grid":0,"tags":["circle-10"]},"attrs":[{},{},{},{}],"properties":{"order":13,"id":0,"name":"circle-10","prevSize":32,"code":59656},"setIdx":0,"setId":1,"iconIdx":8}],"height":1024,"metadata":{"name":"galioExtra"},"preferences":{"showGlyphs":true,"showQuickUse":true,"showQuickUse2":true,"showSVGs":true,"fontPref":{"prefix":"icon-","metadata":{"fontFamily":"galioExtra","majorVersion":1,"minorVersion":0},"metrics":{"emSize":1024,"baseline":6.25,"whitespace":50},"embed":false,"showSelector":false,"showMetrics":false,"showMetadata":false,"includeMetadata":false},"imagePref":{"prefix":"icon-","png":true,"useClassSelector":true,"color":0,"bgColor":16777215,"classSelector":".icon","name":"icomoon"},"historySize":50,"showCodes":true,"gridSize":16,"showGrid":false,"showLiga":false}} \ No newline at end of file diff --git a/assets/fonts/galioExtra.ttf b/assets/fonts/galioExtra.ttf deleted file mode 100644 index f1119f54..00000000 Binary files a/assets/fonts/galioExtra.ttf and /dev/null differ diff --git a/assets/icons/baby.svg b/assets/icons/baby.svg deleted file mode 100644 index 1b40de61..00000000 --- a/assets/icons/baby.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - baby - Created with Sketch. - - - - - - \ No newline at end of file diff --git a/assets/icons/basket-simple.svg b/assets/icons/basket-simple.svg deleted file mode 100644 index a0116c4c..00000000 --- a/assets/icons/basket-simple.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - basket-simple - Created with Sketch. - - - - - - - - \ No newline at end of file diff --git a/assets/icons/camera-18.svg b/assets/icons/camera-18.svg deleted file mode 100644 index 05895800..00000000 --- a/assets/icons/camera-18.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - camera-18 - Created with Sketch. - - - - - - - - \ No newline at end of file diff --git a/assets/icons/chat-33.svg b/assets/icons/chat-33.svg deleted file mode 100644 index 7189c356..00000000 --- a/assets/icons/chat-33.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - chat-33 - Created with Sketch. - - - - - - - \ No newline at end of file diff --git a/assets/icons/circle-10.svg b/assets/icons/circle-10.svg deleted file mode 100644 index fdd79e16..00000000 --- a/assets/icons/circle-10.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - circle-10 - Created with Sketch. - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/preferences-circle-rotate.svg b/assets/icons/preferences-circle-rotate.svg deleted file mode 100644 index 621055d3..00000000 --- a/assets/icons/preferences-circle-rotate.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - preferences-circle-rotate - Created with Sketch. - - - - - - - - - - - \ No newline at end of file diff --git a/assets/icons/shape-star.svg b/assets/icons/shape-star.svg deleted file mode 100644 index a066a84a..00000000 --- a/assets/icons/shape-star.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - shape-star - Created with Sketch. - - - - - - \ No newline at end of file diff --git a/assets/icons/shop.svg b/assets/icons/shop.svg deleted file mode 100644 index a8214dfa..00000000 --- a/assets/icons/shop.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - shop - Created with Sketch. - - - - - - - - \ No newline at end of file diff --git a/assets/icons/zoom-split.svg b/assets/icons/zoom-split.svg deleted file mode 100644 index b0486606..00000000 --- a/assets/icons/zoom-split.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - zoom-split - Created with Sketch. - - - - - - - \ No newline at end of file diff --git a/assets/images/android.png b/assets/images/android.png deleted file mode 100644 index fec1051c..00000000 Binary files a/assets/images/android.png and /dev/null differ diff --git a/assets/images/icon.png b/assets/images/icon.png deleted file mode 100644 index b587b905..00000000 Binary files a/assets/images/icon.png and /dev/null differ diff --git a/assets/images/ios.png b/assets/images/ios.png deleted file mode 100644 index e2863ef1..00000000 Binary files a/assets/images/ios.png and /dev/null differ diff --git a/assets/images/splash.png b/assets/images/splash.png deleted file mode 100644 index f8560ad7..00000000 Binary files a/assets/images/splash.png and /dev/null differ diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 2900afe9..00000000 --- a/babel.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = function(api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - }; -}; diff --git a/components/Button.js b/components/Button.js deleted file mode 100644 index 2b9a5a77..00000000 --- a/components/Button.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Button, Text, theme } from 'galio-framework'; - -import materialTheme from '../constants/Theme'; - -export default class GaButton extends React.Component { - render() { - const { gradient, children, style, ...props } = this.props; - - if (gradient) { - return ( - - - - ); - } - - return ( - - ); - } -} - -const styles = StyleSheet.create({ - gradient: { - borderWidth: 0, - borderRadius: theme.SIZES.BASE * 2, - }, -}); \ No newline at end of file diff --git a/components/Drawer.js b/components/Drawer.js deleted file mode 100644 index 26b4306d..00000000 --- a/components/Drawer.js +++ /dev/null @@ -1,198 +0,0 @@ -import React from "react"; -import { TouchableOpacity, StyleSheet } from "react-native"; -import { Block, Text, theme } from "galio-framework"; - -import Icon from "./Icon"; -import materialTheme from "../constants/Theme"; - -const proScreens = [ - "Woman", - "Man", - "Kids", - "New Collection", - "Sign In", - "Sign Up" -]; - -class DrawerItem extends React.Component { - renderIcon = () => { - const { title, focused } = this.props; - - switch (title) { - case "Home": - return ( - - ); - case "Woman": - return ( - - ); - case "Man": - return ( - - ); - case "Kids": - return ( - - ); - case "New Collection": - return ( - - ); - case "Profile": - return ( - - ); - case "Settings": - return ( - - ); - case "Components": - return ( - - ); - case "Sign In": - return ( - - ); - case "Sign Up": - return ( - - ); - default: - return null; - } - }; - - renderLabel = () => { - const { title } = this.props; - - if (proScreens.includes(title)) { - return ( - - - PRO - - - ); - } - - return null; - }; - - render() { - const { focused, title, navigation } = this.props; - const proScreen = proScreens.includes(title); - return ( - {navigation.navigate(title)}}> - - - {this.renderIcon()} - - - - {title} - - {this.renderLabel()} - - - - ); - } -} - -export default DrawerItem; - -const styles = StyleSheet.create({ - defaultStyle: { - paddingVertical: 16, - paddingHorizontal: 16 - }, - activeStyle: { - backgroundColor: materialTheme.COLORS.ACTIVE, - borderRadius: 4 - }, - shadow: { - shadowColor: theme.COLORS.BLACK, - shadowOffset: { - width: 0, - height: 2 - }, - shadowRadius: 8, - shadowOpacity: 0.2 - }, - pro: { - backgroundColor: materialTheme.COLORS.LABEL, - paddingHorizontal: 6, - marginLeft: 8, - borderRadius: 2, - height: 16, - width: 36 - } -}); diff --git a/components/Header.js b/components/Header.js deleted file mode 100644 index c060fc2c..00000000 --- a/components/Header.js +++ /dev/null @@ -1,256 +0,0 @@ -import React from 'react'; -import { withNavigation } from '@react-navigation/compat'; -import { TouchableOpacity, StyleSheet, Platform, Dimensions } from 'react-native'; -import { Button, Block, NavBar, Input, Text, theme } from 'galio-framework'; - -import Icon from './Icon'; -import materialTheme from '../constants/Theme'; - -const { height, width } = Dimensions.get('window'); -const iPhoneX = () => Platform.OS === 'ios' && (height === 812 || width === 812 || height === 896 || width === 896); - -const ChatButton = ({isWhite, style, navigation}) => ( - navigation.navigate('Pro')}> - - - -); - -const BasketButton = ({isWhite, style, navigation}) => ( - navigation.navigate('Pro')}> - - - -); - -const SearchButton = ({isWhite, style, navigation}) => ( - navigation.navigate('Pro')}> - - -); - -class Header extends React.Component { - handleLeftPress = () => { - const { back, navigation } = this.props; - return (back ? navigation.goBack() : navigation.openDrawer()); - } - - renderRight = () => { - const { white, title, navigation } = this.props; - - if (title === 'Title') { - return [ - , - - ] - } - - switch (title) { - case 'Home': - return ([ - , - - ]); - case 'Deals': - return ([ - , - - ]); - case 'Categories': - return ([ - , - - ]); - case 'Category': - return ([ - , - - ]); - case 'Profile': - return ([ - , - - ]); - case 'Product': - return ([ - , - - ]); - case 'Search': - return ([ - , - - ]); - case 'Settings': - return ([ - , - - ]); - default: - break; - } - } - - renderSearch = () => { - const { navigation } = this.props; - return ( - navigation.navigate('Pro')} - iconContent={} - /> - ) - } - - renderTabs = () => { - const { navigation, tabTitleLeft, tabTitleRight } = this.props; - - return ( - - - - - ) - } - - renderHeader = () => { - const { search, tabs } = this.props; - if (search || tabs) { - return ( - - {search ? this.renderSearch() : null} - {tabs ? this.renderTabs() : null} - - ) - } - return null; - } - - render() { - const { back, title, white, transparent, navigation } = this.props; - // const { routeName } = navigation.state; - const noShadow = ["Search", "Categories", "Deals", "Pro", "Profile"].includes(title); - const headerStyles = [ - !noShadow ? styles.shadow : null, - transparent ? { backgroundColor: 'rgba(0,0,0,0)' } : null, - ]; - - return ( - - - {this.renderHeader()} - - ); - } -} - -export default withNavigation(Header); - -const styles = StyleSheet.create({ - button: { - padding: 12, - position: 'relative', - }, - title: { - width: '100%', - fontSize: 16, - fontWeight: 'bold', - }, - navbar: { - paddingVertical: 0, - paddingBottom: theme.SIZES.BASE * 1.5, - paddingTop: iPhoneX ? theme.SIZES.BASE * 4 : theme.SIZES.BASE, - zIndex: 5, - }, - shadow: { - backgroundColor: theme.COLORS.WHITE, - shadowColor: 'black', - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 6, - shadowOpacity: 0.2, - elevation: 3, - }, - notify: { - backgroundColor: materialTheme.COLORS.LABEL, - borderRadius: 4, - height: theme.SIZES.BASE / 2, - width: theme.SIZES.BASE / 2, - position: 'absolute', - top: 8, - right: 8, - }, - header: { - backgroundColor: theme.COLORS.WHITE, - }, - divider: { - borderRightWidth: 0.3, - borderRightColor: theme.COLORS.MUTED, - }, - search: { - height: 48, - width: width - 32, - marginHorizontal: 16, - borderWidth: 1, - borderRadius: 3, - }, - tabs: { - marginBottom: 24, - marginTop: 10, - elevation: 4, - }, - tab: { - backgroundColor: theme.COLORS.TRANSPARENT, - width: width * 0.50, - borderRadius: 0, - borderWidth: 0, - height: 24, - elevation: 0, - }, - tabTitle: { - lineHeight: 19, - fontWeight: '300' - }, -}) \ No newline at end of file diff --git a/components/Icon.js b/components/Icon.js deleted file mode 100644 index c902e341..00000000 --- a/components/Icon.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import * as Font from 'expo-font'; -import { createIconSetFromIcoMoon } from '@expo/vector-icons'; -import { Icon } from 'galio-framework'; - -import GalioConfig from '../assets/fonts/galioExtra'; - -const GalioExtra = require('../assets/fonts/galioExtra.ttf'); -const IconGalioExtra = createIconSetFromIcoMoon(GalioConfig, 'GalioExtra'); - -export default class IconExtra extends React.Component { - state = { - fontLoaded: false, - } - - async componentDidMount() { - await Font.loadAsync({ GalioExtra: GalioExtra }); - this.setState({ fontLoaded: true }); - } - - render() { - const { name, family, ...rest } = this.props; - - if (name && family && this.state.fontLoaded) { - if (family === 'GalioExtra') { - return ; - } - return ; - } - - return null; - } -} diff --git a/components/Product.js b/components/Product.js deleted file mode 100644 index 532f0d07..00000000 --- a/components/Product.js +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { withNavigation } from '@react-navigation/compat'; -import { StyleSheet, Dimensions, Image, TouchableWithoutFeedback } from 'react-native'; -import { Block, Text, theme } from 'galio-framework'; - -import materialTheme from '../constants/Theme'; - -const { width } = Dimensions.get('screen'); - -class Product extends React.Component { - render() { - const { navigation, product, horizontal, full, style, priceColor, imageStyle } = this.props; - const imageStyles = [styles.image, full ? styles.fullImage : styles.horizontalImage, imageStyle]; - - return ( - - navigation.navigate('Pro', { product: product })}> - - - - - navigation.navigate('Pro', { product: product })}> - - {product.title} - ${product.price} - - - - ); - } -} - -export default withNavigation(Product); - -const styles = StyleSheet.create({ - product: { - backgroundColor: theme.COLORS.WHITE, - marginVertical: theme.SIZES.BASE, - borderWidth: 0, - minHeight: 114, - }, - productTitle: { - flex: 1, - flexWrap: 'wrap', - paddingBottom: 6, - }, - productDescription: { - padding: theme.SIZES.BASE / 2, - }, - imageContainer: { - elevation: 1, - }, - image: { - borderRadius: 3, - marginHorizontal: theme.SIZES.BASE / 2, - marginTop: -16, - }, - horizontalImage: { - height: 122, - width: 'auto', - }, - fullImage: { - height: 215, - width: width - theme.SIZES.BASE * 3, - }, - shadow: { - shadowColor: theme.COLORS.BLACK, - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 4, - shadowOpacity: 0.1, - elevation: 2, - }, -}); \ No newline at end of file diff --git a/components/Select.js b/components/Select.js deleted file mode 100644 index c859e3fa..00000000 --- a/components/Select.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; -import ModalDropdown from 'react-native-modal-dropdown'; -import { Block, Text, Icon,theme } from 'galio-framework'; - -export default class DropDown extends React.Component { - state = { - value: 1, - } - - handleOnSelect = (index, value) => { - const { onSelect } = this.props; - - this.setState({ value: value }); - onSelect && onSelect(index, value); - } - - render() { - const { onSelect, style, ...props } = this.props; - return ( - - - {this.state.value} - - - - ) - } -} - -const styles = StyleSheet.create({ - qty: { - width: 100, - backgroundColor: '#DCDCDC', - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom:9.5, - borderRadius: 3, - shadowColor: "rgba(0, 0, 0, 0.1)", - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 4, - shadowOpacity: 1, - }, - dropdown: { - marginTop: 8, - marginLeft: -16, - width: 100, - }, -}); diff --git a/components/Switch.js b/components/Switch.js deleted file mode 100644 index 6effca5a..00000000 --- a/components/Switch.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { Switch, Platform } from 'react-native'; - -import materialTheme from '../constants/Theme'; - -export default class MkSwitch extends React.Component { - render() { - const { value, ...props } = this.props; - const thumbColor = Platform.OS === 'ios' ? null : - Platform.OS === 'android' && value ? materialTheme.COLORS.SWITCH_ON : materialTheme.COLORS.SWITCH_OFF; - - return ( - - ); - } -} \ No newline at end of file diff --git a/components/Tabs.js b/components/Tabs.js deleted file mode 100644 index 2ecd0785..00000000 --- a/components/Tabs.js +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react'; -import { StyleSheet, Dimensions, FlatList, Animated } from 'react-native'; -import { Block, theme } from 'galio-framework'; - -const { width } = Dimensions.get('screen'); -import materialTheme from '../constants/Theme'; - -const defaultMenu = [ - { id: 'popular', title: 'Popular', }, - { id: 'beauty', title: 'Beauty', }, - { id: 'cars', title: 'Cars', }, - { id: 'motocycles', title: 'Motocycles', }, -]; - -export default class MenuHorizontal extends React.Component { - static defaultProps = { - data: defaultMenu, - initialIndex: null, - } - - state = { - active: null, - } - - componentDidMount() { - const { initialIndex } = this.props; - initialIndex && this.selectMenu(initialIndex); - } - - animatedValue = new Animated.Value(1); - - animate() { - this.animatedValue.setValue(0); - - Animated.timing(this.animatedValue, { - toValue: 1, - duration: 300, - // useNativeDriver: true, // color not supported - }).start() - } - - menuRef = React.createRef(); - - onScrollToIndexFailed = () => { - this.menuRef.current.scrollToIndex({ - index: 0, - viewPosition: 0.5 - }); - } - - selectMenu = (id) => { - this.setState({ active: id }); - - this.menuRef.current.scrollToIndex({ - index: this.props.data.findIndex(item => item.id === id), - viewPosition: 0.5 - }); - - this.animate(); - this.props.onChange && this.props.onChange(id); - } - - renderItem = (item) => { - const isActive = this.state.active === item.id; - - const textColor = this.animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: [materialTheme.COLORS.MUTED, isActive ? materialTheme.COLORS.ACTIVE : materialTheme.COLORS.MUTED], - extrapolate: 'clamp', - }); - - const width = this.animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: ['0%', isActive ? '100%' : '0%'], - extrapolate: 'clamp', - }); - - return ( - - this.selectMenu(item.id)}> - {item.title} - - - - ) - } - - renderMenu = () => { - const { data, ...props } = this.props; - - return ( - item.id} - showsHorizontalScrollIndicator={false} - onScrollToIndexFailed={this.onScrollToIndexFailed} - renderItem={({ item }) => this.renderItem(item)} - contentContainerStyle={styles.menu} - /> - ) - } - - render() { - return ( - - {this.renderMenu()} - - ) - } -} - -const styles = StyleSheet.create({ - container: { - width: width, - backgroundColor: theme.COLORS.WHITE, - zIndex: 2, - }, - shadow: { - shadowColor: theme.COLORS.BLACK, - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 8, - shadowOpacity: 0.2, - elevation: 4, - }, - menu: { - paddingHorizontal: theme.SIZES.BASE * 2.5, - paddingTop: 8, - paddingBottom: 0, - }, - titleContainer: { - alignItems: 'center', - }, - menuTitle: { - fontWeight: '300', - fontSize: 16, - lineHeight: 28, - // paddingBottom: 8, - paddingHorizontal: 16, - color: materialTheme.COLORS.MUTED - }, -}); diff --git a/components/index.js b/components/index.js deleted file mode 100644 index e683466d..00000000 --- a/components/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import Button from './Button'; -import Select from './Select'; -import Icon from './Icon'; -import Tabs from './Tabs'; -import Product from './Product'; -import Drawer from './Drawer'; -import Header from './Header'; -import Switch from './Switch'; - -export { - Button, - Select, - Icon, - Tabs, - Product, - Drawer, - Header, - Switch, -}; \ No newline at end of file diff --git a/constants/Images.js b/constants/Images.js deleted file mode 100644 index 3eded2a4..00000000 --- a/constants/Images.js +++ /dev/null @@ -1,26 +0,0 @@ -const Onboarding = 'https://images.unsplash.com/photo-1505995433366-e12047f3f144?fit=crop&w=840&q=80'; -const Pro = 'https://images.unsplash.com/photo-1485796826113-174aa68fd81b?fit=crop&w=840&q=80'; -const Products = { - 'Accessories': 'https://source.unsplash.com//l1MCA0VyNrk/840x840', -}; - -const Profile = 'https://images.unsplash.com/photo-1512529920731-e8abaea917a5?fit=crop&w=840&q=80'; -const Avatar = 'https://images.unsplash.com/photo-1518725522904-4b3939358342?fit=crop&w=210&q=80'; - -const Viewed = [ - 'https://images.unsplash.com/photo-1508264443919-15a31e1d9c1a?fit=crop&w=240&q=80', - 'https://images.unsplash.com/photo-1497034825429-c343d7c6a68f?fit=crop&w=240&q=80', - 'https://images.unsplash.com/photo-1487376480913-24046456a727?fit=crop&w=240&q=80', - 'https://images.unsplash.com/photo-1494894194458-0174142560c0?fit=crop&w=240&q=80', - 'https://images.unsplash.com/photo-1500462918059-b1a0cb512f1d?fit=crop&w=240&q=80', - 'https://images.unsplash.com/photo-1542068829-1115f7259450?fit=crop&w=240&q=80', -]; - -export default { - Onboarding, - Pro, - Products, - Profile, - Viewed, - Avatar, -} \ No newline at end of file diff --git a/constants/Theme.js b/constants/Theme.js deleted file mode 100644 index ccb1320e..00000000 --- a/constants/Theme.js +++ /dev/null @@ -1,27 +0,0 @@ -export default { - COLORS: { - DEFAULT: '#DCDCDC', - PRIMARY: '#9C26B0', - LABEL: '#FE2472', - INFO: '#00BCD4', - ERROR: '#F44336', - SUCCESS: '#4CAF50', - WARNING: '#FF9800', - MUTED: '#979797', - INPUT: '#DCDCDC', - ACTIVE: '#9C26B0', - BUTTON_COLOR: '#9C26B0', - PLACEHOLDER: '#9FA5AA', - SWITCH_ON: '#9C26B0', - SWITCH_OFF: '#D4D9DD', - GRADIENT_START: '#6B24AA', - GRADIENT_END: '#AC2688', - PRICE_COLOR: '#EAD5FB', - BORDER_COLOR: '#E7E7E7', - BLOCK: '#E7E7E7', - ICON: '#4A4A4A', - }, - SIZES: { - BLOCK_SHADOW_RADIUS: 2, - } -}; \ No newline at end of file diff --git a/constants/index.js b/constants/index.js deleted file mode 100644 index a3fce2de..00000000 --- a/constants/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import Images from './Images'; -import products from './products'; -import materialTheme from './Theme'; -import utils from './utils'; - -export { - Images, - products, - materialTheme, - utils, -} \ No newline at end of file diff --git a/constants/products.js b/constants/products.js deleted file mode 100644 index 3d1a710d..00000000 --- a/constants/products.js +++ /dev/null @@ -1,29 +0,0 @@ -export default [ - { - title: 'Hardly Anything Takes More Coura...', - image: 'https://source.unsplash.com/dS2hi__ZZMk/840x840', - price: 180, - horizontal: true, - }, - { - title: 'Find the cheapest deals on our range...', - image: 'https://source.unsplash.com/tb6ulgGY5Zc/840x840', - price: 220, - }, - { - title: 'Adidas Tango Terry Jersey', - image: 'https://source.unsplash.com/YHbcum51JB0/840x840', - price: 40, - }, - { - title: 'Internet of Things (IoT) is Here to Stay', - image: 'https://source.unsplash.com/I7BSOoPa5hM/840x840', - price: 188, - horizontal: true, - }, - { - title: 'Coffee - A Drop of Happiness in a Cup', - image: 'https://source.unsplash.com/Ws4wd-vJ9M0/840x840', - price: 180, - }, -]; diff --git a/constants/utils.js b/constants/utils.js deleted file mode 100644 index 561e5cfc..00000000 --- a/constants/utils.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Platform, StatusBar } from 'react-native'; -import { theme } from 'galio-framework'; - -export const StatusHeight = StatusBar.currentHeight; -export const HeaderHeight = (theme.SIZES.BASE * 3.5 + (StatusHeight || 0)); -export const iPhoneX = () => Platform.OS === 'ios' && (height === 812 || width === 812); \ No newline at end of file diff --git a/design/home-before-after.html b/design/home-before-after.html new file mode 100644 index 00000000..79c1e690 --- /dev/null +++ b/design/home-before-after.html @@ -0,0 +1,196 @@ + + + + + +Bojo — strona główna: PRZED / PO + + + + + + + + + +
+

Bojo — strona główna

+

Porównanie warstwy wizualnej. Mapa zastąpiona placeholderem. Paleta robocza: zielony #15803d, limonka #84cc16, tekst #0f172a, tło #f8faf9.

+ +
+ + +
+
PRZED
+ + +
+
+

Następny mecz zaczyna się tutaj.

+

Boiska, mecze i gracze w Poznaniu i okolicach — w jednym miejscu.

+
+ + +
+
+
+ + +
+

Setki boisk. Poznań i okolice.

+

Kliknij boisko, żeby zobaczyć dostępność i aktywne gry.

+
+
[ Mapa ]
+
+
+ + +
+
+

Jak to działa?

+

Mniej organizowania, więcej grania.

+
+ +
+
+
+
📍
+ 1 +
+

Wybierz boisko i termin.

+
+

Setki boisk w Poznaniu — filtruj po sporcie.

+
+
+
+
+
👥
+ 2 +
+

Udostępnij link. Gracze potwierdzają jednym kliknięciem.

+
+

Zero rejestracji dla zaproszonych. Widzisz kto idzie.

+
+
+
+
+
+ 3 +
+

Ogłoś mecz publicznie — dograją się gracze z okolicy.

+
+
+
+
+
+
+ + +
+
PO
+ + +
+
+
+ + + Poznań i okolice + +

Następny mecz
zaczyna się tutaj.

+

Boiska, mecze i gracze w jednym miejscu. Zbierz skład bez przekopywania się przez grupy na Facebooku.

+
+ + +
+
+
+
+ + +
+
+

Setki boisk. Poznań i okolice.

+

Kliknij boisko, żeby zobaczyć dostępność i aktywne gry.

+
+
+
[ Mapa ]
+
+

Otwórz pełną mapę →

+
+ + +
+
+
+

Jak to działa?

+

Mniej organizowania, więcej grania.

+
+
+
+ +
📍1
+

Wybierz boisko i termin.

+

Setki boisk w Poznaniu — filtruj po sporcie.

+
+
+ +
👥2
+

Udostępnij link. Gracze potwierdzają jednym kliknięciem.

+

Zero rejestracji dla zaproszonych. Widzisz kto idzie.

+
+
+ +
3
+

Ogłoś mecz publicznie — dograją się gracze z okolicy.

+

Brakuje wam dwóch do składu? Ktoś z okolicy dołączy.

+
+
+
+
+
+ +
+
+ + + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..e00f9106 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# Dokumentacja Bojo + +Baza wiedzy o projekcie. Zasady pracy w repo (komendy, konwencje, pułapki) → +[AGENTS.md](../AGENTS.md). + +## Który plik na jakie pytanie + +| Pytanie | Plik | +|---|---| +| Po co jest ten produkt? Co ma robić? Czy funkcja X jest w planie? | [wizja.md](./wizja.md) | +| Co jest zbudowane? Które flagi co ukrywają? Czego NIE ma? | [funkcje.md](./funkcje.md) | +| Jak to jest zamodelowane? Dlaczego architektura wygląda tak? | [domena.md](./domena.md) | +| Która migracja tworzy tabelę X? Czemu zapis nie działa? | [baza-danych.md](./baza-danych.md) | +| Ile to kosztuje? Kto co robi? Co jest w której fazie? | [strategia.md](./strategia.md) | +| Jak opisać Bojo modelowi, który nie ma dostępu do repo? | [llm-context.md](./llm-context.md) | +| Chcę, żeby model zakwestionował ten produkt — co mu wkleić? | [prompt-rewizja.md](./prompt-rewizja.md) | +| Co wyszło z rewizji przed startem? | [rewizja-2026-08.md](./rewizja-2026-08.md) | +| Gdzie organizator się zacina przy tworzeniu meczu? Co zostaje bez zmian i dlaczego? | [przeplyw-organizatora.md](./przeplyw-organizatora.md) | + +## Hierarchia + +**[wizja.md](./wizja.md) jest dokumentem nadrzędnym.** Gdy kod nie zgadza się z wizją, +to kod nie nadążył — rozbieżność trafia do [BACKLOG.md](../BACKLOG.md) jako zadanie, +a nie do dokumentacji jako sprostowanie. + +Celowo NIE utrzymujemy inwentarzy tras, funkcji i komponentów — agent znajdzie je +w kodzie szybciej, niż my utrzymamy tabelę. Dokumentujemy tylko to, czego kod sam +nie powie: wizję, nieoczywiste reguły, schemat bazy, stan flag. + +## Rytmy aktualizacji + +| Rytm | Co obejmuje | Mechanizm | +|---|---|---| +| **Zdarzenie — ten sam PR/commit** | [domena.md](./domena.md), [funkcje.md](./funkcje.md), [baza-danych.md](./baza-danych.md), [llm-context.md](./llm-context.md), `frontend/public/llms.txt`, [AGENTS.md](../AGENTS.md) | hook doc-guard przypomina; `npm run check:docs` weryfikuje; CI odrzuca rozjazd | +| **Audyt kwartalny** | statusy w [wizja.md](./wizja.md) §2–3, [BACKLOG.md](../BACKLOG.md), [PRZEWODNIK.md](../PRZEWODNIK.md) | checklista poniżej | +| **Ręcznie, gdy ludzie coś ustalą** | [wizja.md](./wizja.md) §1 (werbatim — nigdy przez agenta), [strategia.md](./strategia.md) | człowiek | + +### Checklista audytu kwartalnego + +1. `npm run check:docs` — zielony? +2. Statusy w [wizja.md](./wizja.md) §2 nadal zgodne z flagami i kodem? +3. [BACKLOG.md](../BACKLOG.md): wykreśl zrobione, zaktualizuj „luki wobec wizji". +4. Liczby w [AGENTS.md](../AGENTS.md) i [PRZEWODNIK.md](../PRZEWODNIK.md) (testy, + migracje, workflowy) — aktualne? +5. Czy funkcja odmrożona od ostatniego audytu czeka na wpis w `llms.txt` i sitemap? + +## Mapowanie: zmiana kodu → dokument + +| Zmieniasz | Sprawdź | +|---|---| +| `frontend/src/lib/features.ts`, `config/features.ts` | [funkcje.md](./funkcje.md#flagi-funkcji) | +| `frontend/src/lib/*` | [domena.md](./domena.md), [funkcje.md](./funkcje.md) | +| `frontend/src/app/*` (nowa lub usunięta trasa) | [funkcje.md](./funkcje.md), `frontend/public/llms.txt` | +| `supabase/migrations/*` | [baza-danych.md](./baza-danych.md) | + +## Odbiorcy dokumentacji + +- **Modele rozwijające kod** — `AGENTS.md` (zasady) + `docs/` (wiedza) +- **Modele czytające o Bojo na zimno, bez dostępu do repo** — + [llm-context.md](./llm-context.md), serwowany też pod `bojo.pl/llm-context.md` + (kopia w `frontend/public/`, synchronizacja: `npm run sync:llm-context`) +- **Wyszukiwarki i crawlery** — JSON-LD na stronach publicznych + (`lib/structuredData.ts`), `robots.txt`, `sitemap.xml` oraz `frontend/public/llms.txt` + — indeks odsyłający do `llm-context.md`. Zastrzeżenie zostaje aktualne: żaden duży + dostawca nie potwierdził, że czyta `llms.txt`, więc plik ma być krótki i tani + w utrzymaniu, a nie rozbudowywany +- **Ludzie** — [PRZEWODNIK.md](../PRZEWODNIK.md) (opis funkcji), + [README.md](../README.md) (start) diff --git a/docs/baza-danych.md b/docs/baza-danych.md new file mode 100644 index 00000000..f9a9bfc3 --- /dev/null +++ b/docs/baza-danych.md @@ -0,0 +1,234 @@ +# Baza danych + +95 migracji (`001`–`097`, z lukami w numeracji — dwóch numerów tuż przed `082` brak) w +`supabase/migrations/`. Modele domenowe → [domena.md](./domena.md). + +--- + +## ⚠️ Migracje uruchamia się RĘCZNIE + +Pliki w `supabase/migrations/` trzeba **wkleić do Supabase → SQL Editor**, kolejno wg +numeracji. **Nic nie robi tego automatycznie** — nie ma CI, nie ma `supabase db push` +w pipelinie. + +**Dodanie kolumny w pliku migracji ≠ kolumna istnieje w bazie.** Jeśli aplikacja rzuca +błędem o nieznanej kolumnie, pierwsza hipoteza brzmi: migracja nie została puszczona. + +**Stanu bazy produkcyjnej nie da się odczytać z repo.** Numer ostatniej migracji w repo +mówi tylko, co zostało napisane — nie co zostało zastosowane. + +### ⚠️ Historia migracji w repo ≠ historia w Supabase (MCP) + +Od sesji z dostępem do Supabase przez MCP (`execute_sql`, `list_migrations`, +`apply_migration`) można czytać i pisać do bazy wprost z agenta. **`list_migrations` +na produkcji zwraca pustą listę** — mimo 60 plików w `supabase/migrations/` — bo +wszystkie były wklejane ręcznie do SQL Editora, a nie puszczane przez `apply_migration`. + +**Pusta lista nie znaczy „baza jest świeża".** Znaczy tylko, że Supabase nigdy nie +prowadziło własnej księgi migracji dla tego projektu. Pierwsze użycie `apply_migration` +założy **równoległą historię** zaczynającą się od zera — numeracja w repo (`060_…`) +i historia w Supabase nigdy nie będą tożsame, i to jest oczekiwane, nie błąd. + +Zasady pracy z `apply_migration` (agent, nie człowiek w SQL Editorze): +- plik w `supabase/migrations/NNN_nazwa.sql` jest źródłem prawdy — `apply_migration` + go wykonuje, nigdy odwrotnie (żadnego DDL istniejącego wyłącznie w bazie), +- najpierw projekt deweloperski, potem produkcja (patrz „Osobna baza" niżej — + **projekt `BojoDev` już istnieje**, dziś ze statusem `INACTIVE`), +- nigdy bez wyraźnej zgody użytkownika w danym momencie — zgoda na jedną migrację + nie jest zgodą na następną, +- po każdym DDL: `get_advisors(type: 'security')` — łapie brakujące polityki RLS. + +## ⚠️ RLS po cichu unieważnia UPDATE + +Gdy polityka RLS nie pasuje, Postgres **nie zgłasza błędu** — po prostu aktualizuje +0 wierszy i zwraca sukces. + +Objaw: „przycisk nic nie robi", zero błędów w konsoli. + +Realny przypadek: brakowało polityki pozwalającej użytkownikowi zmienić własny wpis +w `event_participants` — naprawione w `053_own_participation_update.sql`. + +**Jeśli zapis „nie działa" bez błędu — najpierw sprawdź polityki, potem kod.** + +--- + +## Tabele → migracja tworząca + +| Tabela | Powstała w | Rola | +|---|---|---| +| `fields` | `001` | Boiska i obiekty (~1400) | +| `events` | `002` | Mecze. `recurring_event_id` (`073`) wiąże termin z szablonem — patrz sekcja o seriach niżej. `min_players` (`097`) — próg „gra się odbędzie", `NULL` = brak progu | +| `event_participants` | `002` | Zapisy na mecz. Kolumny `status` i `confirmed_at` usunięte w `064` — relację gracza do meczu opisują `pending_approval` i `rsvp`. `claim_token` (`066`) pozwala gościowi przejąć wpis po założeniu konta | +| `event_declines` | `097` | Jawne „nie gram" — NIE nieobecność. Klucz główny `(event_id, user_id)`, RLS: widoczna dla siebie/organizatora/członków grupy meczu, zapis wyłącznie za siebie | +| `profiles` | `005` | Użytkownicy (+ flaga `is_admin`) | +| `recurring_events` | `007` | Szablony meczów cyklicznych — reguła powtarzania (dzień, godzina, wyprzedzenie), nie komplet ustawień meczu | +| `recurring_event_invites` | `007` | Zapraszani do cyklicznych | +| `bookings` | `008` | Rezerwacje terminów | +| `venue_schedules` | `008` | Godziny otwarcia obiektu | +| `venue_pricing` | `008` | Cennik obiektu | +| `match_results` | `011` | Wyniki meczów | +| `player_goals` | `011` | Gole i asysty per gracz | +| `player_stats` | `011` | Statystyki gracza | +| `player_reports` | `011` | Zgłoszenia graczy | +| `event_reminders` | `013` | Przypomnienia o meczu | +| `player_match_stats` | `014` | Statystyki per mecz | +| `rate_limits` | `016` | Limity (m.in. usuwanie konta) | +| `field_outreach` | `020` | CRM kontaktu z obiektami | +| `game_alerts` | `025` | Alerty o grach w okolicy | +| `notifications` | `025` | Powiadomienia in-app. `claim_token` (`084`) — dla typu `niepotwierdzony_wpis_goscia`, link do przejęcia wpisu | +| `event_comments` | `026` | Komentarze pod meczem | +| `field_comments` | `063` | Komentarze pod obiektem z katalogu boisk — osobne od `event_comments`, bo przeżywają pojedynczy mecz | +| `event_activity_log` | `026` | Log zdarzeń meczu | +| `event_invites` | `036` | Zaproszenia na mecz po e-mailu — **martwa**, `lib/invites.ts` nie jest nigdzie importowany | +| `event_player_invites` | `060`, RLS poprawione w `061` | Imienne zaproszenia użytkowników na mecz | +| `groups` | `044` | Stałe ekipy. `cover_image_url` (`046`), `field_id`/`field_name` (`051`), `join_code_rotated_at` (`094`) | +| `group_members` | `044` | Członkowie ekip. `can_manage_members`/`can_create_events`/`can_moderate_wall`/`granted_by` (`092`), `invited_by` (`094`), `can_invite` (`096`) — patrz `092`/`094`/`096` niżej | +| `group_posts` | `093` | Rozmowa grupy (dawniej „Tablica") — płaska lista, `pinned_at` dla ogłoszenia, zamknięta dla nie-członków | +| `analytics_events` | `047` | Log akcji do analityki | +| `team_proposals` | `059` | Propozycje składów od uczestników | +| `team_proposal_picks` | `059` | Przypisania graczy w propozycji | +| `team_proposal_votes` | `059` | Poparcia propozycji | +| `tournaments` i 5 tabel `tournament_*` | `029` | Turniej | +| `event_delegates` | `089` | Delegowanie uprawnień organizatora (`can_edit`/`can_manage_squad`/`can_manage_payments`) — patrz `090` niżej | + +**Tabela `games` (`001`) jest martwa** — powstała w pierwszym schemacie i została +zastąpiona przez `events` (`002`). Żaden kod jej nie używa. + +--- + +## Migracje zmieniające zachowanie (nie tylko schemat) + +Te warto znać, bo wyjaśniają, dlaczego coś działa tak, a nie inaczej: + +| Migracja | Co wprowadziła | +|---|---| +| `011_advanced_event_features` | Drużyny, wyniki, płatności, statystyki | +| `025_game_alerts` | Alerty + tabela `notifications` + RPC `get_nearby_events` | +| `033_contact_visibility` | Telefony i e-maile boisk **ukryte domyślnie**, egzekwowane w DB | +| `041_join_code` | Kod dołączenia + `require_approval` | +| `043_player_stats_fn` | RPC `get_player_stats` (poprawki w `045`, `055`) | +| `048_participant_pending_approval` | Oczekiwanie na akceptację nie zajmuje miejsca | +| `049_participant_rsvp` | RSVP „Obserwuję" (`maybe`) | +| `053_own_participation_update` | **Polityka RLS na własny wiersz uczestnika** — patrz ostrzeżenie wyżej | +| `055_stats_exclude_observing` | Obserwujący nie liczą się do statystyk | +| `056_payment_options` | Metody płatności i karty sportowe | +| `062_reserve_claim_notification` | `sync_reserve_claim` dopisuje wpis do `notifications`, gdy oferuje zwolnione miejsce — dotąd oferta była widoczna tylko po ręcznym wejściu na stronę meczu | +| `065_powiadomienia_akceptacja_termin` | Wyzwalacze: akceptacja zapisu i zmiana terminu meczu | +| `067_powiadomienie_o_zaproszeniu` | Wyzwalacz na `event_player_invites` + uzupełnienie zaległych zaproszeń | +| `070_powiadomienia_odwolanie_i_profil` | Wyzwalacze: **odwołanie meczu** (dotąd ciche — uczestnik dowiadywał się wyłącznie wchodząc na stronę) oraz **nowe konto bez imienia** (kieruje do `/profil`) | +| `071_wymagaj_pelnej_nazwy_w_powiadomieniu` | Zaostrza wyzwalacz z `070` na "nowe konto bez imienia" — wymaga co najmniej dwóch członów nazwy (imię i nazwisko), nie tylko dowolnej niepustej wartości. Google OAuth zawsze wypełnia `full_name`, więc słabszy check praktycznie nigdy nie wykrywał braku | +| `072_brakujace_powiadomienia` | Wyzwalacze: **organizator** dostaje powiadomienie o nowej prośbie o dołączenie (`event_participants.pending_approval`), **członkowie grupy** dostają powiadomienie o nowym meczu w grupie (`events.group_id`) | +| `073_serie_wydarzen_cyklicznych` | `events.recurring_event_id` — termin cykliczny staje się prawdziwą **serią**, nie zbiorem niepowiązanych kopii. Funkcja `utworz_termin_serii()` (RPC dla przeglądarki i crona) kopiuje pełne ustawienia z ostatniego terminu, nie z ubogiego szablonu — wcześniej `spawnEventInstance()` gubił cenę, płatności i bramkarzy. Cron co godzinę (`pg_cron`, jeśli włączony) tworzy należne terminy z wyprzedzeniem `notify_days_before`; wyzwalacz powiadamia o nowym terminie uczestników poprzedniego | +| `079_powiadom_o_zmianie_kompletu` | Wyzwalacz na `event_participants` (INSERT/UPDATE/DELETE): powiadamia organizatora, gdy skład **przechodzi** ze stanu niekompletnego w komplet albo z kompletu z powrotem w niekompletny (kogoś zabrakło). Nie powiadamia o każdym pojedynczym zapisie — tylko o zmianie stanu, żeby nie zagłuszyć dwóch naprawdę ważnych momentów kilkunastoma wpisami na jeden mecz | +| `082_guest_self_signup` | RPC `dolacz_do_meczu_jako_goscie()` — zapis na mecz bez konta (imię + e-mail), kolumny `guest_email`/`guest_phone` w `event_participants` | +| `083_fix_guest_signup_claim_token` | Poprawka `082` — `INSERT…RETURNING` z jawnym prefiksem tabeli, naprawia „ambiguous column reference" w `claim_token` | +| `084_powiadomienie_o_koncie_z_wpisem_goscia` | Dwa wyzwalacze po obu stronach skojarzenia po e-mailu: nowy wpis gościa → istniejące konto z tym e-mailem dostaje powiadomienie od razu; nowe konto → dostaje powiadomienie o już istniejących nieprzejętych wpisach gościa z tym e-mailem. Kolumna `notifications.claim_token`, indeks na `event_participants (lower(guest_email))`. Świadomie bez automatycznego przejęcia — tylko powiadomienie z linkiem, przejęcie nadal wymaga `auth.uid()` | +| `085_zapobiegaj_duplikatom_wpisu_goscia` | `dolacz_do_meczu_jako_goscie()` (`082`/`083`) sprawdza na starcie, czy ten sam e-mail już ma wpis w TYM meczu (nieprzejęty gość → zwraca istniejący `claim_token` zamiast duplikatu; przejęty → odrzuca) albo pasuje do konta już uczestniczącego przez normalne dołączenie. Naprawia realny przypadek z produkcji — ten sam e-mail zapisywał się jako gość wielokrotnie na jeden mecz | +| `086_rpc_powiadomienie_braku_nazwy` | Wyzwalacz z `070`/`071` na `auth.users` jest poprawnie zdefiniowany, ale w produkcji **nigdy nie wstawił ani jednego powiadomienia** `uzupelnij_profil` — potwierdzone zapytaniem po danych produkcyjnych (dziesiątki kont z niepełną nazwą, zero wierszy tego typu w `notifications`), przyczyna nieznana. RPC `zglos_brak_pelnej_nazwy()` (`SECURITY DEFINER`, `GRANT EXECUTE TO authenticated`) to niezawodny odpowiednik po stronie klienta — wołany z `lib/auth.tsx` przy `SIGNED_IN` dla świeżych kont (< 10 min), tym samym warunkiem `isPelneImie()` co baner na pulpicie. Wyzwalacz zostaje — `NOT EXISTS` w RPC chroni przed duplikatem, gdyby jednak zadziałał | +| `087_juz_dolaczony_flaga` | `dolacz_do_meczu_jako_goscie()` zwraca dodatkową kolumnę `already_joined` (true przy idempotentnym zwrocie istniejącego `claim_token`, false przy świeżym zapisie) — frontend rozróżnia po niej ekran „Zapisano!" od „Wcześniej dołączyłeś do tej gry.". Zmiana `RETURNS TABLE` wymagała `DROP FUNCTION` + `CREATE` (nie `CREATE OR REPLACE`) i ponownego `GRANT` | +| `088_konto_i_zamek_na_duplikaty` | Czwarta kolumna wyniku `dolacz_do_meczu_jako_goscie()`: `has_account` (`EXISTS` na `auth.users` po `lower(email)` — pytanie globalne, nie „czy w tym meczu"), dzięki czemu ekran po zapisie namawia na LOGOWANIE zamiast na drugie konto. Wyjątek `'Jesteś już zapisany na ten mecz.'` zamieniony na zwykły wiersz z `claim_token = NULL` (frontend rozpoznaje sytuację po kształcie wyniku, nie po treści komunikatu). Wyszukanie istniejącego wpisu dostało `ORDER BY (claim_token IS NULL) DESC, created_at` — samo `LIMIT 1` losowało wariant ekranu przy duplikatach. Unikalny indeks `idx_participants_unique_guest_email` na `(event_id, lower(guest_email)) WHERE guest_email IS NOT NULL` zamyka wyścig dwóch równoległych zapisów; **migracja KASUJE nadmiarowe wpisy** sprzed `085` (zostaje przejęty, a jak nie ma — najstarszy), bo inaczej indeks się nie zakłada | +| `089_delegaci_wydarzenia` | Tabela `event_delegates` (organizator deleguje `can_edit`/`can_manage_squad`/`can_manage_payments` uczestnikowi meczu albo członkowi przypiętej grupy) + trzy funkcje pomocnicze `can_edit_event()`/`can_manage_squad()`/`can_manage_payments()` (`SECURITY DEFINER`) do użycia w politykach RLS innych tabel. Listą delegatów zarządza wyłącznie prawdziwy organizator | +| `090_rozszerzenie_rls_o_delegatow` | Rozszerza polityki RLS na `events`, `event_participants`, `team_proposals`, `team_proposal_picks`, `match_results`, `player_goals`, `event_player_invites` o funkcje z `089`. Nowa RPC `event_set_payment_settings()` — jedyna droga dla delegata z samym `can_manage_payments` (bez `can_edit`) do zmiany metod płatności/BLIK, bo ogólna polityka UPDATE na `events` celowo nie obejmuje `can_manage_payments` (tabela ma ~30 niezwiązanych kolumn). `set_event_teams_published()` (`042`) przechodzi z `SECURITY INVOKER` na `SECURITY DEFINER` + `can_manage_squad()` z tego samego powodu | +| `091_oznaczanie_nieobecnosci` | Unikalny indeks na `player_reports (event_id, reported_participant_id, report_type)` — bez niego powtórne oznaczenie nieobecności zawyżało `no_shows` w `get_player_stats()`. Zaostrza RLS: `player_reports` INSERT był otwarty dla dowolnego zalogowanego użytkownika (`auth.uid() IS NOT NULL`), teraz tylko organizator/delegat z `can_manage_squad`; dodaje brakującą politykę DELETE (cofnięcie błędnego oznaczenia) | +| `092_uprawnienia_w_grupie` | `group_members` dostaje `can_manage_members`/`can_create_events`/`can_moderate_wall` (wzorem `089`) + pięć funkcji `SECURITY DEFINER` (`czy_zalozyciel_grupy`, `czy_czlonek_grupy`, `czy_moze_zarzadzac_grupa`, `czy_moze_tworzyc_wydarzenia_w_grupie`, `czy_moze_moderowac_tablice`, wszystkie z `GRANT` dla `anon` I `authenticated` — strona grupy renderuje się kluczem anonimowym). Trigger `ustaw_role_czlonka()` wylicza `role` z przełączników przy każdym zapisie, nadpisując to, co przyszło z klienta. Wyzwalacz `pilnuj_uprawnien_do_grupy()` na `events.group_id` pilnuje, kto może przypiąć mecz do grupy — pomija kontrolę, gdy `auth.uid() IS NULL` (seedy, admin z SQL Editora, przyszłe zadania w tle — ten sam wzorzec co `073`) oraz gdy termin dziedziczy grupę z serii cyklicznej (`recurring_event_id`) | +| `093_tablica_grupy` | Tabela `group_posts` (płaska lista, `pinned_at`, RLS zamknięte dla nie-członków przez `czy_czlonek_grupy`). `notifications.group_id` — powiadomienie bez meczu. Wyzwalacz `powiadom_o_ogloszeniu_w_grupie()` powiadamia ekipę WYŁĄCZNIE przy przypięciu wpisu przez kogoś z `can_moderate_wall`; `powiadom_o_nowym_meczu_w_grupie()` (`072`) dostaje `group_id` w insercie | +| `094_zaproszenia_do_grupy` | `group_members.invited_by` + `groups.join_code_rotated_at`. RPC `dolacz_do_grupy_kodem(kod, od)` (jedyna droga samodzielnego dołączenia — weryfikuje `od` w bazie, zanim zapisze zapraszającego), `dodaj_czlonka_do_grupy` (dla `can_manage_members`, bez kodu), `odswiez_kod_grupy` (wyłącznie założyciel). **Zdejmuje politykę INSERT na `group_members`** — dotąd wystarczyło znać UUID grupy (publicznie czytelne), żeby się do niej dopisać | +| `095_statystyki_grupy` | RPC `get_group_stats` (publiczne — pięć liczb do nagłówka grupy) i `get_group_leaderboard` (wyłącznie dla członków — `SECURITY DEFINER`, bo `player_reports` czyta tylko organizator/delegat). Zwycięstwa liczone wyłącznie tam, gdzie mecz miał podział na drużyny I zapisany wynik — stąd dodatkowa kolumna `matches_with_teams` jako mianownik | +| `096_zaproszanie_do_grupy` | `group_members.can_invite` (domyślnie `true`, jak `can_create_events` w `092` — dziś każdy widzi „Zaproś" bez bramki) — czwarty niezależny przełącznik, kto widzi przycisk „Zaproś" i kod dołączenia. Bramka wyłącznie UI: `dolacz_do_grupy_kodem` (`094`) nie sprawdzała i nadal nie sprawdza uprawnień zapraszającego. Trigger `ustaw_role_czlonka()` (`092`) przedefiniowany, żeby wymusić `can_invite = true` na founderze | +| `097_czy_gramy` | `events.min_players` (próg „gra się odbędzie", `NULL` domyślnie — zero zmiany dla istniejących meczów) + tabela `event_declines` (jawne „nie gram", osobna od `rsvp` — patrz `docs/domena.md`). RPC `zapytaj_milczacych(event_id)` (`SECURITY DEFINER`, wyłącznie mecze przypięte do grupy) powiadamia członków ekipy bez wpisu w składzie ani odmowy, z zaporą przed spamem (12 h). Wyzwalacz `powiadom_o_progu_gry()` na `event_participants` — wzorem `079`, reaguje na PRZEKROCZENIE progu w obie strony, nie na każdy zapis | +| `098_admin_bez_rekurencji` | Funkcja `czy_admin()` (`SECURITY DEFINER`, `STABLE`) plus przepięcie na nią polityk „Admins can update any profile" (`022`) i „Admins can update any event" (`005`). Poprzednia wersja sprawdzała uprawnienie podzapytaniem o `profiles` WEWNĄTRZ polityki na `profiles` — podzapytanie samo podlegało RLS tej tabeli, więc warunek wychodził fałsz i UPDATE zmieniał ZERO wierszy, zwracając sukces. Objaw: przełącznik admin/użytkownik „nic nie robi", wraca po odświeżeniu | +| `099_zgloszenia_bledow` | Tabela `zgloszenia_bledow` (zgłoszenia od ludzi i automatyczne awarie w jednym miejscu) plus RPC `zapisz_zgloszenie_bledu()` — `SECURITY DEFINER`, JEDYNE wejście do zapisu, bo tabela nie ma polityki INSERT. Awarie grupowane po `odcisk` (`ON CONFLICT` dokłada do licznika zamiast tworzyć kopię). SELECT/UPDATE wyłącznie dla `czy_admin()` — w kolumnie `adres` bywa link do prywatnego meczu. Trzeci rodzaj `obiekt` (`field_id`) to zgłoszenie błędu w danych boiska: NIE zmienia danych, bo katalog pochodzi z OSM | + +**Powiadomienia mogą powstawać wyłącznie z wyzwalaczy albo z wąsko uprawnionych +funkcji RPC** (np. `zglos_brak_pelnej_nazwy`, `086`) — nigdy z gołego INSERT-a +z przeglądarki. Tabela `notifications` (`025`) ma polityki SELECT i UPDATE dla +własnych wierszy i **żadnej polityki INSERT** — przeglądarka nie zapisze +powiadomienia nawet sobie bez przejścia przez taką funkcję. Każda z nich to +`SECURITY DEFINER` z `SET search_path = public`, wzorowana na `065`. + +--- + +## Funkcje w bazie (RPC) + +| Funkcja | Rola | +|---|---| +| `get_nearby_events` | Mecze w promieniu (używa `haversine_km`) | +| `get_player_stats` | Statystyki gracza | +| `set_event_teams_published` | Publikacja składów. `SECURITY DEFINER` + `can_manage_squad()` od `090` (wcześniej `SECURITY INVOKER` z `organizer_id` wpisanym wprost w `WHERE`) | +| `generate_join_code` | Kod dołączenia do meczu | +| `add_group_creator_as_member` | Trigger — twórca grupy zostaje członkiem | +| `tournament_team_count`, `shared_availability_days`, `admin_team_contacts` | Turniej | +| `sync_reserve_claim` | Utrzymuje kolejkę ofert zwolnionego miejsca i powiadamia o ofercie (`SECURITY DEFINER`, `062`) | +| `zglos_brak_pelnej_nazwy` | Wołana z przeglądarki (`supabase.rpc()`) przez świeżo zalogowanego użytkownika bez pełnego imienia i nazwiska — wstawia powiadomienie `uzupelnij_profil`, chyba że już istnieje (`SECURITY DEFINER`, `086`) | +| `accept_team_proposal` | Przenosi propozycję składów na realne drużyny (`SECURITY DEFINER`) | +| `can_edit_event`, `can_manage_squad`, `can_manage_payments` | Organizator ORAZ delegat z odpowiednim uprawnieniem (`event_delegates`) — używane wewnątrz polityk RLS innych tabel, nie wołane bezpośrednio z przeglądarki (`SECURITY DEFINER`, `089`) | +| `event_set_payment_settings` | Zmienia `accepted_payment_methods`/`blik_phone` na `events` dla delegata z `can_manage_payments` bez `can_edit` — jedyna droga, bo ogólna polityka UPDATE na `events` go tam nie przepuszcza (`SECURITY DEFINER`, `090`) | +| `haversine_km` | Odległość geograficzna | +| `trigger_set_updated_at`, `trigger_set_expires_at` | Triggery czasowe | +| `utworz_termin_serii(szablon_id, data)` | Tworzy jeden termin serii, kopiując ustawienia z ostatniego terminu. Wołana przez `supabase.rpc()` (przycisk „Utwórz termin” na `/cykliczne/[id]`) i przez `utworz_nalezne_terminy_serii()` — **to samo wejście dla ręcznego i automatycznego tworzenia**, żeby oba dawały identyczny wynik (`073`, `SECURITY DEFINER`, kontrola „tylko organizator” w środku) | +| `utworz_nalezne_terminy_serii` | Pętla po aktywnych szablonach, woła `utworz_termin_serii` dla każdego terminu w zasięgu `notify_days_before`. Cel zadania `pg_cron` (`073`) — działa też wywołana ręcznie, gdy `pg_cron` nie jest włączony | +| `czy_zalozyciel_grupy`, `czy_czlonek_grupy`, `czy_moze_zarzadzac_grupa`, `czy_moze_tworzyc_wydarzenia_w_grupie`, `czy_moze_moderowac_tablice` | Pomocnicze do polityk RLS na `group_members`/`groups`/`group_posts` — `SECURITY DEFINER` unika nieskończonej rekurencji polityki, która sama odpytuje `group_members` (`092`) | +| `dolacz_do_grupy_kodem`, `dodaj_czlonka_do_grupy`, `odswiez_kod_grupy` | Jedyne drogi wejścia do `group_members` po zdjęciu polityki INSERT — kolejno: dołączenie kodem, dopisanie przez zarządzającego, rotacja kodu przez założyciela (`SECURITY DEFINER`, `094`) | +| `get_group_stats`, `get_group_leaderboard` | Statystyki grupy — pierwsza publiczna, druga wyłącznie dla członków, sama sprawdza członkostwo i odmawia wyjątkiem (`095`) | +| `zapytaj_milczacych` | Wołana z przeglądarki przez organizatora/delegata z `can_create_events` — wstawia powiadomienie `pytanie_o_udzial` dla członków ekipy bez wpisu w składzie ani odmowy, pomija zaczepionych w ciągu ostatnich 12 h. Wyłącznie dla meczów przypiętych do grupy (`SECURITY DEFINER`, `097`) | + +**`pg_cron` wymaga jednorazowego włączenia** (Supabase → Database → Extensions) +— migracja `073` sprawdza jego obecność i pomija harmonogram, jeśli go nie ma +(`RAISE NOTICE`, migracja się nie wywraca). Bez `pg_cron` terminy serii trzeba +tworzyć ręcznie z `/cykliczne/[id]`, albo uruchomić +`SELECT utworz_nalezne_terminy_serii();` z SQL Editora. + +--- + +## Konwencja nowych migracji + +Kolejny numer + krótka nazwa: `058_nazwa_zmiany.sql`. W nagłówku komentarz mówiący +**dlaczego** migracja powstała — nie co robi (to widać w SQL). + +Dodając kolumnę do tabeli, która ma politykę RLS na `UPDATE`, sprawdź, czy polityka +obejmuje nową kolumnę. + +--- + +## Osobna baza (dev / preview) + +Domyślnie preview na Vercelu korzysta z **produkcyjnej** bazy — wygodne, ale każdy test +zostawia ślad w prawdziwych danych. Żeby to rozdzielić, stawia się drugi projekt Supabase: + +**Projekt już istnieje — nie zakładać nowego.** `BojoDev` jest w tej samej organizacji, +dziś ze statusem `INACTIVE` (trzeba go wybudzić przy pierwszym użyciu). Poniższe kroki +(migracje, boiska, buckety, konta testowe, URL Configuration, zmienne na Vercelu) trzeba +i tak przejść — projekt istnieje jako powłoka, nie jako gotowe do użycia środowisko. + +1. **Wybudź `BojoDev`** albo, jeśli naprawdę potrzebny jest inny projekt, załóż nowy + w tej samej organizacji. Zapisz hasło do bazy. +2. **Migracje po kolei** — SQL Editor, od `001` do najnowszej. Kolejność ma znaczenie + (późniejsze zakładają wcześniejsze). Nie da się tego pominąć: nie ma migratora, + który zrobi to sam. +3. **Boiska** — `supabase/seed.sql` (5 sztuk, na szybko) albo `seed-orliki.sql` + (pełniejszy zestaw). Bez tego mapa i pickery będą puste. +4. **Buckety w Storage** — utwórz `covers` i `avatars`, oba **publiczne**. Kod ich nie + tworzy; przy braku okładki i awatary rzucą błędem przy uploadzie. +5. **Konta testowe** — `supabase/seed-test-users.sql`, potem `seed_test_data.sql`. + Konta organizatorów muszą istnieć: albo zaloguj się nimi raz w apce wskazującej + na tę bazę, albo dopisz je do skryptu z kontami. +6. **Auth → URL Configuration** — Site URL na adres preview, a w Redirect URLs wildcard + dla podglądów Vercela, inaczej logowanie odbije na złą domenę: + `https://-*-.vercel.app/**` +7. **Vercel → Settings → Environment Variables** — `NEXT_PUBLIC_SUPABASE_URL` + i `NEXT_PUBLIC_SUPABASE_ANON_KEY` z nowego projektu, zaznaczone **tylko dla Preview** + (Production zostawia stare). Po zmianie **przebuduj** preview — zmienne wchodzą + przy buildzie. + +Od tego momentu preview pisze do własnej bazy, a `bojo.pl` zostaje nietknięte. + +--- + +## Dane testowe + +| Plik | Zawartość | +|---|---| +| `supabase/seed_test_data.sql` | 25 wydarzeń pokrywających wszystkie kombinacje ustawień (w tym oferty z rezerwy i propozycje składów). Bezpieczny do wielokrotnego użycia — czyści po markerze `[TEST]` w opisie | +| `supabase/seed-test-users.sql` | Konta `test1..test10@example.com`, hasło `test1234` | + +Oba uruchamiane ręcznie w SQL Editor. diff --git a/docs/domena.md b/docs/domena.md new file mode 100644 index 00000000..fcf75f2f --- /dev/null +++ b/docs/domena.md @@ -0,0 +1,724 @@ +# Modele domenowe i granice architektury + +Rzeczy, które trzeba wiedzieć **przed** zmianą kodu — bo są nieoczywiste i już raz kogoś +ugryzły. Stan funkcji i flagi → [funkcje.md](./funkcje.md). Schemat bazy → +[baza-danych.md](./baza-danych.md). + +--- + +## Granice architektury + +Uzasadnienia, których grep nie pokaże. Stack i diagram są w [README.md](../README.md). + +**Nie ma własnego backendu.** Frontend rozmawia z Supabase bezpośrednio, autoryzacja +jest w całości w Row Level Security. Konsekwencja: nie da się „dodać endpointu" — nowa +operacja na danych to funkcja w `frontend/src/lib/` plus polityka RLS w migracji. Jeśli +operacja wymaga uprawnień, których użytkownik nie ma, właściwe narzędzie to funkcja +`SECURITY DEFINER` w bazie (RPC), nigdy obejście po stronie klienta. + +**Komponenty nie omijają `lib/`.** Zapytanie do Supabase w komponencie to błąd — reguły +domenowe (np. liczenie pojemności meczu) mają istnieć w jednej kopii i być testowalne. + +**Jedyny wyjątek: `app/api/geocode/`** — serwerowy proxy do Nominatim, bo przeglądarka +nie może ustawić nagłówka `User-Agent`, a Nominatim go wymaga. To nie jest zalążek +backendu; nie dokładać tam tras. + +**Mappery `toEvent` / `toField` to granica typów** (`lib/events.ts`, `lib/api.ts`): +jedyne miejsce, gdzie `snake_case` bazy spotyka `camelCase` aplikacji. Dziś rzutowanie +bez walidacji runtime — Zod jest na liście długu ([strategia.md §5](./strategia.md#5-dług-techniczny)). + +**Jedno środowisko (prod).** Każdy merge do master idzie na żywo. Domena kanoniczna: +`bojo.pl` (fallback w `layout.tsx`, `robots.ts`, `sitemap.ts` — nowe miejsca używają tej +samej wartości). Migracje uruchamia się ręcznie → [baza-danych.md](./baza-danych.md). + +**Pierwsze zadanie cykliczne w repo: `pg_cron`, migracja `073`.** Do tej pory wszystko +działo się z klienta albo z wyzwalacza SQL — nic nie odpalało się samo, bez niczyjej +wizyty. Auto-tworzenie terminów serii (patrz „Serie wydarzeń cyklicznych" niżej) wymaga +działania bez organizatora w pobliżu, bo RLS na `events` przepuszcza INSERT tylko jako +`auth.uid() = organizer_id`. `pg_cron` bywa niewłączony na danym projekcie Supabase — +migracja to sprawdza i pomija harmonogram zamiast się wywrócić, więc funkcja degraduje +się do ręcznego wywołania, nie przestaje istnieć. + +**Drobne moduły `lib/` bez własnej sekcji tutaj** — po co służą: `lib/legal.ts` (dane +usługodawcy dla `/prywatnosc` i `/regulamin`, jedno miejsce do uzupełnienia); +`lib/eventWizard.ts` (walidacja kroków kreatora meczu, w tym `validatePayments` — +numer BLIK i zniżka karty sportowej — wydzielona z `app/wydarzenia/nowe/page.tsx` +pod testy); `lib/eventDraft.ts` (szkic kreatora w `localStorage`, TTL 12 h — patrz +[funkcje.md](./funkcje.md#szkic-kreatora-meczu)); +`lib/profileName.ts` (nazwa, pod którą użytkownik pokazuje się innym: `displayName`, +`firstName`, `avatarUrl`, `nazwaZEmaila`, `brakNazwy`, `isPelneImie` — mieszkają tu, a nie +w `auth.tsx`, bo Vitest nie transformuje `.tsx` przy `jsx: preserve`, a to właśnie te +funkcje decydują, co zobaczy obcy człowiek na stronie meczu; `auth.tsx` je re-eksportuje, +więc importy `from '@/lib/auth'` działają bez zmian); +`lib/eventShare.ts` (`eventUrl` + `eventShareText` + `shareEvent` — jeden adres i jeden +tekst udostępnienia dla całej aplikacji, patrz +[funkcje.md](./funkcje.md#po-publikacji-mecz-gotowy--wyślij-link)); +`lib/eventSummary.ts` (`zbudujPodsumowanie` — wiersze karty „Tak zobaczą to gracze" +na ostatnim kroku kreatora, patrz +[funkcje.md](./funkcje.md#podsumowanie-przed-publikacją)); +`lib/inviteStatus.ts` (`inviteStatus`/`compareByInviteStatus` — status imiennego +zaproszenia na mecz: uczestnictwo bije wcześniejszą odmowę, czyli `dismissed_at` +sprawdza się dopiero, gdy zaproszonego nie ma w `event_participants`; wydzielone +z komponentu po tym, jak przegląd kodu złapał tu odwróconą kolejność, patrz +[funkcje.md](./funkcje.md#zaproszenia-na-mecz)); `lib/eventTitle.ts` (jedyne miejsce, +które liczy domyślną nazwę meczu, gdy tytuł jest pusty — `defaultEventTitle` / +`eventDisplayTitle`, zastąpiło pięć niezależnych kopii tej samej logiki); +`lib/adminLinks.ts` (lista tras panelu admina, współdzielona przez `AdminMenu` +w `Header.tsx` i sekcję „Panel administratora” na `/profil`); `lib/api.ts#hasManagedVenue` +(czy użytkownik zarządza obiektem — steruje „Moje obiekty” w headerze i na `/profil`); +`lib/eventFilters.ts` (filtrowanie, grupowanie i sortowanie listy `/wydarzenia` — +zakres dat, sekcje dzienne, kolejność po realnym starcie meczu; wydzielone z komponentu +pod testy, tak jak `eventWizard.ts`); `lib/plural.ts` (polska odmiana przez liczbę — +zastąpiła regułę `n < 5`, która myliła się na 12–14); `lib/searchText.ts` (`foldText` +składa polskie znaki, żeby „pilka" znajdowało „piłka"); `lib/geo.ts#distanceKm` +(odległość haversine, wspólna dla sortowania „najbliżej mnie" i wykrywania duplikatów +w panelu admina); `lib/groups.ts#setGroupCover` (zapis okładki grupy — jedyna mutacja +grupy, która wcześniej szła inline w JSX); +`lib/bottomNavVisibility.tsx` (kontekst chowający dolny panel nawigacji — patrz +[funkcje.md](./funkcje.md#dolny-panel-nawigacji-mobile)); `lib/useMyInvites.ts` +(zaproszenia na mecz, patrz [funkcje.md](./funkcje.md#zaproszenia-na-mecz)); +`lib/eventFilters.ts#filterByRadius` (filtr promienia na `/wydarzenia` — wiersz bez +policzonej odległości wypada, bo promień bez tego nic by nie znaczył); +`lib/eventFilters.ts#filterByMaxPrice`/`#filterByMinFreeSpots` (suwaki Cena/Wolne miejsca +w modalu filtrów, `/wydarzenia` i tryb gier na `/mapa` — patrz +[funkcje.md](./funkcje.md#układ-wydarzenia--filtry-sortowanie-sekcje-dzienne)); +`lib/eventFilters.ts#multiLabel`/`#toggleInArray` (etykieta dropdownu multi-select i +przełącznik wartości w tablicy — współdzielone przez sportowy dropdown na `/wydarzenia` +i `/mapa`); `DateFilter` w tym samym pliku ma dziś `'miesiac'` zamiast `'weekend'` +(`matchesDateFilter` liczy `isSameMonth()` z `date-fns`) — zestaw pozycji suwaka „Kiedy"; +`components/ui/RangeSlider.tsx` (jeden generyczny suwak — etykieta wartości nad nim, +opisy skrajów pod spodem — reużywany w czterech filtrach na `/wydarzenia` i w trybie gier +na `/mapa`, wzorowany na suwaku promienia w `AlertSetupDialog`); +`components/map/GamesMarkersLayer.tsx` (klastrowana warstwa pinezek meczów, +`L.markerClusterGroup`, dane wchodzą jako prop — bez własnego fetcha viewport-scoped, +bo zbiór publicznych wydarzeń jest już cały w pamięci; pinezka pojedynczego meczu ma +emoji sportu (`sportEmoji()`) i etykietę „kiedy" (`matchWhenLabel()` z `lib/eventDates.ts`, +bez godziny); ikona klastra reużywa `clusterDivIcon()` z `mapIcons.ts` — ten sam wygląd +co klastry boisk, zamiast domyślnej, nieostylowanej ikony Leafleta; kliknięcie mapy poza +pinezką (`map.on('click', …)`, marker nie propaguje własnego kliknięcia do mapy) zamyka +zaznaczenie — `onSelect` przyjmuje `null`; współdzielona przez widok mapy +w `/wydarzenia` (`components/map/GamesMapCanvas.tsx`, własny ``) i tryb +„Pokaż gry" w `VenueExplorer.tsx` (ten sam `` co boiska)); +`lib/eventFilters.ts#swipeEventId` (który mecz pokazać po swipe w panelu — ta sama +kolejność co pinezki, zawija się na końcach) razem z `lib/useSwipe.ts` (wykrywanie +poziomego gestu touchstart→touchend, próg 50px, ignoruje ruch bardziej pionowy niż +poziomy, żeby nie kolidować ze scrollem); `components/map/LocateMeButton.tsx` (przycisk +„pokaż moją okolicę", ikona `LocateFixed` — wcześniej `MapPin`, mylące dla tej akcji — +wspólny dla `/mapa` i widoku mapy w `/wydarzenia`, pozycja sterowana propem `className`, +bo kontekst pełnoekranowej mapy i mapy osadzonej w karcie mają różne bezpieczne +odstępy); `lib/sports.ts +#MAP_FILTER_SPORTS` (sporty jako filtr facylitów na mapie, szerszy niż `FOCUS_SPORTS` — +dokłada `wielofunkcyjne`/`piłka ręczna`, które mają pinezki na `/mapa`, ale nie były +wcześniej filtrowalne); `lib/api.ts#EXPLORER_COLS` (okrojone kolumny pobierane dla +pinezek `/mapa` — dołączono `surface`, żeby dało się po niej filtrować, patrz +[funkcje.md](./funkcje.md#układ-mapa--szukanie-filtry-powrót-z-boiska)); +`components/ui/FilterSheet.tsx` (modal filtrów w stylu Booking, wspólny dla +`/wydarzenia` i `/mapa` — portal do ``, bottom sheet na mobile); +`components/ui/FilterPill.tsx#PillDropdown` (rozwijana pigułka — Sortuj, Sport na obu +stronach) ma stałą szerokość panelu (`PANEL_WIDTH = 240`), żeby dało się policzyć +bezpieczną pozycję **przed** wyrenderowaniem: przycisk blisko prawej krawędzi ekranu +wcześniej wyrównywał panel do swojej lewej krawędzi, co wypychało kolumnę z ptaszkami +wyboru poza widoczny obszar — teraz panel dosuwa się do prawej krawędzi ekranu +z marginesem zamiast do lewej krawędzi przycisku; +`components/layout/MobileIdentityRow.tsx` (dzwonek + awatar w jednym wierszu, zastępuje +pasek `Header` tam, gdzie strona sama go chowa na mobile dla zalogowanych — patrz +[funkcje.md](./funkcje.md#górny-pasek-nawigacji--inny-dla-zalogowanych-na-mobile)). + +--- + +## Wydarzenie ↔ użytkownik: dwie niezależne osie + +Relacja użytkownika do meczu to **dwie osie, nie jedna etykieta** (`lib/events.ts:701`): + +```ts +interface MyEventRelation { + isOrganizer: boolean; // czyj to mecz — trwała cecha + status: MyEventStatus; // jaki mam udział — zmienny +} +``` + +```ts +type MyEventStatus = + | 'none' // brak relacji — domyślne „Dołącz" + | 'invited' // ktoś mnie zaprosił imiennie, czeka na odpowiedź + | 'pending' // poprosiłem o dołączenie, organizator jeszcze nie zaakceptował + | 'observing' // RSVP „maybe" — obserwuję, nie zajmuję miejsca, nie liczę się do statystyk + | 'reserve' // zapisany, czekam na zwolnienie miejsca + | 'playing'; // zapisany i trzymam miejsce +``` + +**Nie zwijać tego do jednej etykiety.** Można organizować mecz i w nim grać, albo +organizować bez grania — to dwa różne przypadki i UI musi je rozróżniać. + +`'invited'` pochodzi z tabeli `event_player_invites` (migracja `060`, `lib/playerInvites.ts`) +— `getMyParticipationMap()` (`lib/events.ts:936-945`) dopisuje ten status, gdy istnieje +nieodrzucone zaproszenie, a użytkownik nie ma jeszcze żadnego wiersza w +`event_participants` dla tego meczu. Odpowiedź na zaproszenie to zwykłe dołączenie / +obserwowanie na stronie meczu — nie ma osobnego „accept" w bazie. Gdzie to widać w UI → +[funkcje.md § Zaproszenia na mecz](./funkcje.md#zaproszenia-na-mecz). +Nie mylić z `event_invites` (migracja `036`, `lib/invites.ts`) — zaproszenia po e-mailu +z tokenem, martwy kod, nic go nie importuje. + +### Skąd bierze się status + +Prywatna `statusFromRow()` (`lib/events.ts:707`). Kolejność sprawdzeń ma znaczenie: + +```ts +if (row.pending_approval) return 'pending'; +if (row.rsvp === 'maybe') return 'observing'; +return row.is_reserve ? 'reserve' : 'playing'; +``` + +Czyli: oczekujący na akceptację jest `pending`, **nawet jeśli** ma `rsvp = 'maybe'`. + +--- + +## Delegowanie uprawnień organizatora + +`isOrganizer` powyżej to `organizer_id === userId` — **jeden** właściciel na cały cykl +życia meczu, bez wyjątków. Migracja `089`/`090` dokłada obok tego osobną, opcjonalną +warstwę: tabela `event_delegates` (`event_id, user_id, can_edit, can_manage_squad, +can_manage_payments`) pozwala organizatorowi, który sam nie gra albo dzieli się +obowiązkami, nadać zaufanej osobie część swoich praw — **bez zmiany `organizer_id`**. + +Kandydatem na delegata może zostać wyłącznie uczestnik meczu z kontem (nie gość) albo, +jeśli mecz jest przypięty do grupy, członek tej grupy — nigdy dowolny użytkownik Bojo. +Listą delegatów zarządza wyłącznie prawdziwy organizator (panel „Zarządzaj +wydarzeniem" → „Uprawnienia"), nie inny delegat, nawet z `can_edit` — inaczej +powstałby niekontrolowany łańcuch przekazywania. + +Trzy przełączniki, niezależne, `can_edit` jest nadzbiorem pozostałych dwóch: + +| Uprawnienie | Zakres | +|---|---| +| `can_edit` | Jak organizator: termin, miejsce, ustawienia, odwołanie meczu. Fizyczne usunięcie (`DELETE`) zostaje wyłącznie dla prawdziwego organizatora/admina | +| `can_manage_squad` | Dzieli drużyny, wpisuje wynik, dodaje/usuwa uczestników, akceptuje prośby o dołączenie, zaprasza gości, oznacza nieobecność | +| `can_manage_payments` | Oznacza kto zapłacił, zmienia zaakceptowane metody płatności i numer BLIK, wysyła rozliczenie | + +**Egzekwowane w RLS, nie tylko w UI** — trzy funkcje `SECURITY DEFINER` +(`can_edit_event()`, `can_manage_squad()`, `can_manage_payments()`, migracja `089`) +rozszerzają polityki na `events`, `event_participants`, `team_proposals`, +`match_results`, `player_goals`, `event_player_invites`, `player_reports` (`090`/`091`). +Wyjątek: metody płatności i BLIK na `events` NIE idą przez rozszerzenie ogólnej +polityki UPDATE (tabela ma ~30 kolumn niezwiązanych z płatnościami — delegat od +płatności dostałby dostęp do wszystkich) — zamiast tego dedykowana RPC +`event_set_payment_settings()`. + +**Świadome ograniczenie zakresu**: delegat działa wyłącznie ze strony +`/wydarzenia/[id]` — dashboard, listy „Moje mecze" i etykieta „organizator" w +historii gracza (`/gracz/[id]`) NIE uwzględniają delegacji, poza jednym wyjątkiem: +`getMyParticipatedEvents()` dolicza mecze, gdzie użytkownik jest delegatem z +`can_edit`, żeby taki mecz w ogóle pojawił się na jego dashboardzie, gdy sam nie gra. + +--- + +## Reguły pojemności + +Do limitu miejsc liczą się **wyłącznie** wiersze `event_participants` spełniające: + +``` +is_reserve = false AND pending_approval = false +``` + +Reguła jest **celowo zdublowana** w trzech funkcjach: `joinEvent`, `addGuest`, +`confirmFromMaybe`. Zmieniając jedną, sprawdź pozostałe — rozjazd między nimi oznacza, +że mecz przepełni się jedną ścieżką, a drugą nie. + +Konsekwencje: +- Przy `require_approval = true` dołączenie **nie zajmuje miejsca** do czasu akceptacji. +- „Obserwuję" (`rsvp = 'maybe'`) nie zajmuje miejsca. +- Limit bramkarzy (`max_goalkeepers`, domyślnie 2, tylko gdy `goalkeepers_enabled`) + spycha nadmiarowych na rezerwę. + +### Zwolnione miejsce: oferta, nie auto-awans + +Gdy ktoś się wypisze, rezerwowy **nie wskakuje automatycznie**. Miejsce zostaje +**zaproponowane** pierwszej osobie z rezerwy, która musi sama kliknąć **„Wchodzę"** +albo **„Odpuszczam"**. + +**Nikt nigdy nie trafia do składu po cichu** — to świadoma decyzja produktowa. +Nie zamieniać tego na automatyczny awans. + +Mechanika (migracja `058`): + +| Element | Gdzie | +|---|---| +| Okno na decyzję | `events.reserve_claim_hours` (1–72 h, domyślnie 3) | +| Aktywna oferta | `event_participants.claim_offered_at` | +| Przepuścił (odrzucił lub nie zdążył) | `event_participants.claim_passed` | +| Utrzymanie kolejki | funkcja `sync_reserve_claim(event_id)`, `SECURITY DEFINER` | + +Kolejka rusza się przy **wejściu na stronę meczu** — nie ma backendu ani crona, więc +`sync_reserve_claim` jest wołane z klienta (`syncReserveClaim` w `lib/events.ts`) i musi +być idempotentne. Funkcja wygasza przeterminowaną ofertę i przekazuje miejsce dalej. + +Od migracji `062` funkcja dopisuje też wpis do `notifications` w momencie ustawienia +oferty — bez tego rezerwowy dowiadywał się o zwolnionym miejscu wyłącznie wtedy, gdy +sam odświeżył stronę meczu, co w praktyce marnowało jego okno na decyzję. To wciąż +tylko powiadomienie w skrzynce w appce (`NotificationBell`), nie push/SMS/e-mail. + +Miejsce pod aktywną ofertą **liczy się jako zajęte** — ktoś z zewnątrz nie podbierze go +rezerwowemu w trakcie jego okna (`joinEvent` dolicza oferty do zajętości). + +Osoba, która przepuściła, **zostaje na liście** (organizator wciąż może ją awansować +ręcznie), ale nie blokuje kolejki. Goście bez konta są pomijani — nie mają jak kliknąć. + +--- + +## Czy gramy — próg minimum i jawna odmowa + +Migracja `097`. Odpowiada wprost na to, co organizatorzy ekip dziś liczą ręcznie +w wątku na WhatsAppie: „brakuje nam 1go? Dobrze liczę?", „10 to minimum żeby zagrać". + +**`events.min_players`** — ile graczy musi być w składzie, żeby gra się odbyła. +`NULL` (domyślnie) = organizator progu nie ustawił, zero zmiany zachowania dla +istniejących meczów. Liczone tą samą regułą co pojemność (`is_reserve = false AND +pending_approval = false`) — patrz „Reguły pojemności" wyżej. Czysta funkcja +`werdyktGry(event, liczbaWSkladzie)` (`lib/events.ts`) zwraca `'gramy'` / `'zagrozona'` +/ `'brak-progu'` + liczbę brakujących — jedno miejsce z regułą, więc panel na stronie +meczu i linijka na stronie grupy nigdy się nie rozjadą. + +**`event_declines` — jawne „nie gram", NIE nieobecność.** Osobna tabela, nie nowa +wartość `rsvp`: `rsvp` jest wpleciona w regułę pojemności zdublowaną w trzech funkcjach +(patrz wyżej) i w zapytania statystyk (`lib/players.ts` odfiltrowuje `rsvp <> 'maybe'`) +— nowa wartość wpadłaby tam jako uczestnik. `player_reports` z `report_type = +'nie_przyszedl'` (`091`) karmi statystykę „Niezawodność" wyłącznie ze zgłoszeń +nie-przyjścia na mecz, na który ktoś się zapisał; wcześniejsza, jawna odmowa jest +zachowaniem **dobrym** i nie ma z tamtą tabelą żadnego związku. RLS: widoczna dla +siebie, organizatora meczu i członków grupy (gdy mecz jest przypięty do grupy), zapis +wyłącznie za siebie (`auth.uid() = user_id`). + +**„Kto milczy" — usunięte.** Był tu panel dla organizatora meczu ekipy licząc różnicę +między członkami grupy a sumą `event_participants`/`event_declines`, z przyciskami do +zaczepienia milczących (RPC `zapytaj_milczacych()`, powiadomienie `pytanie_o_udzial`, i +gotowy tekst na WhatsAppa). Usunięty na wyraźną prośbę — prostszą odpowiedzią na „brakuje +ludzi" jest „Otwórz dla okolicy" niżej, nie ściganie własnej ekipy. `lib/eventResponses.ts` +i `tekstZaczepki()` skasowane jako martwy kod. RPC i typ powiadomienia **zostają w +bazie** (migracji `097` się nie kasuje po wdrożeniu) — po prostu nic już ich nie wywołuje. + +`event_declines` samo w sobie **nie znika** — karmi „Nie gram" (`NieGramButton.tsx`) +opisane wyżej, niezależnie od usuniętego panelu „kto milczy". + +**„Otwórz dla okolicy".** Gdy prywatnemu meczowi brakuje ludzi, organizator (albo +delegat z `can_create_events`) jednym kliknięciem zamienia go w publiczny — +`setVisibility(id, 'public')`, ta sama funkcja co ręczny przełącznik widoczności na +stronie meczu. Nie jest to nowe pojęcie w domenie: `docs/domena.md` już wcześniej +ustaliło, że przypisanie do grupy jest ortogonalne do widoczności (mecz ekipy bywa +publiczny), więc to po prostu istniejący przełącznik za jednym tapnięciem zamiast +w menu ustawień, z podpowiedzią liczby brakujących miejsc. + +**Świadomie NIE zbudowane:** automatyczne dopisywanie milczących do składu (łamałoby +„nikt nie trafia do składu po cichu" — patrz wyżej), powiadomienie o każdej odpowiedzi +(tylko organizator dostaje zbiorczy obraz przez panel, nie strumień zdarzeń), +`min_players` na poziomie serii cyklicznej (jak reszta ustawień specyficznych dla +terminu, dziedziczy się z ostatniego terminu serii — patrz „Serie wydarzeń +cyklicznych" niżej — nie z szablonu). + +--- + +## Self-service zapis gościa bez konta + +Niezalogowany gracz może zapisać się na mecz bez zakładania konta, podając imię i e-mail. +Tworzy to wpis gościa (`is_guest = true`, `user_id = NULL`, `guest_email = ...`) z losowym +`claim_token` wygenerowanym triggerem `nadaj_token_gosciowi()` (migracja `066`). + +**Reguły pojemności** — gość liczy się normalnie do limitu miejsc (`is_reserve = false AND pending_approval = false`), +wyląduje na rezerwie jeśli mecz pełny, identycznie jak zalogowany gracz. + +**Przejęcie wpisu** — zaraz po zapisie ekran zachęty (`EventDetailClient.tsx`) oferuje +dwie ścieżki, obie bez ponownego wpisywania imienia/maila i bez dodatkowego kliku +potwierdzenia: +- **Hasło** — `handleCreateAccountFromGuest()` woła `signUpWithEmail()` (imię i e-mail + z formularza zapisu), a gdy sesja jest od razu aktywna (bez wymogu potwierdzenia + e-maila), od razu też `przejmij_wpis_goscia()` — user ląduje wprost na stronie meczu. +- **Google** — `signInWithGoogle()` z `next=/gracz/przejmij/[token]?auto=1`. + +Parametr `?auto=1` na `/gracz/przejmij/[token]` (`PrzejmijClient.tsx`) każe stronie +przejąć wpis automatycznie, gdy user jest już zalogowany — zamiast czekać na klik +„To ja — potwierdzam". Bez `?auto=1` (np. link wysłany SMS-em przez organizatora) +strona zachowuje się jak dotąd — wymaga świadomego potwierdzenia tożsamości. + +Gdy Supabase wymaga potwierdzenia e-maila (`needsConfirmation = true`), przejęcie nie +może nastąpić od razu — `auth.uid()` jeszcze nie istnieje. User zostaje na stronie +meczu (wpis gościa już tam jest, widoczny bez konta), a link w mailu potwierdzającym +(niesie ten sam `?auto=1`) dokańcza przejęcie po kliknięciu. + +**Gdy podany e-mail ma już konto** — `signUpWithEmail()` rzuca błąd „już istnieje" +(`mapAuthError()` w `lib/auth.tsx`). Rzuca go w dwóch przypadkach: klasyczny błąd +Supabase „already registered", oraz — gdy w projekcie włączona jest ochrona przed +enumeracją e-maili (ustawienie w Dashboardzie, `signUp()` wtedy nie rzuca błędu, tylko +zwraca fałszywy sukces z pustą tablicą `identities`) — wykryte po `data.user.identities +.length === 0` (migracja `085`, ta sama zmiana naprawia to samo w zwykłej rejestracji +przez `/logowanie`). Ekran zachęty przełącza wtedy to samo pole hasła z rejestracji na +logowanie (`handleSignInFromGuest()`): po udanym `signInWithEmail()` przejęcie następuje +od razu, tak samo jak przy rejestracji. + +**Ten sam e-mail nie może zapisać się dwa razy na ten sam mecz** — `dolacz_do_meczu_ +jako_goscie()` (migracja `085`) sprawdza to na starcie, przed liczeniem pojemności, żeby +odrzucone żądanie nie ruszało kolejki rezerwowych. Od migracji `088` pilnuje tego również +unikalny indeks `idx_participants_unique_guest_email` na `(event_id, lower(guest_email))` +— sprawdzenie w funkcji obsługuje przypadek po ludzku, indeks zamyka wyścig dwóch +równoległych zapisów. Ta sama migracja skasowała duplikaty, które zdążyły powstać przed +`085`, bo bez tego indeks nie miał prawa się założyć. + +**Wynik zapisu gościa ma cztery warianty, nie dwa.** Migracja `088` przestawiła RPC +z „wyjątek jako komunikat" na strukturalny wynik: `claim_token`, `already_joined` +(powtórka, nie świeży wiersz) i `has_account` (ten e-mail ma konto w Bojo — pytanie +GLOBALNE, nie „czy jest w tym meczu"). Wyjątki zostały wyłącznie dla realnych błędów +(walidacja, brak meczu, mecz odwołany). + +| `claim_token` | `already_joined` | `has_account` | co widzi gość | +|---|---|---|---| +| nowy | `false` | `false` | „Świetnie! Jesteś w składzie." / „Zapisano! Jesteś na liście rezerwowej." + zachęta do konta | +| nowy | `false` | `true` | ten sam nagłówek, ale ekran skrócony do logowania | +| istniejący | `true` | `false` | „Wcześniej dołączyłeś do tej gry." + zachęta do konta | +| istniejący | `true` | `true` | „Wcześniej dołączyłeś do tej gry." + ekran skrócony do logowania | +| `NULL` | `true` | — | osobny ekran: sam przycisk „Zaloguj się" i „Pomiń i zobacz skład bez logowania" | + +Pusty `claim_token` to sygnał **„wpis ma już właściciela, nie ma czego przejmować"** — +wiersz przejęty przez konto albo konto uczestniczące w meczu przez zwykłe, zalogowane +dołączenie. `handleJoinAsGuest()` w `EventDetailClient.tsx` wybiera ekran po KSZTAŁCIE +wyniku; wcześniej rozpoznawał tę sytuację po treści wyjątku (`msg.includes('już zapisany +na ten mecz')`), co było kruche — ten sam tekst rzucają `066` i `078` dla ścieżki +zalogowanej, a zmiana copy w SQL po cichu psuła UI. Dopasowanie po treści zostało jako +furtka zgodności na czas, zanim `088` zostanie wgrana ręcznie. + +Ekran skrócony do logowania (`has_account = true`) nie pokazuje listy trzech korzyści ani +linku „potwierdź tutaj", a to samo pole hasła startuje od razu w trybie logowania +(`accountEmailTaken` ustawiane z `has_account`, nie dopiero po nieudanej rejestracji). + +`has_account` jest oracle'em na istnienie konta dla niezalogowanego — koszt świadomy. +Sygnał wraca dopiero PO zapisie, każda próba zostawia widoczny wiersz uczestnika i odpala +powiadomienie z `084` do właściciela konta. Ten sam sygnał i tak wyciekał wcześniej, +ciszej, przez nieudane `signUpWithEmail()`. + +**Gdy gość zamknie ekran bez logowania (albo w ogóle nie doszedł do tego kroku)** — +wpis zostaje jako gość, ale migracja `084` po cichu kojarzy go z kontem po e-mailu +i wysyła powiadomienie (typ `niepotwierdzony_wpis_goscia`, dzwonek w `Header`) z +gotowym linkiem `/gracz/przejmij/[token]`. Dwa wyzwalacze pokrywają obie kolejności: +- `event_participants` → `auth.users`: nowy wpis gościa, e-mail pasuje do JUŻ + istniejącego konta — powiadomienie trafia od razu. +- `auth.users` → `event_participants`: nowe konto, e-mail pasuje do wcześniej + zapisanych nieprzejętych wpisów gościa — powiadomienie(a) trafiają po rejestracji. + +Świadomie **bez automatycznego przejęcia** — sam SQL nigdy nie ustawia `user_id` bez +`auth.uid()`. Inaczej ktokolwiek wpisujący cudzy e-mail w formularzu gościa mógłby +podpiąć dowolny mecz pod nieswoje konto bez zgody jego właściciela. Powiadomienie +tylko *proponuje* — klik w link i świadome potwierdzenie na `/gracz/przejmij/[token]` +(albo `?auto=1`, patrz wyżej) nadal robią całą pracę. + +**Rate limiting** — brak je na MVP. Jeśli spam, dodać captchę (reCAPTCHA v3) do formularza, +albo edge function do pilnowania po e-mailu (wymaga dostępu do IP). + +Migracje: `082_guest_self_signup.sql`, `083_fix_guest_signup_claim_token.sql` (naprawia +„ambiguous column reference" w `RETURNING`), `084_powiadomienie_o_koncie_z_wpisem_goscia.sql`, +`085_zapobiegaj_duplikatom_wpisu_goscia.sql`, `087_juz_dolaczony_flaga.sql` (kolumna +`already_joined` w wyniku RPC), `088_konto_i_zamek_na_duplikaty.sql` (kolumna +`has_account`, wynik zamiast wyjątku, unikalny indeks na `(event_id, lower(guest_email))`). + +--- + +## Propozycje składów + +Uczestnik może zaproponować podział na drużyny, reszta go popiera (👍), a organizator +przenosi wybraną propozycję na realne składy (migracja `059`). + +**Propozycja niczego nie zmienia w składzie.** Dopóki organizator jej nie zatwierdzi, +`event_participants.team` pozostaje nietknięte — propozycja żyje w osobnych tabelach +(`team_proposals`, `team_proposal_picks`, `team_proposal_votes`). + +Podział ról: + +| | Organizator | Uczestnik | +|---|---|---| +| Panel realnych składów (`TeamsPanel`) | ✅ ustawia wprost | ❌ | +| „Zaproponuj składy" | ❌ — nie musi, ustawia sam | ✅ dopóki składy nieopublikowane | +| Poparcie propozycji (👍) | ✅ | ✅ | +| „Zatwierdź" | ✅ wyłącznie on | ❌ | +| Usunięcie propozycji | ✅ każdą (moderacja) | ✅ tylko własną | + +Zatwierdzenie idzie przez `accept_team_proposal(proposal_id)` (`SECURITY DEFINER`, +sprawdza organizatora w środku): czyści poprzedni podział i wpisuje przypisania +z propozycji, żeby zatwierdzony układ był pełnym obrazem, a nie nakładką. + +Jedna aktywna propozycja na osobę i mecz — kolejna zastępuje poprzednią, żeby lista +nie zapełniła się wariantami tego samego autora. + +--- + +## RSVP „Obserwuję" + +W bazie to `rsvp = 'maybe'` (`049_participant_rsvp.sql`). Znaczenie: + +- nie zajmuje miejsca w składzie, +- nie liczy się do statystyk gracza (`055_stats_exclude_observing.sql`), +- nie trafia do historii meczów. + +Przejście z obserwowania w granie: `confirmFromMaybe()` — i dopiero ono sprawdza +pojemność. Funkcja przyjmuje te same decyzje co zwykłe dołączanie: rolę +(`asGoalkeeper`, z osobnym limitem bramkarzy) i sposób płatności (`payment_method`, +`has_sports_card`, `sports_card_provider`). Bez tego obserwujący, który klikał +„Dołącz", trafiał do składu jako gracz w polu i bez deklaracji płatności — +z pominięciem pytań, które dostaje każdy inny uczestnik. + +### Kolumny skasowane migracją `064` — nie wstawiaj ich z powrotem + +`064_usun_statusy_uczestnika.sql` usunęła z `event_participants` kolumny `status` +i `confirmed_at`. Kod jeszcze przez chwilę je wstawiał, przez co PostgREST odrzucał +**każdy** insert (`PGRST204`): organizator nie trafiał do własnego składu, „Dołącz" +i „Dopisz osobę bez konta" nie działały. Awaria była cicha, bo insert organizatora +w `createEvent` ignorował `error`. + +Strażnikiem jest `__tests__/eventsSchema.test.ts` — czyta źródło `lib/events.ts` +i przewraca się, gdy któryś insert do `event_participants` znów ustawi skasowaną +kolumnę. TypeScript tego nie złapie: obiekt insertu nie jest typowany schematem bazy. + +--- + +## Płatności + +`lib/payments.ts`. Organizator wybiera akceptowane metody (`blik`, `gotowka`, `inne`) +i akceptowane karty sportowe (`multisport`, `fitprofit`, `medicover`, `inne`). + +**Kwota zniżki jest opcjonalna i to jest istotne semantycznie:** + +| `sports_card_discount_grosz` | Znaczenie | +|---|---| +| liczba | Zniżka o tę kwotę | +| `null` | „Zniżka jest, ale zapytaj organizatora" — **nie** „brak zniżki" | + +Powód: zniżki z kart w realnym świecie są zbyt różne (procent, dzienne limity, zależność +od obiektu), żeby wymuszać jedną liczbę. + +Przy pierwszym wpisaniu kosztu większego od zera kreator zaznacza **Gotówkę**, jeśli żadna +metoda nie jest jeszcze wybrana (jednorazowo — świadome odznaczenie wszystkiego zostaje). +Powód: `validatePayments()` nie wymaga ani jednej metody, więc dało się opublikować mecz +z ceną i bez informacji, jak ją uregulować. Pusty zestaw daje ostrzeżenie, nie blokadę — +płatność można ustalić poza aplikacją. + +**Zawsze licz cenę przez `priceForParticipant()`** — nigdy nie odejmuj ręcznie. Funkcja +zwraca trzy pola i wszystkie trzy trzeba obsłużyć w UI: + +```ts +{ + priceGrosze: number, // ile zapłacić + discountApplied: boolean, // zniżka policzona + discountUnspecified: boolean, // ma kartę, ale kwota nieznana → pokaż „zapytaj organizatora" +} +``` + +`sportsCardLabel()` podstawia własną nazwę karty organizatora zamiast generycznego +„Inna karta”. + +⚠️ **Pułapka nazw:** kolumny to `cost_grosz` i `sports_card_discount_grosz` (bez „e"), +pola TS to `costGrosze` i `sportsCardDiscountGrosze`. + +**Numer do BLIKA — kto go widzi.** `canSeeBlikPhone()` (`lib/payments.ts`): organizator +widzi go zawsze, uczestnik ze składu dopiero `BLIK_PHONE_REVEAL_MINUTES` (60) przed +startem meczu — nagłówek strony meczu jest publiczny i indeksowalny, więc numer +prywatnego telefonu nie wystawia się komukolwiek od razu. **Jeden świadomy wyjątek:** +okno „Dołączam” z wyborem metody BLIK pokazuje numer natychmiast, niezależnie od czasu +do meczu — bez niego nie da się zapłacić przy zapisie. `minutesUntilStart()` +(`lib/eventDates.ts`) liczy dystans czasowy; ujemna wartość (mecz już trwa) też +odsłania numer. + +⚠️ **To bramka wyłącznie w interfejsie.** Kolumna `events.blik_phone` przyjeżdża +w całym wierszu `events` (RLS jest wierszowe, `toEvent` robi `select('*')`), więc numer +da się odczytać z ruchu sieciowego, mimo że UI go nie renderuje. Twarde odcięcie +wymaga widoku albo uprawnień kolumnowych — zadanie w [BACKLOG.md](../BACKLOG.md). + +Format wpisywania: `formatBlikPhone()` przycina do 9 cyfr polskiego numeru i grupuje +je 3-3-3 w trakcie pisania (obcina też prefiks `+48`/`48`, gdy zostaje sensowna +długość). `validatePayments()` (`lib/eventWizard.ts`) blokuje publikację/zapis meczu, +gdy: wybrano BLIK, a numer ma inną liczbę cyfr niż 9, albo zniżka karty jest wyższa +niż koszt od osoby. Reguły działają tylko dla płatnego meczu (`costPln > 0`) — darmowy +mecz nie ma żadnych ograniczeń płatności do sprawdzenia. + +**Agregaty do badge'a rozliczenia na liście i w nagłówku meczu.** +`EventItem.unpaidCount` (liczone w `toEvent()` z już pobranego, zagnieżdżonego +`event_participants(..., has_paid)` — zero nowego zapytania) i +`MyEventRelation.hasPaid` (własny wiersz uczestnictwa w `getMyParticipatedEvents()`) +zasilają badge „Rozliczono"/„X nie zapłaciło" (organizator) i „Zapłacono"/„Zapłać" +(gracz) na `EventBrowseCard` i w nagłówku `EventDetailClient.tsx` — widoczne tylko +dla wydarzeń, które już się rozpoczęły (`eventStarted`); wcześniej w tym samym miejscu +jest cena. + +--- + +## Grupy + +`lib/groups.ts`. Stała ekipa: sport, miasto, okładka, członkowie, mecze grupy, tablica +(`lib/groupPosts.ts`), statystyki (`lib/groupStats.ts`). Dołączanie wyłącznie przez kod +zaproszenia — `/g/[kod]` albo pole „Masz kod?" na `/grupy` (migracja `094`; znajomość +samego UUID grupy dziś **nie wystarcza**, patrz niżej). + +**`events.group_id` steruje listowaniem, i — dla prywatnych meczów grupy — dostępem.** +Przypisanie meczu do grupy sprawia, że pojawia się on na liście meczów grupy +(`getEventsByGroup()`) i, jeśli jest prywatny, na dashboardzie każdego członka +(`getMyGroupEvents()`) — mimo że `events.visibility` samo w sobie mówi tylko +`private`/`public` i nie ma osobnej trzeciej wartości „widoczne dla grupy". To jest +**świadomie ustalone zachowanie**, nie luka: prywatny mecz przypięty do grupy jest +zawsze widoczny dla jej członków, tak samo jak dla organizatora. Kreator meczu i strona +meczu mówią to wprost pod kartą widoczności (`opisWidocznosciWGrupie()` w +`lib/eventFeatures.ts`) — inaczej „Prywatne" wygląda jak obietnica bez pokrycia. +Nadal nie ma **prawdziwego** trzeciego poziomu w `events.visibility` (CHECK zostaje +dwuwartościowy) i nadal nie zaostrzono ogólnej polityki `Events readable by all` +(`USING (true)`) — `getMyGroupEvents()` w dalszym ciągu działa dzięki tej luźnej +polityce, a jej domknięcie bez równoczesnej przebudowy funkcji po cichu urwałoby mecze +grupowe z list. To osobne zadanie, patrz `BACKLOG.md §5`. + +--- + +## Uprawnienia w grupie + +Założyciel (`groups.created_by`) ma zawsze pełną władzę i nie da się go zdegradować — +to jedyna rzecz, którą definiuje sama kolumna, nie da się jej odebrać żadnym zapisem. +Migracja `092` (rozszerzona o `096`) dokłada obok tego cztery niezależne przełączniki na +`group_members`, wzorem [Delegowania uprawnień organizatora](#delegowanie-uprawnień-organizatora) wyżej: + +| Uprawnienie | Zakres | Domyślnie | +|---|---|---| +| `can_manage_members` | Dodaje i usuwa graczy z grupy, zmienia/odświeża kod zaproszenia | `false` | +| `can_create_events` | Zakłada mecze przypięte do tej grupy | `true` — każdy członek to dziś robi, flaga istnieje po to, żeby dało się to odebrać | +| `can_invite` | Widzi przycisk „Zaproś" i kod dołączenia (`096`) | `true` — z tego samego powodu co `can_create_events`: dziś każdy członek to widzi bez żadnej bramki | +| `can_moderate_wall` | Kasuje cudze wpisy w rozmowie i przypina ważne | `false` | + +**`can_invite` a `can_manage_members` — dwa różne poziomy.** `can_invite` steruje +wyłącznie WIDOCZNOŚCIĄ przycisku „Zaproś" i kodu dołączenia w UI — RPC +`dolacz_do_grupy_kodem()` (`094`) nie sprawdzała i nadal nie sprawdza uprawnień osoby, +która podała kod, więc to nie jest nowa granica bezpieczeństwa. Rotacja kodu +(`odswiez_kod_grupy()`, unieważnia stary link) zostaje przy `can_manage_members` — to +cięższa akcja, bo dotyka wszystkich, nie tylko widoczności jednego przycisku. + +**Kolumna `role` (`'admin'`/`'member'`) zostaje jako etykieta, ale przestaje być +źródłem prawdy.** Trigger `ustaw_role_czlonka()` wylicza ją z trzech przełączników przy +każdym zapisie i nadpisuje to, co przyszło z klienta — dzięki temu plakietka +„Założyciel"/„Współorganizator" na liście składu działa bez zmian, a rozjazd między +dwoma niezależnymi zapisami tej samej informacji jest fizycznie niemożliwy. + +**Uprawnieniami zarządza wyłącznie założyciel** — nie inny współorganizator, nawet +z `can_manage_members`. Ten sam powód co przy delegatach meczu: inaczej powstaje +niekontrolowany łańcuch przekazywania. `can_manage_members` pozwala dodać i usunąć +CZŁONKA, nie nadać komuś praw — dlatego panel „Uprawnienia" w ustawieniach grupy +(`/grupy/[id]/edytuj`) jest widoczny tylko założycielowi, mimo że sama strona ustawień +jest dostępna też dla `can_manage_members`. + +Egzekwowane w RLS: pięć funkcji `SECURITY DEFINER` (`czy_zalozyciel_grupy()`, +`czy_czlonek_grupy()`, `czy_moze_zarzadzac_grupa()`, +`czy_moze_tworzyc_wydarzenia_w_grupie()`, `czy_moze_moderowac_tablice()`) rozszerzają +polityki na `group_members`, `groups`, `group_posts` i wyzwalacz na `events.group_id`. +Przypięcie meczu do grupy bez `can_create_events` kończy się wyjątkiem z bazy +(wyzwalacz, nie polityka RLS — `WITH CHECK` przy `UPDATE` nie widzi wiersza sprzed +zmiany, więc zablokowałby też zwykłą edycję terminu przez kogoś, kto międzyczasie +wyszedł z grupy). + +**Zaproszenia noszą nadawcę.** `group_members.invited_by` (migracja `094`) zapisuje, +kto kogo przyprowadził — RPC `dolacz_do_grupy_kodem(kod, od)` weryfikuje w bazie, że +`od` naprawdę jest członkiem grupy, zanim to zapisze (parametr z adresu URL da się +podrobić; najgorszy skutek to błędne imię na ekranie zaproszenia, nie fałszywe +uprawnienie). Link zaproszenia da się unieważnić (`odswiez_kod_grupy()`, wyłącznie +założyciel) — stary kod przestaje działać natychmiast, kto już jest w grupie, zostaje. + +--- + +## Rozmowa grupy + +`group_posts` (migracja `093`) — płaska lista wpisów, bez wątków i bez załączników, +zamknięta dla nie-członków (w odróżnieniu od `groups`, które jest publicznie +czytelne — strona grupy jest celem linku zaproszenia i musi się wyrenderować bez +konta). W interfejsie ta zakładka nazywa się „Rozmowa" (dawniej „Tablica") i wygląda +jak dymki czatu (`RozmowaGrupy.tsx`) — mechanika bazy danych i nazwy kolumn zostają bez +zmian, zmienił się tylko produkt, nie schemat. Jeden wpis może być przypięty +(`pinned_at`) — to jedyna rzecz w rozmowie, która wysyła powiadomienie do całej ekipy +(typ `ogloszenie_w_grupie`, kolumna `notifications.group_id`), żeby dzwonek nie zamienił +się w kanał czatu. Przypiąć własny wpis może każdy (RLS jest wierszowe), ale +powiadomienie poleci tylko wtedy, gdy przypina ktoś z `can_moderate_wall` — pilnuje tego +wyzwalacz w bazie, nie UI. + +--- + +## Serie wydarzeń cyklicznych + +`lib/recurring.ts`, `lib/series.ts`. Od migracji `073` (`events.recurring_event_id → +recurring_events.id`) termin cykliczny jest prawdziwą serią, nie niezależną kopią. + +**Podział ról — dlaczego dwie tabele, nie jedna z flagą.** Duplikowanie całego schematu +`events` w `recurring_events` byłoby jednym źródłem prawdy o dwie kolumny za dużo: + +- **szablon** (`recurring_events`) jest właścicielem WYŁĄCZNIE reguły powtarzania: dzień + tygodnia, godzina, miejsce, limit miejsc, widoczność, wyprzedzenie + (`notify_days_before`). Edycja szablonu (`/cykliczne/[id]/edytuj`) zmienia tylko te pola. +- **ostatni termin serii** (`events` z najpóźniejszym `event_date` przy danym + `recurring_event_id`) jest żywym wzorcem WSZYSTKIEGO INNEGO: ceny, metod płatności, + bramkarzy, akceptacji zapisów, grupy. Nowy termin — ręczny czy automatyczny — dziedziczy + stamtąd, nie z szablonu. + +Konsekwencja, którą łatwo przeoczyć: **szablon sam w sobie nigdy nie mówi, ile kosztuje +gierka.** Pierwszy termin serii (bez poprzednika) startuje z domyślnych `createEvent()` — +darmowy, bez metod płatności. Cena wchodzi do serii dopiero, gdy ktoś ją ustawi NA +TERMINIE i wybierze zakres „to i przyszłe"/„cała seria" (patrz niżej) — to wtedy trafia +też do organizatora patrzącego tylko na `/cykliczne/[id]`, który sam z siebie ceny nie +pokazuje (bo jej nie ma — to własność terminu, nie szablonu). + +**Auto-tworzenie.** Funkcja SQL `utworz_nalezne_terminy_serii()` (migracja `073`, +`SECURITY DEFINER`) sprawdza co godzinę (`pg_cron`, jeśli włączony w Supabase) każdy +aktywny szablon: liczy najbliższe wystąpienie `day_of_week`, i jeśli mieści się +w `notify_days_before` i jeszcze nie istnieje — tworzy je przez `utworz_termin_serii()`. +To samo RPC woła `spawnEventInstance()` z przeglądarki (przycisk „Utwórz termin" na +`/cykliczne/[id]`) — ręczne i automatyczne tworzenie idą jedną ścieżką, więc nie mogą się +rozjechać. Bez `pg_cron` seria żyje wyłącznie z ręcznych kliknięć — degradacja, +nie awaria. + +**`event_date` nigdy nie jest własnością serii.** Nawet przy zbiorczej edycji (zakres „to +i przyszłe" / „cała seria" — `components/events/ZakresEdycjiSerii.tsx`, +`lib/series.ts#terminyWZakresie`) data zmienia się wyłącznie na edytowanym terminie. +Wspólna data absolutna dla wielu terminów jest sprzeczna sama w sobie; przesunięcie całej +gierki na inny dzień tygodnia to zmiana REGUŁY (szablon), nie zbiorcza zmiana dat. + +**„Przyszłe" liczy się po dacie terminu, nie po kolejności wstawiania** — terminy można +dopisać ręcznie poza kolejnością (dowolna data na `/cykliczne/[id]`), więc pozycja w tabeli +nic nie mówi o tym, czy mecz jeszcze się nie odbył. + +--- + +## Wyniki i statystyki + +`match_results` + `player_goals` (gole i asysty per gracz) + RPC `get_player_stats`. +`MatchResultData` obsługuje trzy kształty wyniku: bramki, sety siatkarskie, statystyki +koszykarskie. + +⚠️ **`player_goals` jest martwym duplikatem** — `EventDetailClient.tsx` z niej czyta +(fallback `initialGoals` dla `MatchResultForm`), ale nic dziś do niej nie zapisuje. +Jedyne aktywnie zapisywane źródło goli/asyst jest `match_results.result_data.scorers` +(`type: 'goals'`) — stamtąd liczy się też gol przy nazwisku w składzie (`golyMap` +w `EventDetailClient.tsx`). + +**Suma goli/asyst u strzelców nie może przekroczyć wyniku końcowego.** +`MatchResultForm` (`family === 'goals'`) blokuje zapis, gdy `Σ scorers.goals > +scoreA + scoreB` albo `Σ scorers.assists > scoreA + scoreB` — górna granica asyst to +łączna liczba goli w meczu, bo strzelcy nie mają przypisania do drużyny (walidacja +per-drużyna nie jest możliwa przy obecnym modelu danych). + +**Nazwy drużyn: A/B w bazie, „Niebiescy"/„Czerwoni" + N/C w UI** — `lib/teamLabels.ts` +jest jedynym źródłem etykiet, używanym identycznie w składzie (`TeamsPanel`, +`PublishedTeamsCard`) i w wyniku (`MatchResultForm`). Dane w `event_participants.team` +i `match_results.score_a/score_b` zostają literami. + +Statystyki **pomijają** uczestników ze statusem `observing`. + +### Reputacja — dwa niezależne mechanizmy, nie jeden + +**Publiczny profil gracza (`/gracz/[id]`)** — plakietka „Niezawodny" (`eventsJoined >= 5 +&& noShows === 0`) i pasek frekwencji liczą się z `get_player_stats()` (RPC), która +agreguje `no_shows` z tabeli `player_reports` (`report_type = 'nie_przyszedl'`, +migracja `011`). Zapis do tej tabeli robi organizator/delegat z `can_manage_squad` +(`089`/`090`) w modalu „Kto nie przyszedł" na stronie meczu, po `resultsAvailable` +(`lib/attendance.ts`, `PoMeczuCard.tsx`) — świadomie osobny, dedykowany modal, nie +kontrolka w głównym widoku składu. Unikalny indeks +`(event_id, reported_participant_id, report_type)` (`091`) chroni przed podwójnym +zawyżeniem licznika przy powtórnym kliknięciu. + +**`reliabilityPct()` (`lib/eventFeatures.ts`) to INNY, niezależny mechanizm** — liczy +frekwencję z tabeli `player_stats`, per seria cykliczna (`getGroupPlayerStats`), nie +per profil publiczny. Nie mylić obu — patrzą na różne tabele i różne konteksty +(mecz pojedynczy vs seria). diff --git a/docs/funkcje.md b/docs/funkcje.md new file mode 100644 index 00000000..e521e789 --- /dev/null +++ b/docs/funkcje.md @@ -0,0 +1,1595 @@ +# Inwentarz funkcji + +Co aplikacja potrafi, gdzie to leży i **czy użytkownik to widzi**. Status wobec wizji → +[wizja.md](./wizja.md#2-status-implementacji). + +--- + +## Flagi funkcji + +**Najczęstsze źródło pomyłki w tym repo: „funkcja nie działa" — a ona działa, tylko jest +schowana.** Zanim uznasz coś za niezbudowane, sprawdź tę tabelę. + +| Flaga | Wartość | Co chowa | Gdzie warunkuje | +|---|---|---|---| +| `SHOW_CUP` | `false` | Turniej / BOJO Cup | `Header.tsx`, `AnnouncementBar.tsx` | +| `SHOW_GAME_ALERTS` | `false` | „Ustaw alert" o grach w okolicy | `components/home/dashboard/DashboardSections.tsx` (sekcja „Otwarte mecze" na dashboardzie zalogowanego) | +| `SHOW_SMS_FEATURES` | `false` | Potwierdzenia SMS i przypomnienia | `app/wydarzenia/[id]/edytuj/page.tsx` | +| `SHOW_RECURRING` | `false` | Gry cykliczne / stałe gierki (wyłączona ponownie 2026-08-16, produktowa decyzja — kod i istniejące serie zostają) | `Header.tsx`, `SiteFooter.tsx`, `app/moje-gry/page.tsx` (link „Stałe gierki" i sekcja „Kolejne stałe gierki"), `app/wydarzenia/nowe/page.tsx` (kafelek „Wydarzenie cykliczne") | +| `FEATURE_RESERVATIONS` | z env `NEXT_PUBLIC_FEATURE_RESERVATIONS` | Rezerwacje obiektów | `LeafletMapImpl.tsx`, `app/admin/[fieldId]/page.tsx` | + +Cztery pierwsze: `frontend/src/lib/features.ts` (stałe w kodzie). +Piąta: `frontend/src/config/features.ts` (zmienna środowiskowa). + +**Rezerwacje mają drugą furtkę per obiekt:** `showBookingForField()` zwraca `true`, jeśli +flaga globalna jest włączona **albo** dany obiekt ma `fields.booking_enabled = true`. +Czyli rezerwacje można włączyć pojedynczemu boisku bez odmrażania całej funkcji. + +**Flagi ukrywają wejścia, nie trasy.** Trasa `/turniej` odpowiada normalnie, jeśli ktoś +wpisze adres ręcznie — flaga (`SHOW_CUP`) usuwa tylko linki w nawigacji. Dlatego trasy za +flagami nie trafiają do `llms.txt` ani do `sitemap.ts`: reklamowanie ich wyszukiwarce +obiecuje coś, czego użytkownik nie znajdzie w interfejsie. + +--- + +## Gdzie jest spis tras + +Celowo nie utrzymujemy tu inwentarza tras i komponentów — agent znajdzie je szybciej +przez `frontend/src/app/**` niż w tabeli, która by się zestarzała. Ludzki opis funkcji +z trasami: [PRZEWODNIK.md](../PRZEWODNIK.md). Admin = `profiles.is_admin = true`, +panel pod `/admin/*` (CRM kontaktu z obiektami: `/admin/outreach`, logika `lib/outreach.ts`). + +--- + +## Funkcje meczu (opcje zaawansowane) + +Włączane per mecz przy tworzeniu lub edycji, obsługiwane przez `lib/eventFeatures.ts`: + +| Opcja | Kolumna | Efekt | +|---|---|---| +| Drużyny | `team_mode`, `teams_published` | Podział składu, kapitanowie, losowanie, publikacja | +| Wyniki | `track_results` | Wynik meczu + gole i asysty | +| Płatności | `track_payments`, `show_payment_status` | Podział kosztów (organizator), karta „Twoja płatność" (uczestnik) | +| Bramkarze | `goalkeepers_enabled`, `max_goalkeepers` | Osobny limit; nadmiarowi na rezerwę | +| Akceptacja zapisów | `require_approval` | Zapis nie zajmuje miejsca do akceptacji | +| Goście bez konta | `allow_guest_adds` | Uczestnicy mogą dopisywać gości — formularz „Dopisz osobę bez konta" widoczny dla każdego potwierdzonego uczestnika (także rezerwowego) do startu meczu, nie tylko organizatora | +| Kod dołączenia | `join_code` | Wejście przez `/d/[code]` | +| Przejęcie wpisu gościa | `claim_token` | Osoba dopisana ręcznie wiąże wpis z kontem przez `/gracz/przejmij/[token]`; zaproszenie „Zaproś do Bojo" niesie argument (`tekstZaproszeniaGoscia`), nie sam link, i działa też po starcie meczu. Wysłać może też ten, kto gościa dopisał (`allowGuestAdds`), nie tylko organizator — `mozeZaprosic()` w `EventDetailClient.tsx`. Przycisk jest identyczny w składzie i na rezerwie — gość-rezerwowy też ma `claim_token`. Zaraz po dodaniu gościa (`handleAddGuest()`) otwiera się modal `GuestInviteNudge.tsx` z tą samą argumentacją, proaktywnie — raz na wydarzenie (`localStorage`, klucz `bojo:goscie-cta-widziano:`), żeby organizator dopisujący kilkanaście osób pod rząd nie dostał tylu samo modali | +| Potwierdzenie SMS | `require_sms_confirmation`, `confirmation_deadline_h` | **ukryte — `SHOW_SMS_FEATURES`** | + +**„Twoja płatność" — uczestnik widzi, ile ma zapłacić.** Do niedawna kwotę po +uwzględnieniu zniżki kartowej i status opłacone/nieopłacone widział wyłącznie +organizator w panelu „Podział kosztów". Karta na stronie meczu +(`EventDetailClient.tsx`, `costGrosze > 0 && !isOwner && event.showPaymentStatus && +myConfirmed && !myConfirmed.isReserve`) liczy cenę przez `priceForParticipant()` — +ten sam wzorzec co panel organizatora, jedno źródło prawdy. Rezerwowy nie widzi tej +karty: jeszcze nie ma za co płacić, dopóki nie wejdzie do składu. + +**„Wyślij rozliczenie ekipie" — rozliczenie da się wysłać, nie tylko obejrzeć.** +Przycisk w panelu „Podział kosztów" (`lib/settlementShare.ts`, `tekstRozliczenia()`) +otwiera systemowy arkusz udostępniania z gotową wiadomością: kwota od osoby, ile +zebrano z ile oczekiwanych, lista zaległości z kwotami (uwzględniają zniżkę kartową) +i numer BLIK, gdy organizator akceptuje tę metodę płatności. Bez tego organizator +przepisywał to ręcznie na czat — goście bez konta w ogóle nie mają jak zobaczyć +swojej kwoty w Bojo, więc wiadomość na czacie jest dla nich jedynym kanałem. + +**Po starcie meczu cena ustępuje miejsca rozliczeniu.** Chip ceny w nagłówku strony +meczu i badge na karcie `EventBrowseCard` (zakładka „Historia") pokazują przed +meczem cenę i „Wymaga akceptacji"; po starcie meczu (`eventStarted`) — organizator +widzi „Rozliczono" albo „X osób nie zapłaciło" (`event.unpaidCount`, liczone z już +pobranego `event_participants` w `getMyParticipatedEvents()`), gracz widzi „Zapłacono" +albo „Zapłać" (`relation.hasPaid`). „Wymaga akceptacji" znika po starcie — bez +znaczenia po fakcie. Sekcje „Podział kosztów"/„Twoja płatność" na stronie meczu +renderują się nad „Składy"/„Wynik meczu" po starcie meczu (przed startem — odwrotnie); +treść sekcji się nie zmienia, tylko kolejność (`skladWynikSection`/`platnosciSection` +w `EventDetailClient.tsx`). + +**Nazwy drużyn są jednym słownikiem.** `lib/teamLabels.ts` (`TEAM_LABELS`, +`TEAM_LETTERS`, `TEAM_COLOR_CLASSES`) — „Niebiescy"/„Czerwoni" + litery N/C wszędzie, +w składzie (`TeamsPanel`, `PublishedTeamsCard`) i w wyniku (`MatchResultForm`). Dane +w bazie zostają literami A/B, zmieniła się wyłącznie warstwa etykiet. + +**Strzelcy nie mogą przebić wyniku końcowego.** `MatchResultForm` blokuje zapis +(i disabluje przycisk), gdy suma goli albo asyst u strzelców przekracza +`scoreA + scoreB` — dotyczy wyłącznie `family === 'goals'` (piłka nożna/futsal/piłka +ręczna), bo tylko tam jest sekcja „Strzelcy". + +--- + +## Zapis na mecz bez logowania + +**Problem.** Nieznajomi obawiają się założenia konta w obcej aplikacji. Organizator chce +dać im możliwość szybkiego dołączenia, bez wymuszania logowania — wystarczy imię i e-mail. + +**Rozwiązanie w Bojo.** Osoba bez konta może dołączyć do meczu, podając imię i e-mail +(dokładnie tak samo jak uczestnik zalogowany). Zanim kliknie „Zapisz się", widzi tę +samą zapowiedź rezerwy co zalogowany („Mecz ma już komplet — zapiszesz się na listę +rezerwową jako 2. w kolejce"). Zapisuje się na główny skład lub rezerwę zgodnie +z tymi samymi regułami pojemności; ekran po zapisie pokazuje faktyczny status +(„Jesteś w składzie" albo „Jesteś na liście rezerwowej") nad już zaktualizowaną listą +uczestników. Może dokończyć profil bez ponownego wpisywania imienia/maila — hasłem +albo przez Google — i od razu ląduje na stronie meczu, bez dodatkowego ekranu +potwierdzenia „to ja". Gdy podany e-mail ma już konto w Bojo, to samo pole hasła +przełącza się z rejestracji na logowanie — po zalogowaniu wpis przejmowany jest +tak samo od razu. Nawet jeśli gość zamknie ten ekran bez logowania (albo w ogóle +go nie zobaczy — np. wpis dodał organizator ręcznie), a e-mail pasuje do istniejącego +lub przyszłego konta, właściciel tego konta dostanie **powiadomienie** z gotowym +linkiem do przejęcia przy najbliższej okazji (patrz niżej) — nic nie ginie w milczeniu. +Anonimowy zapis **nie wymaga logowania ani wymyślania po stronie organizatora** — +link do dołączenia to ten sam link, co do każdego innego meczu. Ten sam e-mail nie +zapisze się dwa razy na ten sam mecz. Gdy to wciąż e-mail bez konta (nieprzejęty +wpis-gość), druga próba pokazuje ten sam ekran zachęty, tylko z nagłówkiem +„Wcześniej dołączyłeś do tej gry." zamiast „Zapisano!". Gdy e-mail **ma już konto +w Bojo**, ekran skraca się do logowania — bez listy korzyści i bez namawiania na +drugie konto, z podlinią „Zaloguj się, żeby zobaczyć więcej szczegółów" i małym +„Pomiń i zobacz skład bez logowania"; nagłówek nadal mówi, czy to świeży zapis, czy +powrót do zapisu sprzed chwili. Gdy wpis ma już właściciela (konto przejęło zapis +albo dołączyło normalnie, po zalogowaniu), pokazuje się ten sam skrócony ekran bez +pola hasła — nie ma czego przejmować, więc zostaje samo logowanie. W żadnym z tych +przypadków nie powstaje drugi wiersz w składzie i nie leci czerwony błąd. + +**Mechanika.** Funkcja RPC `dolacz_do_meczu_jako_goscie()` (migracja `082`, poprawiona +migracją `083` — INSERT…RETURNING z jawnym prefiksem tabeli) w Supabase, wołana z +`frontend/src/lib/events.ts` (`joinEventAsGuest()`, zwraca `claimToken` i `isReserve`). +Wpis gościa to wiersz `event_participants` z kolumnami `user_id = NULL`, `is_guest = true`, +`guest_email`, `is_reserve` (liczony przez tę samą logikę co zalogowani). Trigger +`nadaj_token_gosciowi` (migracja `066`) generuje unikalny `claim_token` (UUID). Dialog +gościa w `EventDetailClient.tsx` liczy zapowiedź rezerwy z `wolneMiejscaWgRol()` +(bez zapytania do bazy); `handleJoinAsGuest()` woła `load()` po udanym zapisie, żeby +lista uczestników była aktualna, zanim pokaże się ekran zachęty. Tam +`handleCreateAccountFromGuest()` woła `signUpWithEmail()` i — gdy sesja jest aktywna +od razu — `przejmij_wpis_goscia()` wprost, bez przejścia przez `/logowanie`. Gdy +`signUpWithEmail()` rzuci błąd „już istnieje", `handleSignInFromGuest()` woła +`signInWithEmail()` na tym samym polu hasła i przejmuje wpis po udanym logowaniu. +Przycisk Google woła `signInWithGoogle()` z `next=/gracz/przejmij/[token]?auto=1` — +parametr `auto=1` na tej stronie (`PrzejmijClient.tsx`) każe przejąć wpis automatycznie, +gdy user jest już zalogowany, zamiast czekać na klik „To ja — potwierdzam". + +Migracja `084` dodaje dwa wyzwalacze SQL, które kojarzą wpis gościa z kontem po +e-mailu w tle, niezależnie od tego, czy gość w ogóle przeszedł przez ekran zachęty: +`event_participants` → `auth.users` (nowy wpis, e-mail pasuje do istniejącego konta) +i `auth.users` → `event_participants` (nowe konto, e-mail pasuje do wcześniejszych +nieprzejętych wpisów). Oba wstawiają powiadomienie typu `niepotwierdzony_wpis_goscia` +z kolumną `notifications.claim_token`; `NotificationBell.tsx` kieruje kliknięcie na +`/gracz/przejmij/[token]` zamiast na stronę meczu. **Żaden z wyzwalaczy nie ustawia +`user_id` sam** — samo powiadomienie niczego nie przejmuje, to nadal wymaga +świadomego kliknięcia i `auth.uid()` po stronie `przejmij_wpis_goscia()` (migracja `066`). + +Migracja `085` dodaje na starcie `dolacz_do_meczu_jako_goscie()` sprawdzenie +duplikatu: wpis z tym e-mailem już w tym meczu (nieprzejęty gość → zwraca istniejący +`claim_token` zamiast tworzyć drugi wiersz; przejęty → wyjątek) albo e-mail pasuje do +konta już będącego uczestnikiem przez normalne, zalogowane dołączenie (JOIN +`auth.users`→`event_participants.user_id`) → wyjątek. Sprawdzenie idzie przed +`sync_reserve_claim()`/`czy_na_rezerwe()`, żeby odrzucone żądanie nie ruszało kolejki +rezerwowych. `signUpWithEmail()` w `lib/auth.tsx` dostała drugi sposób wykrycia +„e-mail już ma konto" — `data.user.identities.length === 0` — bo gdy w projekcie +włączona jest ochrona przed enumeracją e-maili, Supabase dla istniejącego adresu nie +rzuca błędu, tylko zwraca fałszywy sukces bez sesji; bez tej dodatkowej detekcji +`handleCreateAccountFromGuest()` nigdy by nie przełączył się na logowanie w tym +trybie. Naprawia to też tę samą lukę w zwykłej rejestracji przez `/logowanie`. + +Migracja `087` dodaje do wyniku `dolacz_do_meczu_jako_goscie()` kolumnę +`already_joined` (zmiana `RETURNS TABLE` — wymagała `DROP FUNCTION` + `CREATE`, +`CREATE OR REPLACE` nie pozwala zmienić typu zwracanego, i ponownego `GRANT EXECUTE`). +`joinEventAsGuest()` w `lib/events.ts` przekazuje ją dalej jako `alreadyJoined`, +a `handleJoinAsGuest()` w `EventDetailClient.tsx` używa jej do nagłówka ekranu zachęty +(„Wcześniej dołączyłeś" zamiast „Zapisano!"). + +Migracja `088` dokłada czwartą kolumnę `has_account` (`EXISTS` na `auth.users` po +zlowercase'owanym e-mailu — pytanie globalne, nie „czy jest w tym meczu"), zamienia +wyjątek `'Jesteś już zapisany na ten mecz.'` na zwykły wiersz z `claim_token = NULL` +i zakłada unikalny indeks `idx_participants_unique_guest_email` na +`(event_id, lower(guest_email))`, wcześniej kasując duplikaty sprzed `085` (bez tego +indeks się nie zakłada). Wyszukanie istniejącego wpisu dostało `ORDER BY (claim_token +IS NULL) DESC, created_at` — przy danych z duplikatami samo `LIMIT 1` losowało wariant +ekranu. `handleJoinAsGuest()` wybiera ekran po kształcie wyniku: pusty `claimToken` → +`showAlreadyJoinedPrompt`, w przeciwnym razie `showAccountPrompt` z `newUserHasAccount` +i `accountEmailTaken` ustawionymi z `has_account` (pole hasła startuje w trybie +logowania). Dopasowanie po treści wyjątku zostało tylko jako furtka zgodności na czas, +zanim `088` zostanie wgrana ręcznie w Supabase — `joinEventAsGuest()` czyta +`has_account` przez `?? false`, więc stary, trzykolumnowy kształt RPC nadal działa. + +**Pytania, na które odpowiada ta sekcja:** Czy mogę dołączyć do meczu bez konta w Bojo? +Jak niezalogowany gracz może się zapisać na mecz? Czy gość bez konta zajmuje miejsce +w składzie? Jak przejąć wpis gościa po założeniu konta? Co się stanie, jeśli zapiszę +się jako gość na e-mail, który ma już konto w Bojo? + +--- + +## Zaproszenia na mecz + +Imienne zaproszenie (`event_player_invites`, migracja `060`, `lib/playerInvites.ts`) — +organizator albo dowolny potwierdzony uczestnik zaprasza konkretne osoby z grupy +(`components/events/InviteFromGroupDialog.tsx`, przycisk „Zaproś z grupy" na stronie +meczu). Zaproszenie nie zajmuje miejsca w składzie; odpowiedź to zwykłe „Dołącz" / +„Obserwuj" na stronie meczu albo „Nie tym razem" (odrzucenie, zapisywane trwale, żeby +ponowne „zaproś grupę" nie wskrzeszało odrzuconego zaproszenia). + +Gdzie widać otwarte zaproszenia: + +| Miejsce | Co pokazuje | +|---|---| +| Strona główna (dashboard) | Sekcja „Zaproszenia" — max 3, znika przy zerze | +| `/moje-gry?tab=nadchodzace` | Ten sam teaser co na dashboardzie — max 3, link „Wszystkie" prowadzi do zakładki niżej | +| `/moje-gry?tab=zaproszenia` | Pełna lista, bez limitu, z pustym stanem | +| `/wydarzenia` | Plakietka „Zaproszenia N" obok pola wyszukiwania — **widoczna tylko gdy N > 0**, prowadzi do zakładki wyżej | + +Wspólny hook `lib/useMyInvites.ts` (pobiera zaproszenia + mapę uczestnictwa, filtruje do +statusu `'invited'`) i wspólny komponent listy `components/events/InviteList.tsx` — cztery +powyższe miejsca renderują ten sam kod, żeby nie rozjeżdżały się przy zmianie. +`InvitesSection` (`components/home/dashboard/DashboardSections.tsx`) przyjmuje opcjonalne +`href`/`dismissedIds`/`onDismiss` właśnie po to, żeby dashboard i `/moje-gry` mogły dzielić +jeden komponent zamiast dwóch kopii — patrz sekcja „Układ `/moje-gry`" niżej. + +Nie mylić z `lib/invites.ts` (tabela `event_invites`, migracja `036`) — zaproszenia po +e-mailu z tokenem, martwy kod, nic go nie importuje. + +**Jeden przycisk „Zaproś z grupy" na stronie, nie dwa.** Do niedawna były dwa — przy +liczniku wolnych miejsc i osobno w sekcji „Zaproś znajomych" — z różnymi ikonami i różnymi +warunkami widoczności. Zostaje wyłącznie ten przy liczniku (`!isFull`, ikona `Users`); +sekcja niżej na stronie ma dziś tylko udostępnianie linku. + +**Kto zaprosił, kto odpowiedział — widok organizatora.** +`components/events/EventInvitesStatus.tsx`, tylko `isOwner` (RLS na +`event_player_invites` i tak nie przepuści reszty — SELECT widzi zaproszony, organizator +i admin). Lista imion z awatarami i statusem: Czeka / Dołączył(a) / Nie tym razem. Nazwy +dociąga `getEventInvitesWithNames()` (`lib/playerInvites.ts`) drugim zapytaniem do +`profiles` — `event_player_invites` ma klucz obcy do `auth.users`, nie do `profiles`, więc +PostgREST nie potrafi tego wbudować jednym joinem. Reguła „uczestnictwo bije wcześniejszą +odmowę" (`lib/inviteStatus.ts`, pod testem) — ktoś mógł kliknąć „Nie tym razem" i mimo to +dołączyć innym kanałem; `dismissed_at` sprawdza się dopiero, gdy w składzie go nie ma. + +--- + +## Dolny panel nawigacji (mobile) + +`components/layout/BottomNav.tsx`, montowany globalnie przez `BottomNavGate.tsx` +(`app/layout.tsx`) dla zalogowanych na mobile. Panel chowa się na dwóch ścieżkach, gdzie +zasłaniałby ważniejsze CTA: + +- **Kreator meczu** (`/wydarzenia/nowe`) — cały czas, żeby nie rozpraszać organizatora + i nie zasłaniać przycisku „Dalej". +- **Strona meczu**, dopóki widoczny jest pasek „Dołącz →" / „Obserwuj" (czyli dopóki + użytkownik nie ma potwierdzonego miejsca ani oczekującej prośby). Po dołączeniu panel + wraca — to zachęta do kolejnej akcji. + +Mechanizm: `lib/bottomNavVisibility.tsx` — kontekst z licznikiem (nie boolean), żeby dwa +niezależne powody ukrycia nie odsłaniały panelu przedwcześnie. Komponent `` +montowany warunkowo chowa panel, dopóki jest zamontowany. + +**Miejsce pod paskiem — zmienna `--bottom-nav-h`.** Pasek jest `fixed`, więc sam z siebie +nie rezerwuje miejsca w dokumencie. `BottomNavGate.tsx` ustawia `document.documentElement +.dataset.bottomNav = '1'`, dopóki pasek faktycznie jest widoczny (zalogowany, mobile, nie +schowany); `app/globals.css` reaguje na `html[data-bottom-nav='1']` i: +- dokłada `padding-bottom: var(--bottom-nav-h)` do ``, +- odejmuje `--bottom-nav-h` od `.min-h-screen` / `.h-screen` (kolejność `vh` → `svh`, jak + w `.hero-first-screen` — `svh` ignoruje chowający się pasek adresu). + +Od `md:` (768px) `--bottom-nav-h` wraca do `0px` — pasek i tak jest `md:hidden`. Zastąpiło +to element-dystans (`
`), który **nie działał**: `BottomNavGate` +montuje się w layoucie po `{children}`, więc dystans lądował poza kontenerem strony i tylko +wydłużał dokument o 64 px — po dojechaniu do dołu każda strona dla zalogowanego na mobile +kończyła się pustym pasem tła. Wartość `--bottom-nav-h` (`3.5rem` + `env(safe-area-inset-bottom)`) +musi się zgadzać z rzeczywistą wysokością paska (`h-14` w `BottomNav.tsx`). + +**Kropki na „Moje", „Grupy" i „Znajdź grę".** Niebieska na „Moje" (prawy górny róg) — +oczekujące prośby o dołączenie (`hasPendingApprovalRequests()`, `lib/events.ts`). Różowa na +„Moje" (lewy górny róg) i na „Grupy" (lewy górny róg) — nieprzeczytane wiadomości +(`hasUnreadEventMessages()` w `lib/comments.ts`, `hasUnreadGroupMessages()` w +`lib/groupPosts.ts`). Pomarańczowa na „Grupy" (prawy górny róg) — nowy mecz w +którejkolwiek mojej ekipie od ostatniej wizyty na jej stronie (`hasNewGroupEvents()` +w `lib/groups.ts`, ten sam znacznik `kluczGrupyWidziano()` co kropka na karcie ekipy niżej). +Pomarańczowa na „Znajdź grę" (prawy górny róg) — nowe wydarzenie w promieniu 5 km od +ostatniej wizyty na `/wydarzenia` (`maNoweWydarzeniaWPobolizu()` w `lib/events.ts`, znacznik +`KLUCZ_WYDARZENIA_WIDZIANO`). Kolor ma stałe znaczenie w całej apce, patrz `AGENTS.md` → +Konwencje. Każda ikona może nosić dwie kropki naraz (różową i pomarańczową na „Grupy"; +różową i niebieską na „Moje") — lewy i prawy róg, żeby się nie nakładały. + +**Każde zapytanie ignoruje odpowiedź, która wróciła po zmianie trasy.** Wszystkie cztery +efekty w `BottomNav.tsx` (prośby, wiadomości „Moje", wiadomości+nowość „Grupy", pobliskie +nowe) trzymają lokalną flagę `aktualne`, zerowaną w funkcji sprzątającej efektu — bez tego +wolniejsza odpowiedź z POPRZEDNIEJ trasy mogła wrócić PO szybszej odpowiedzi ze świeżo +odpalonego zapytania i nadpisać poprawny stan starym, zostawiając kropkę zapaloną bez +żadnego realnego powodu. Zgłoszone wprost jako różowa kropka na „Moje" mimo zera +nieprzeczytanych wiadomości widocznych na samej stronie. + +**Błąd zapytania gasi kropkę, nie zostawia poprzedniej wartości.** Powyższa poprawka nie +wystarczyła — kropka na „Moje" wracała mimo przeczytania wiadomości. Każdy `.then()` w tych +efektach kończył się gołym `.catch(() => {})`: przy błędzie (chwilowy problem sieci, +odświeżenie tokenu Supabase w trakcie) stan po prostu zostawał taki, jaki był PRZED +nieudanym zapytaniem — jeśli ostatnia udana odpowiedź brzmiała „są nieprzeczytane", kropka +świeciła dalej w nieskończoność, aż trafi się kolejne udane zapytanie. `catch` w każdym z +czterech efektów ustawia teraz jawnie `false` (`null` dla nazwy grupy) zamiast nic nie +robić — brak pewności o stanie ma zawsze wygrywać z fałszywie zapaloną kropką. + +Pomarańczowa kropka **wymaga zgody na lokalizację JUŻ udzielonej** — sprawdzana cicho przez +`hasGeolocationPermission()` (`lib/geo.ts`, Permissions API), bez pytania o nią. Gdyby zamiast +tego kropka wołała `getCurrentLocation()` wprost, każda zmiana trasy wywoływałaby systemowe +okno o zgodę na lokalizację bez żadnego kontekstu — dla kogoś, kto jej nigdy nie udzielił. +Brak zgody = brak kropki, nie prośba w tle. + +**Dymki przy pierwszym zapaleniu kropki.** Gdy kropka na dolnej nawigacji przechodzi z +wyłączonej na włączoną (nie przy każdej zmianie trasy, dopóki świeci — `poprzednieAktywne` +w `BottomNav.tsx` łapie wyłącznie to przejście), nad ikoną na 4 s pojawia się mała czarna +etykieta z krótkim wyjaśnieniem: „Nowa prośba o dołączenie" (niebieska, „Moje"), „Nowe +wiadomości" (różowa — osobny typ/licznik dla „Moje" i osobny dla „Grupy", mimo identycznego +tekstu, żeby dymek jednoznacznie wiedział, przy której ikonie stanąć), „Nowa gra w grupie +{nazwa}" (pomarańczowa na „Grupy" — nazwa z `getNewGroupEventGroupName()` w `lib/groups.ts`, +ekipa z najświeższym nowym meczem, gdy nowych jest kilka naraz), „Nowa gra w promieniu 5 km" +(pomarańczowa na „Znajdź grę"). Licznik pokazań w `localStorage` +(`bojo:dymek-pokazania:`) jest per typ — po 5 pokazaniach danego typu dymek przestaje +się pojawiać, zakładamy że użytkownik już wie, co ta kropka znaczy. + +**Najwyżej jeden dymek na ekranie naraz.** Gdy kilka kropek zapala się w tym samym +przeliczeniu (typowo przy pierwszym załadowaniu), dymki nie renderują się równolegle — +zasłaniałyby się nawzajem na wąskim pasku pięciu ikon. `BottomNav.tsx` trzyma pojedynczy +stan `dymekWidoczny` (typ + tekst + `href` ikony, do której należy) i kolejkę +`kolejkaDymkow`: pierwszy trafiony typ pokazuje się od razu, reszta czeka w kolejce +i pokazuje się po kolei, jeden po drugim, każdy na swoje 4 sekundy. Dymek jest zawsze +przypięty do konkretnej ikony przez `href` — komponent `NavLink` dostaje gotowy tekst +tylko wtedy, gdy `dymekWidoczny.href` zgadza się z jego własnym `href`. + +Dymek nad skrajną ikoną (pierwszą — „Znajdź grę", ostatnią — „Grupy") wystawał poza ekran: +wyśrodkowany nad wąską kolumną blisko krawędzi, ciągnął się poza jej brzeg (zgłoszone wprost, +ze zrzutem). `NavLink` dostaje prop `dymekAlign` (`'left' | 'center' | 'right'`) — skrajne +kolumny w `BottomNav.tsx` przypinają dymek do swojej wewnętrznej krawędzi zamiast centrować +go nad ikoną, środkowe trzy kolumny zostają wyśrodkowane jak dotąd. + +**Pomarańczowa kropka na konkretnej karcie, nie tylko na ikonie/liście.** Zbiorcza kropka +(„Grupy", „Znajdź grę", karta ekipy na `/grupy`) mówi „coś jest nowe", ale nie wskazuje +CO — zgłoszone wprost. `EventBrowseCard` dostał prop `isNew`: pomarańczowa kropka w rogu +ikony sportu na konkretnym wpisie. Na `/wydarzenia` — `EventsListClient.tsx` odczytuje +`KLUCZ_WYDARZENIA_WIDZIANO` PRZED nadpisaniem go na „teraz" (inaczej porównanie zawsze +wypadałoby „nic nie jest nowe") i przekazuje starą wartość do `EventsListView` jako +`widzianoWczesniej`; `null`/`undefined` (pierwsza wizyta) świadomie nie oznacza niczego — +na pierwszej wizycie KAŻDE wydarzenie byłoby „nowe", co zalałoby listę kropkami. Na +`/grupy/[id]` (zakładka Mecze, też „Najbliższy mecz" nad zakładkami) — ten sam wzorzec ze +starą wartością `kluczGrupyWidziano()`, zmienna `grupaWidzianaWczesniej` w +`GroupDetailClient.tsx`. + +„Nieprzeczytane" liczy się z `localStorage` („ostatnio widziano" per mecz/ekipa, +`kluczRozmowyWidziano()`/`kluczTablicaWidziano()`), nie z tabeli w bazie — własne +wiadomości nigdy się nie liczą, bo nadawca widział je w momencie wysyłania. +`getMyActiveEventIds()` (gram/rezerwa/organizuję) **nie filtruje po dacie** — mecz +z historii z nową wiadomością też zapala różową kropkę na „Moje"; `/moje-gry` (zakładka +Historia) i mecze ekipy (`/grupy/[id]`, sekcja Historia) przekazują `unreadMessages` do +`EventBrowseCard` również tam, nie tylko w Nadchodzących. To jednak nie wystarczyło samo +w sobie — **`EventBrowseCard` w ogóle nie renderował plakietki w gałęzi JSX dla +rozegranych meczów** (osobny branch od meczów nadchodzących, bez badge'a niezależnie od +propa `unreadMessages`), więc kropka na „Moje" świeciła się bez żadnego widocznego śladu, +gdzie szukać wiadomości — zgłoszone wprost, dwa razy, zanim znaleziono właściwe miejsce. +Plakietka (razem ze `statusChip`) teraz stoi też w gałęzi „rozegrany/anulowany", owinięta +wspólnym `ml-auto`, żeby oba elementy trzymały się prawej krawędzi niezależnie od tego, +czy któryś z nich akurat istnieje. Ten sam mechanizm zasila plakietkę z liczbą przy +zakładce Rozmowa/Tablica (patrz zakładki `/wydarzenia/[id]` i `/grupy/[id]` niżej) oraz +ikonę z liczbą obok chipu „N wolnych miejsc"/„Rozegrany" na karcie meczu (tylko gdy +gram/organizuję/jestem na rezerwie w tym meczu). + +**Kropki na karcie ekipy (`/grupy`).** Na ikonie każdej ekipy: różowa w lewym górnym rogu — +nieprzeczytana wiadomość na tablicy (ten sam `nieprzeczytane()` co wyżej) — pomarańczowa +w prawym górnym rogu — nowy mecz w ekipie od ostatniej wizyty na `/grupy/[id]` +(`maNoweMecze()`/`getGroupEventsForNew()` w `lib/groups.ts`, znacznik `kluczGrupyWidziano()`, +ustawiany przy KAŻDYM wejściu na stronę ekipy, niezależnie od zakładki — osobny od +`kluczTablicaWidziano()`, bo odpowiada na inne pytanie). Sama kropka, bez licznika — karta +listy grup ma być czytelna na pierwszy rzut oka, nie kolejnym miejscem do liczenia. + +**Filtr „tylko z nieprzeczytanymi" na `/moje-gry`.** Ikonka wiadomości stoi na wysokości +nagłówka pierwszej sekcji, która realnie ma co pokazać, nie w pasku zakładek (zgłoszone +wprost) i nie na sztywno przy „Brakuje graczy" (zgłoszone wprost po raz drugi — pusty +wiersz zarezerwowany tylko dla ikonki, gdy ta sekcja jest akurat pusta, zjadał sporo +miejsca na ekranie). Kolejność prób w `app/moje-gry/page.tsx`: **Brakuje graczy** (`extra` +w `SectionHeader`, gdy `maBrakujeGraczy`) → **Najbliższy mecz** (`extra` w +`NextMatchCard`, gdy jest `nextWidoczny`, a „Brakuje graczy" akurat puste) → pusty wiersz +jako ostateczność (`pokazPustyNaglowek` w `NeedsPlayersSection`), wyłącznie gdy obie +tamte sekcje nic nie pokazują naraz. Widoczna tylko, gdy jest choć jeden nieprzeczytany +mecz w ogóle. Filtruje „Czekają na Twoją decyzję", „Brakuje graczy", najbliższy mecz +i „Twoje najbliższe mecze" do tych z nieprzeczytaną wiadomością; zaproszenia i stałe +gierki (gdy `SHOW_RECURRING`) filtr nie dotyczy — to nie są „wiadomości". + +--- + +## Górny pasek nawigacji — inny dla zalogowanych na mobile + +Poniżej `md` (768px) zalogowany użytkownik dostaje w `Header.tsx` **inny pasek** niż +wylogowany i niż desktop: bez logo, `h-12` zamiast `h-16`, po prawej dzwonek powiadomień +(`NotificationBell`) i awatar linkujący do `/profil` — zamiast logo + hamburgera. Powód: +wszystko, co było w arkuszu hamburgera dla zalogowanego (Moje mecze, Grupy, Moje obiekty, +panel admina, profil, motyw, Wyloguj), już jest dostępne w dolnym panelu nawigacji albo na +`/profil` — drugi zestaw tych samych skrótów tylko zjadał pierwszy ekran. + +Skutek uboczny: dzwonek powiadomień, wcześniej wyłącznie w bloku `hidden md:flex`, jest +teraz dostępny na telefonie. + +### Pasek znika całkiem na `/`, `/wydarzenia`, `/mapa` + +Na tych trzech trasach zalogowany na mobile **w ogóle nie widzi paska Header** — dzwonek +i awatar wędrują do własnego wiersza strony, wzorem tego, jak od dawna robi to pulpit +(`GreetingBar`: powitanie + awatar w jednym wierszu). Mechanizm: `Header` dostaje prop +`hideMobileBarForUser` — gdy jest `true` **i** ktoś jest zalogowany, cały `
` +dostaje `hidden md:block` (znika na mobile, wraca od `md:`), a jego własny mobilny +dzwonek/awatar w ogóle się nie montuje (żeby nie było trzeciego, niewidocznego kanału +realtime obok tego w treści strony). + +Zastępczy wiersz to nowy, współdzielony komponent `components/layout/MobileIdentityRow.tsx` +(dzwonek + awatar, markup 1:1 z mobilnego klastra `Header`) — sam sprawdza `useAuth()` +i zwraca `null` dla wylogowanego, więc wywołujący wstawia go bezwarunkowo: + +| Trasa | Gdzie wiersz siedzi | +|---|---| +| `/` | `GreetingBar` — dzwonek obok istniejącego awatara `h-10 w-10` | +| `/wydarzenia` | `EventsListView` — jeden wiersz z polem szukania (`flex-1`) + `MobileIdentityRow` | +| `/mapa` | `VenueExplorer` — ten sam wiersz obok pływającego pola szukania nad mapą | + +`/moje-gry` i `/grupy` **zachowują pełny pasek Header bez zmian** — `hideMobileBarForUser` +się tam nie przekazuje. Wylogowanych i desktop `hideMobileBarForUser` nie dotyczy nigdy: +wylogowany na tych trasach nadal widzi marketingowy pasek (mapa/Dołącz/awatar) opisany +niżej, a desktop ma pełny pasek jak zawsze. + +**Kompaktowy wordmark „bojo" na mobile — `/moje-gry`, `/grupy`, `/grupy/[id]`, widok +wydarzenia.** Te trasy zostawiają pasek Header (nie mają `MobileIdentityRow` we własnej +treści), a zalogowany na mobile ma tam dziś pusty lewy slot — logo (`LogoPill`) jest +`hidden md:block`. Nowy prop `Header({ showMobileWordmark })` wypełnia ten slot +tekstowym linkiem „bojo" (`font-display font-bold text-primary-700`) do `/`, bez zmiany +wysokości paska (`h-12` na mobile zostaje). Przekazywany na `app/moje-gry/page.tsx`, +`app/grupy/GroupsClient.tsx`, `app/grupy/[id]/GroupDetailClient.tsx` i +`app/wydarzenia/[id]/EventDetailClient.tsx` — nigdzie indziej. + +**Hamburgera nie ma już w ogóle** — ani dla zalogowanych, ani dla wylogowanych. Arkusz +pełnoekranowy, pułapka focusa i blokada przewijania zostały usunięte z `Header.tsx`. + +Wylogowany na mobile dostaje po prawej trzy elementy: **ikonę mapy** (`/mapa`), zielony +przycisk **„Dołącz"** i **ikonę awatara** (logowanie). „Dołącz" prowadzi na +`/logowanie?mode=rejestracja` i otwiera formularz od razu na zakładaniu konta — +`AuthForm` przyjmuje prop `initialMode`, domyślnie `'signin'`, więc pozostałe ~20 wejść +na `/logowanie` zachowuje się bez zmian. + +Konsekwencja świadoma: **pasek przestał być nawigacją dla wylogowanego.** Do +`/wydarzenia` i `/wydarzenia/nowe` prowadzą CTA w hero landingu, klikalny krok +„Stwórz mecz" w sekcji „Jak to działa", kafelek w „Co dostajesz", pływający przycisk `+` +(`StickyCta`) oraz linki w stopce. + +Desktop (`md:` i wyżej) ma na to miejsce, więc pokazuje oba wejścia z nazwami: +tekstowe „Zaloguj się" i zielone „Dołącz". + +### `/profil` — nowy dom opcji z dawnego hamburgera + +Zalogowany na mobile, chcąc przełączyć motyw, wejść do panelu admina albo zobaczyć swoje +obiekty, robi to na `/profil` (`app/profil/page.tsx`), nie w nagłówku: + +| Sekcja | Warunek | Źródło | +|---|---|---| +| Moje statystyki | zawsze | link do `/gracz/[id]` | +| Moje obiekty | `hasManagedVenue(userId)` (`lib/api.ts`) | zarządza ≥1 obiektem | +| Wygląd (jasny/ciemny) | `next-themes` załadowany | `useTheme()`, ten sam wzorzec co w `Header.tsx` | +| Panel administratora | `useAdmin()` | lista z `lib/adminLinks.ts` — ta sama, co w `AdminMenu` na desktopie | +| Wyloguj się | zawsze | istniało już wcześniej | + +`lib/adminLinks.ts` i `lib/api.ts#hasManagedVenue` to wspólne źródła prawdy między +`Header.tsx` (desktop) a `/profil` (mobile) — jedna lista tras, jedno zapytanie. + +--- + +## Szkic kreatora meczu + +Kreator (`app/wydarzenia/nowe/page.tsx`) zapamiętuje wypełniany formularz w +`localStorage` przez **12 godzin** (`lib/eventDraft.ts`, `EVENT_DRAFT_TTL_MS`) — jeśli +organizator wyjdzie w trakcie (np. sprawdzić godzinę wynajmu) i wróci, formularz stoi tam, +gdzie go zostawił, zamiast zerować się do stanu początkowego. + +- **Odtwarzanie**: raz, przy montowaniu. **Pomijane całkowicie** przy wejściu z `?group=` + albo `?fieldId=` — te parametry mają własne efekty prefill i kolidowałyby z odtworzonym + szkicem; wejście z linku obiektu/grupy to świadomy start od nowa. +- **Data w przeszłości**: jeśli odtworzona data blokowałaby krok 2 (`isPast()`), podmieniana + jest na jutro — reszta szkicu zostaje. +- **Zapis dopiero po pierwszej realnej zmianie**: efekt zapisujący szkic pomija swoje + pierwsze uruchomienie po hydratacji (`useRef` `isFirstSave`) — bez tego zapisywał czyste + wartości domyślne przy samym wejściu na stronę, więc kolejna wizyta w oknie 12h TTL + pokazywała baner odtworzenia mimo braku jakiejkolwiek edycji. +- **Pasek informacyjny**: jedna linia „Wróciliśmy do Twojego szkicu (N minut/godzin temu). + Zacznij od nowa" + osobny krzyżyk. „Zacznij od nowa" czyści `localStorage` i resetuje + formularz do stanu początkowego; krzyżyk tylko chowa baner na czas tej wizyty + (lokalny `useState`, nie dotyka `localStorage` ani TTL) — pojawi się znów po odświeżeniu, + jeśli szkic wciąż jest ważny. +- **Kasowanie**: po udanej publikacji meczu, automatycznie. + +Pole `nazwaWlasnaMiejsca` (nazwa dla pinezki spoza katalogu) jest w `EventDraftValues` +**opcjonalne**, a wersja schematu została na `v: 1`. To celowe: `loadEventDraft` odrzuca +szkic przy `parsed.v !== 1`, więc podbicie wersji unieważniłoby każdy formularz wypełniany +w chwili wdrożenia. Odczyt robi `?? ''`. Pokryte testem w `eventDraft.test.ts`. Tak samo +opcjonalne — i z tego samego powodu — jest `grupaId` (grupa wybrana w kroku 3). + +--- + +## Kreator meczu — co widać na którym kroku + +**Wordmark „bojo" w pasku.** Kreator montuje ``, więc bez wordmarku +zalogowany na telefonie nie miał stamtąd żadnego wyjścia „do domu". Oba `
` +w `app/wydarzenia/nowe/page.tsx` (brama logowania i właściwy kreator) dostają +`showMobileWordmark` — ten sam prop co `/moje-gry`, `/grupy`, `/wydarzenia/[id]`. +Wysokość paska bez zmian (`h-12`, sticky stepper na `top-12`). + +**Krok 1 — propozycja ostatniego boiska.** `lib/lastVenue.ts` zapamiętuje ostatnio +wybrany obiekt z katalogu (`localStorage`, klucz `bojo_ostatnie_boisko_v1`, TTL 60 dni, +guardowany `try/catch` jak `eventDraft.ts`). Zapis następuje po udanej publikacji, +**przed** `clearEventDraft()`, i tylko gdy miejsce pochodziło z katalogu — pinezka własna +nie ma `id`. Odczyt pokazuje chip „Ostatnio: «nazwa» — Użyj", widoczny wyłącznie gdy +miejsce nie jest jeszcze wybrane. To **propozycja, nie autowybór**: ciche ustawienie +miejsca meczu jest najgorszą możliwą pomyłką do przeoczenia. + +**Krok 2 — „Czas na decyzję z rezerwy" bez chowania.** Pole stoi na stałe pod „Liczbą +miejsc" (opcje 1/3/6/12/24 h, domyślnie 3). Wcześniej siedziało pod rozwijanym „Więcej +opcji" — sekcja została w kodzie, ale nie ma dziś czego pokazać i się nie renderuje. +Odwrócenie ustalenia O-11 audytu, patrz [przeplyw-organizatora.md](./przeplyw-organizatora.md). +Obok steppera liczby miejsc stoi podpowiedź, że graczy dopisuje się po utworzeniu meczu, +na jego stronie, także bez konta. + +**Krok 2 — kafelek „Wydarzenie cykliczne".** Obok pól daty/godziny, kafelek otwiera +`components/events/RecurringSettingsDialog.tsx` z dniem tygodnia wyliczonym z wybranej +daty (`lib/recurring.ts#dayOfWeekFromDate`) i suwakiem „otwieraj zapisy X dni przed +terminem" (dawniej „powiadamiaj" — od migracji `073` ta wartość steruje AUTOMATYCZNYM +tworzeniem kolejnego terminu, nie tylko treścią przypomnienia, więc minimum to 1, nie 0). +Kliknięcie aktywnego kafelka wyłącza cykliczność, ikona ołówka na aktywnym kafelku +ponownie otwiera modal. Ustawienia żyją wyłącznie w stanie kreatora — dopiero publikacja +meczu tworzy szablon w `recurring_events` (`createRecurringEvent`) i wiąże z nim ten +pierwszy mecz przez `events.recurring_event_id`. Po publikacji strona meczu pokazuje +jednorazowy link do panelu serii (`/cykliczne/{id}`) przez `?cykliczne=`, a stały badge +„Stała gierka" (organizator, w pasku u góry strony meczu) prowadzi tam samo z powrotem. +Patrz „Serie wydarzeń cyklicznych" niżej. + +**Krok 3 — mecz w ramach grupy.** Wiersz pod kartami widoczności otwiera +`components/events/WybierzGrupeDialog.tsx` (bottom sheet od najmniejszych ekranów, +wyśrodkowana karta od `sm:`) z listą `getMyGroups()`. Wybór trafia do `createEvent` +jako `groupId`. Wiersz jest **osobny od widoczności**, bo przypisanie do grupy jest +wobec niej ortogonalne: mecz grupy bywa publiczny. Wejście `?group=` preselekcjonuje +ten sam stan. Ten sam dialog reużyty jest na stronie meczu (badge grupy w pasku u góry, +tylko dla organizatora) — patrz sekcja „Strona meczu" niżej. + +„Załóż grupę"/„Załóż nową grupę" **nie prowadzi na `/grupy/nowe`** — otwiera drugi tryb +tego samego dialogu, okrojony formularz (nazwa + sport) w tym samym oknie. Nawigacja na +osobną trasę wyrzucała organizatora z kreatora w połowie wypełniania; po `createGroup()` ++ `getGroup()` dialog wywołuje ten sam `onWybierz(grupa)` co wybór z listy — zamyka się +i wraca dokładnie na krok 3, z nowo założoną grupą już wybraną. + +**Powrót po publikacji.** „← Wróć" na stronie świeżo utworzonego meczu (`?utworzono=1`) +prowadzi na `/moje-gry`, nie `router.back()` — cofanie wracało do wypełnionego kreatora. +Wejścia z listy, mapy czy linku zachowują zwykłe „wstecz". + +--- + +## Podsumowanie przed publikacją + +Ostatni krok kreatora kończy się kartą **„Tak zobaczą to gracze"** +(`app/wydarzenia/nowe/PodsumowanieMeczu.tsx`, logika w `lib/eventSummary.ts`). Powód: +przycisk „Opublikuj mecz" stoi na kroku 3, a data, miejsce, skład i cena były ustawiane na +krokach 1–2 i w chwili publikacji nie były widoczne. + +Sześć wierszy — Co / Kiedy / Gdzie / Skład / Koszt / Kto widzi — każdy z przyciskiem +„Zmień" wołającym `attemptGoToStep`. Cofanie nigdy nie waliduje, więc skok jest bezpieczny +z każdego wiersza. Siódmy wiersz to **organizator**: „Wyświetlasz się jako X" z edycją +inline przez `updateDisplayName`; gdy konto nie ma **pełnej** nazwy własnej (imię +i nazwisko — `lib/profileName.ts#isPelneImie`, nie tylko dowolnie niepuste pole), pole +startuje rozwinięte. + +Trzy ostrzeżenia, które **nie blokują** publikacji (krok 3 celowo nie ma pól wymaganych — +`validateStep3` zwraca `{}`): mecz jest dzisiaj, miejsce zostało bez nazwy (same +współrzędne po nieudanym reverse geocodingu), cena bez wybranej metody płatności. + +--- + +## Po publikacji: „Mecz gotowy — wyślij link" + +Kreator przekierowuje na `/wydarzenia/{id}?utworzono=1`, a strona meczu pokazuje +organizatorowi odrzucalny panel: „Wyślij link znajomym" (pełna szerokość, systemowy +share sheet — nie ogranicza się do członków żadnej grupy), pod nim „Kopiuj link" i „Zaproś +z grupy" (to już konkretnie funkcja Grupy — `InviteFromGroupDialog`), na dole jedno zdanie +o konsekwencji wybranej widoczności. + +Parametr czytany jest z `window.location.search` w `useEffect`, **nie** przez +`useSearchParams()` — ten hak wymusza na trasie prerenderowanej bail-out do CSR i wywala +produkcyjny build (pułapka opisana w `AGENTS.md`). Zaraz po odczycie parametr znika +z adresu przez `history.replaceState`, więc odświeżenie nie pokazuje panelu drugi raz. + +Gdy kreator utworzył razem z meczem szablon cykliczny (kafelek na kroku 2), doszedł +`?cykliczne=` — czytany tym samym `useEffect` i zdejmowany tak samo. Panel dostaje +wtedy dodatkowy link „Ustawiłeś powtarzanie co tydzień — zarządzaj serią" do +`/cykliczne/{id}`. + +**Jeden link i jeden tekst dla całej aplikacji** — `lib/eventShare.ts`. `eventUrl()` zwraca +adres kanoniczny `/wydarzenia/{id}`, a nie krótki `/d/{kod}`: `robots.ts` trzyma `/d/` poza +indeksowaniem, więc crawlery Facebooka i WhatsAppa nie pobiorą Open Graph i taki link leci +na czat bez podglądu. `eventShareText()` składa cztery linie (sport i tytuł / dzień, data, +zakres godzin / miejsce z adresem / liczba miejsc i cena), a `shareEvent()` przekazuje je +do arkusza systemowego razem z adresem — osobno od tekstu, żeby podgląd linku działał. + +Trasa `/d/[code]` zostaje żywa dla linków już rozesłanych; zniknęła tylko jako drugi, +konkurencyjny przycisk „Udostępnij" na tej samej stronie. + +--- + +## Serie wydarzeń cyklicznych + +Od migracji `073` termin cykliczny to prawdziwa **seria**, nie zbiór niepowiązanych kopii. +Moduł jest dziś schowany za `SHOW_RECURRING = false` (patrz „Flagi funkcji" wyżej) — +kod, istniejące serie i ich strony zarządzania zostają nietknięte, chowają się wyłącznie +wejścia w nawigacji. Model, żeby nie duplikować schematu `events` w `recurring_events` — +pełny opis w [domena.md](./domena.md): + +- **szablon** (`recurring_events`) niesie regułę powtarzania: dzień tygodnia, godzina, + miejsce, limit miejsc, widoczność i wyprzedzenie (`notify_days_before`), +- **ostatni termin serii** jest żywym wzorcem reszty ustawień (cena, metody płatności, + bramkarze, akceptacja zapisów, grupa) — nowy termin dziedziczy je z niego, nie z ubogiego + szablonu. To naprawia dawny błąd, w którym płatna gierka odradzała się jako darmowa. + +**Auto-tworzenie.** `pg_cron` (jeśli włączony w Supabase) odpala co godzinę +`utworz_nalezne_terminy_serii()`, która dla każdego aktywnego szablonu tworzy należny +termin — gdy jest w zasięgu `notify_days_before` i jeszcze nie istnieje. Bez `pg_cron` +funkcja działa tylko wywołana ręcznie z SQL Editora albo przez przycisk „Utwórz termin" +na `/cykliczne/[id]` (`spawnEventInstance()` w `lib/recurring.ts` woła to samo RPC — +`utworz_termin_serii` — więc ręczne i automatyczne tworzenie dają identyczny wynik). +Uczestnicy poprzedniego terminu dostają wtedy powiadomienie „Nowy termin stałej gierki". + +**Edycja jednego meczu z serii.** Zmiana godziny w modalu „Zmień termin" albo zapis +formularza edycji (gdy seria ma więcej niż jeden termin) pyta o zakres — +`components/events/ZakresEdycjiSerii.tsx`, logika w `lib/series.ts`: + +| Zakres | Co obejmuje | +|---|---| +| Tylko to wydarzenie | sam edytowany termin | +| To i przyszłe | ten termin + terminy z datą ≥ dzisiaj + szablon (żeby kolejne dziedziczyły) | +| Cała seria | wszystkie terminy, także rozegrane, + szablon | + +**Data nigdy nie idzie zbiorczo** (`lib/series.ts#POLA_POZA_ZAKRESEM`) — niezależnie od +zakresu zmienia się wyłącznie w edytowanym terminie. Przesunięcie całej gierki na inny +dzień tygodnia to zmiana reguły, czyli edycja szablonu, nie zbiorcza zmiana terminów. + +**Edycja szablonu** — `/cykliczne/[id]/edytuj` (dawniej zaślepka „w przygotowaniu"). +Pola pokrywają się z `/cykliczne/nowe`: sport, miejsce, dzień tygodnia, godzina, limit, +tytuł/opis, widoczność, wyprzedzenie — bo szablon opisuje regułę, nie komplet ustawień +meczu (cena i płatności edytuje się na konkretnym terminie, z pytaniem o zakres wyżej). + +--- + +## Układ `/moje-gry` + +Cztery zakładki w URL (`?tab=`): **Nadchodzące** (`nadchodzace`) / **Historia** +(`historia`) / **Zaproszenia** (`zaproszenia`) / **Obserwowane** (`obserwowane`). +`SLUG_TO_TAB`/`TAB_TO_SLUG` w `app/moje-gry/page.tsx` — nieznany `?tab=` cicho wraca do +„Nadchodzące", nie rzuca błędem. Pasek zakładek scrolluje się w bok (`overflow-x-auto` +z ukrytym scrollbarem, `shrink-0` na każdym przycisku) — cztery zakładki + dwie plakietki +liczników nie mieściły się zawsze na 360px. + +Zakładka „Nadchodzące" renderuje **te same komponenty co pulpit zalogowanego** +(`components/home/dashboard/`), zamiast własnej, osobno utrzymywanej listy: +`InvitesSection` (limit 3, link do zakładki „Zaproszenia") → `NeedsPlayersSection` → +`NextMatchCard` → `MyMatchesSection`. Sekcje „Twoje grupy" i „Otwarte mecze" **nie** są tu +powtórzone — mają własne strony (`/grupy`, `/wydarzenia`). `NextGroupMatchTeaser` (niżej) +też nie — to specyficznie dla pulpitu (`AppHome.tsx`), `/moje-gry` skupia się na meczach, +nie na ekipach. + +**„Twoja ekipa gra wkrótce" (`NextGroupMatchTeaser`, `DashboardSections.tsx`)** — na +pulpicie zalogowanego, między `NextMatchCard` a `MyMatchesSection` (zgłoszone wprost: +ma stać NAD „Twoje najbliższe mecze", zanim trzeba przewijać do „Twoje grupy" niżej). +Pokazuje ekipę z najbliższym nadchodzącym meczem — ikona, nazwa, termin, pasek +zapełnienia składu — jako link do `/grupy/[id]`, tym samym stylem karty co `KartaEkipy` +na `/grupy`. Dane z `groupEvents`/`groups` w `useDashboardData()`, zero dodatkowego +zapytania: `getMyGroupEvents()` (`lib/events.ts`) sortuje po `event_date` w SQL-u, ale bez +godziny jako drugiej kolumny sortowania, więc komponent doprecyzowuje sort po +`date+time` po stronie klienta, zanim weźmie pierwszy element. Renderuje `null`, gdy +żadna ekipa nie ma nadchodzącego meczu, albo gdy mecz nie da się dopasować do żadnej +z grup usera (np. rozjazd danych) — cicha porażka, nie krzykliwy błąd na pulpicie. + +**„Brakuje graczy"** (`NeedsPlayersSection`, `components/home/dashboard/DashboardSections.tsx`) +— organizowane, nadchodzące mecze, które jeszcze nie mają kompletu, sortowane od +najbliższego terminu. Odpowiada na pytanie, na które `/moje-gry` dotąd nie miało jak +odpowiedzieć: „na który z moich meczów nie zbiera się skład". Dane są już pobrane przez +`getMyParticipatedEvents()` (`participantsCount` liczy `toEvent()` z dołączonego +`event_participants`) — zero nowego zapytania. Renderuje `EventBrowseCard`, tak jak +`MyMatchesSection` niżej — to osobna, DODATKOWA sekcja, nie zamiana świadomie +scalonej listy „organizujesz + grasz" (patrz komentarz w `lib/myEvents.ts`). + +**Zakładka „Obserwowane"** to osobna lista `EventBrowseCard` (wzorem „Historii"), nie +sekcja pulpitu — obserwowane mecze mają teraz **jedno** miejsce, nie dwa: wcześniej +`ObservingSection` renderowała się też inline pod „Nadchodzące", co dublowało tę samą +informację w dwóch miejscach tej samej strony. + +Różnica względem pulpitu: `MyMatchesSection` dostaje `limit={null} href={null}` — pełna +lista bez obcięcia do 2 pozycji i bez linku „Wszystkie" wracającego na tę samą stronę. + +Brak osobnego pustego stanu dla „Nadchodzące": `NextMatchCard` ma własny („Nie masz +zaplanowanych gier" + „Stwórz mecz" / „Znajdź grę"), więc pokrywa przypadek zerowej +aktywności bez drugiej kopii tego ekranu. + +**`NextMatchCard` (wypełniony stan) renderuje `EventBrowseCard`** — ten sam komponent +karty co reszta sekcji „Twoje najbliższe mecze" i zakładka „Historia" — pod etykietą +„NAJBLIŻSZY MECZ", zamiast własnego, większego markupu (osobny pasek postępu, przycisk +„Udostępnij"). Konsekwencja: dedykowany przycisk „Udostępnij" na tej karcie zniknął — +mecz nadal da się udostępnić ze strony szczegółów wydarzenia. Pusty stan zostaje bez +zmian, to nie on był „za duży". + +Nagłówek „Twoje mecze" i przycisk „+ Nowy mecz" zniknęły ze strony — mecz tworzy się +z FAB-a (`+`) w dolnej nawigacji, dostępnego z każdego ekranu na mobile. + +**Zakładka „Historia" ma na górze sekcję „Do rozliczenia"** (`DoRozliczeniaSection`, +`components/home/dashboard/DashboardSections.tsx`) — rozegrane, płatne mecze organizatora, +w których ktoś ze składu nie oddał pieniędzy. Selektor `doRozliczenia()` +(`lib/myEvents.ts`) filtruje i sortuje od najświeższego dane, które `getMyParticipatedEvents()` +już zwraca (`unpaidCount` liczony przez `toEvent()`) — zero nowego zapytania. Bez tej +sekcji zakładka Historia nie odróżniała meczu w pełni rozliczonego od meczu z zaległością — +oba wyglądały identycznie na płaskiej liście. + +--- + +## Karta „Po meczu" + +**Problem.** Po starcie meczu + 30 minut (`resultsAvailable`) strona meczu pokazywała +organizatorowi wyłącznie jedną bursztynową linijkę „wpisz wynik". Nic nie przypominało +o rozliczeniu ani o zaproszeniu gości bez konta do Bojo — organizator musiał sam +wywnioskować, co jeszcze zostało. Skutek widoczny w danych: większość rozegranych meczów +nie ma wpisanego wyniku, sporo nie ma domkniętego rozliczenia, a wpisy gości prawie nigdy +nie są przejmowane. + +**Rozwiązanie.** `components/events/PoMeczuCard.tsx`, renderowana w `EventDetailClient.tsx` +pod warunkiem `tab === 'sklad' && (isOwner || canManageSquad || canManagePayments) && +resultsAvailable && !isCancelled` (drugi i trzeci człon warunku uprawnień: delegaci, patrz +[„Uprawnienia (delegowanie)"](#uprawnienia-delegowanie) niżej). Zbiera do trzech zadań — +każde renderowane tylko, gdy dotyczy tego meczu: + +| Zadanie | Warunek renderowania | „Zrobione" | +|---|---|---| +| Rozlicz ekipę | `event.costGrosze > 0` | nikt nie ma `hasPaid === false` wśród `regulars` | +| Wpisz wynik | `event.trackResults` | `matchResult != null` | +| Zaproś gości do Bojo | są nieprzejęci goście w składzie | znika, gdy `0` | + +Karta żyje **wyłącznie w zakładce Skład** — świadomie NIE jest uniwersalna, bo dubluje się +z jej własną treścią (roster, zarządzanie graczami); na pozostałych zakładkach znika razem +ze zmianą zakładki (patrz „Zakładki na `/wydarzenia/[id]`" niżej). Zadania, które kiedyś +przewijały do sekcji na tej samej stronie, dziś żyją na osobnych zakładkach — samo +`scrollIntoView`/`href="#..."` by nie trafiło. Klik na „Wpisz wynik" woła `onWpiszWynik` → +`goToTab('wynik')`. Klik na +„Rozlicz ekipę" nadal woła `handleWyslijRozliczenie()` (generuje tekst i otwiera arkusz +udostępniania — nie przełącza zakładki, bo nie musi). Zadanie „Zaproś do Bojo" łączy oba: +`handleZaprosGosciaPoMeczu()` najpierw `goToTab('sklad')` i `setRosterOpen(true)` (skład po +meczu jest domyślnie zwinięty do awatarów), dopiero potem `scrollIntoView` po re-renderze +(`requestAnimationFrame`) — bez przełączenia zakładki scroll trafiał w pustkę, gdy karta +była widoczna z innej zakładki niż Skład. + +Pod zadaniami stoi zawsze wiersz dwóch przycisków: **„Kto nie przyszedł"** (widoczny tylko +dla `isOwner || canManageSquad` — otwiera modal oznaczania nieobecności, patrz +[„Oznaczanie nieobecności"](#oznaczanie-nieobecnosci) niżej) i **„Powtórz mecz"** (zawsze). +Gdy wszystkie zadania są zrobione (albo mecz żadnego nie śledzi), karta zwija się do jednej +linii tekstu nad tym samym wierszem przycisków. + +„Powtórz mecz" pojawia się teraz w dwóch miejscach (tu i w „Zarządzaj wydarzeniem"), ale to +ta sama akcja pod tą samą etykietą i ikoną (`handleOpenRepeat`) — nie dwie różne rzeczy pod +wspólną nazwą jak w `O-20` z audytu przepływu organizatora. + +**Okno „Powtórz mecz" ma domyślną datę i zachowuje długość meczu.** Otwierało się dotąd +z pustym polem i zablokowanym przyciskiem. `domyslnyTerminPowtorki()` (`lib/recurring.ts`) +liczy najbliższy przyszły termin tego samego dnia tygodnia co pierwowzór — ta sama +matematyka, którą `nastepnyTermin()` już robi dla serii cyklicznych. Pole zostaje +edytowalne. + +Modal ma teraz też pole „Koniec" obok „Godziny" — wcześniej zmiana samej godziny startu +(np. z 18:00 na 10:00) kopiowała `end_time` źródłowego meczu dosłownie, co potrafiło dać +kopię „trwającą" 690 minut. Zmiana startu przesuwa koniec o tę samą deltę (zachowuje +długość), zmiana końca nigdy nie rusza startu — dokładnie ten sam wzorzec co w modalu +„Zmień termin" (`toMinutes`/`fromMinutes`, wydzielone do `lib/time.ts`). + +--- + +## Oznaczanie nieobecności + +**Problem.** Organizator nie miał jak oznaczyć, że ktoś zapisany na mecz się nie pojawił — +jedyną drogą było ręczne zapamiętanie i unikanie tej osoby przy kolejnym zapraszaniu. +Infrastruktura istniała od migracji `011` (tabela `player_reports`, `get_player_stats()` +już liczyła `no_shows`), ale nic w aplikacji do niej nie zapisywało. + +**Rozwiązanie.** Przycisk „Kto nie przyszedł" w karcie „Po meczu" (widoczny dla +`isOwner || canManageSquad`) otwiera dedykowany modal z listą `regulars` i przełącznikiem +przy każdej osobie (`lib/attendance.ts`: `getNieobecni`/`oznaczNieobecnosc`/ +`cofnijNieobecnosc`). **Świadomie osobny modal, nie kontrolka w głównym widoku składu** — +oznaczenia nie mają wpływać na to, co widzi reszta uczestników na stronie meczu. + +Zapis idzie do `player_reports` (`report_type = 'nie_przyszedl'`) i od razu wpływa na +publiczny profil gracza (`/gracz/[id]`) — pasek frekwencji i plakietka „Niezawodny", patrz +[docs/domena.md § Reputacja](./domena.md#reputacja--dwa-niezależne-mechanizmy-nie-jeden). +Migracja `091` dodaje unikalny indeks (chroni przed podwójnym zawyżeniem licznika) i +zaostrza RLS — INSERT/DELETE/SELECT na `player_reports` wymaga teraz organizatora albo +delegata z `can_manage_squad` (wcześniej: dowolny zalogowany użytkownik). + +Wiadomość rozliczeniowa (`tekstRozliczenia()`, `lib/settlementShare.ts`) dopisuje przy +zalegającym oznaczonym jako nieobecny adnotację „(nie przyszedł/-a)" — ekipa widzi kontekst +długu, nie samą kwotę. + +--- + +## Czy gramy? — próg minimum, otwarcie dla okolicy + +**Problem.** Ekipy grające co tydzień odtwarzały ręcznie w wątku na WhatsAppie +dokładnie ten model, który Bojo już ma — a całą resztą wątku była praca biurowa +organizatora: „Brakuje nam 1go? Dobrze liczę?", „10 to minimum żeby zagrać", „Może +jeszcze ktoś się decyduje?". + +**Rozwiązanie.** `CzyGramyPanel.tsx` (`components/events/`), widoczny na stronie meczu +wyłącznie dla organizatora/delegata z `canManageSquad`, przed startem meczu. Dwa +niezależne bloki, każdy renderuje się tylko wtedy, gdy ma o czym mówić: + +1. **Werdykt progu** — gdy organizator ustawił `min_players` (kompaktowy toggle „+ Ustaw + minimum, żeby gra się odbyła" w `EventCapacityFields.tsx`, obok stepperu liczby + miejsc): „Gramy ✓ 11 z 10 minimum" albo „Brakuje 2 do minimum — 8/10". Liczy to jedna + czysta funkcja, `werdyktGry()` (`lib/events.ts`) — ten sam werdykt na stronie meczu + i w linijce pod „Najbliższym meczem" na `/grupy/[id]`. Toggle żyje **wyłącznie + w edycji** (`/wydarzenia/[id]/edytuj`) od 2026-08-16 — kreator (`/wydarzenia/nowe`) + nie przekazuje `onMinPlayersChange` do `EventCapacityFields`, więc sekcja się tam + w ogóle nie renderuje (prop opcjonalny, komponent gasi ją sam). Zgłoszone wprost jako + zbędny krok przy zakładaniu meczu; istniejące progi i ich logika zostają nietknięte. +2. **„Otwórz dla okolicy"** — dla prywatnego meczu z wolnymi miejscami, niezależnie od + tego, czy jest przypięty do grupy. Woła istniejący `handleSetVisibility('public')` + (ten sam kod co ręczny przełącznik widoczności), z potwierdzeniem tłumaczącym, co się + stanie. To jedyna rzecz w tym panelu, której żaden komunikator nie potrafi: zamienia + prywatny brak ludzi w publiczną podaż na `/wydarzenia`. + +**„Nie gram"** (`NieGramButton.tsx`) — osobny, mały przycisk dla członka ekipy, który +jeszcze nie dołączył do meczu przypiętego do jego grupy. Zapisuje wiersz w +`event_declines` (migracja `097`) — **nie** w `player_reports`, które karmi +„Niezawodność" wyłącznie ze zgłoszeń nieobecności na mecz, na który ktoś się zapisał; +wcześniejsza odmowa jest zachowaniem dobrym. Da się cofnąć („Nie gram — cofnij"). + +**Panel miał wcześniej trzeci blok, „Nie odpowiedziało: N"** (kto z ekipy jeszcze nie +zareagował na mecz, z przyciskami „Zapytaj w Bojo"/„Tekst na WhatsAppa") — usunięty na +wyraźną prośbę: zamiast ścigać milczących, prostszą odpowiedzią na „brakuje ludzi" jest +„Otwórz dla okolicy" powyżej. `lib/eventResponses.ts` (`ktoMilczy()`, `zapytajMilczacych()`) +i `tekstZaczepki()` z `lib/eventShare.ts` usunięte jako martwy kod — nic już ich nie +importuje. RPC `zapytaj_milczacych()` i typ powiadomienia `pytanie_o_udzial` (migracja +`097`) **zostają w bazie** nietknięte (migracji się nie kasuje po wdrożeniu), po prostu +nic już ich nie wywołuje — `lib/notifications.ts` nadal umie wyświetlić taki wpis, gdyby +kiedyś powstał, ale od tej zmiany żaden nie powstanie. + +**Świadomie NIE zbudowane** (patrz `docs/domena.md § Czy gramy`): automatyczny zapis +milczących do składu, powiadomienie o każdej pojedynczej odpowiedzi, próg minimum na +poziomie szablonu serii cyklicznej. + +--- + +## Uprawnienia (delegowanie) + +**Problem.** Organizator, który sam nie gra albo dzieli się obowiązkami prowadzenia meczu +z kimś z ekipy, nie miał jak przekazać części swoich praw — jedyną opcją było dawanie +komuś danych logowania do własnego konta. + +**Rozwiązanie.** Panel „Zarządzaj wydarzeniem" → „Uprawnienia" (wyłącznie dla prawdziwego +organizatora) otwiera modal z listą kandydatów — uczestnicy meczu z kontem plus, jeśli +mecz jest przypięty do grupy, jej członkowie (`lib/eventDelegates.ts`: +`getDelegateCandidates`). Dla każdego trzy niezależne przełączniki: „Może edytować jak +organizator", „Dzieli składy i wpisuje wyniki", „Oznacza rozliczenia i BLIK" — zapisywane +per-osoba od razu przy zmianie, bez zbiorczego „Zapisz". + +Pełny model uprawnień, w tym dlaczego to trzy osobne przełączniki i jak są egzekwowane w +RLS (nie tylko w UI) → [docs/domena.md § Delegowanie uprawnień organizatora](./domena.md#delegowanie-uprawnień-organizatora). + +Delegat z `can_manage_payments` bez `can_edit` nie ma dostępu do pełnego formularza +edycji (RLS go tam nie przepuszcza) — dostaje lekki, samodzielny panel „Sposoby +płatności" obok karty „Podział kosztów" na stronie meczu, zapisujący przez RPC +`event_set_payment_settings()`. + +**Świadome ograniczenie zakresu**: delegat zarządza meczem wyłącznie ze strony +`/wydarzenia/[id]`. Dashboard, listy „Moje mecze" (poza jednym wyjątkiem dla delegatów +z `can_edit`, żeby mecz w ogóle im się pokazał, gdy sami nie grają) i etykieta +„organizator" w historii gracza nie uwzględniają delegacji w tej fazie. + +--- + +## Strony treści — `/jak-dziala-bojo`, `/dlaczego-bojo`, `/faq` + +Trzy statyczne strony serwerowe pod SEO/GEO/AEO, dodane pod strategię „pozyskiwanie +organizatorów" ([strategia.md §0](./strategia.md)). Wspólna powłoka +`components/tresc/StronaTresci.tsx` (+ `SekcjaTresci.tsx`, `SpisTresci.tsx` jako +`
`), treść jako dane w `frontend/src/content/*.ts` — testowalna bez renderowania, +wzorem `components/home/landing/content.ts`. + +| Trasa | Co zawiera | Źródło treści | +|---|---|---| +| `/jak-dziala-bojo` | cała ścieżka od kreatora po rozliczenie, w tym co dokładnie widzi zaproszony gracz i że dołączenie nie wymaga konta | `content/jakDziala.ts` | +| `/dlaczego-bojo` | tabela porównawcza z grupą FB/WhatsApp, argument na „moi gracze nie założą konta" | `content/dlaczego.ts` | +| `/faq` | 36 pytań w sześciu kategoriach | `content/faq.ts` | + +**FAQ ma jedno źródło.** `content/faq.ts` eksportuje `FAQ` (wszystko, renderowane na +`/faq`) i `FAQ_LANDING` (osiem pozycji oznaczonych `naLandingu: true`, pokazywane na +stronie głównej). `components/home/landing/content.ts` re-eksportuje +`FAQ_LANDING as LANDING_FAQ` zamiast trzymać kopię — `LandingFaq.tsx` i +`landingContent.test.ts` nie wiedzą, że coś się zmieniło. Oba miejsca renderują +`faqJsonLd()` (`lib/structuredData.ts`) nad dokładnie tą treścią, którą pokazują — +widoczny tekst i schema nie mają jak się rozjechać. + +**Uczciwość treści pilnowana testem.** `content/zakazaneFrazy.ts` trzyma dwie listy fraz: +`ZAKAZANE_NA_LANDINGU` (landing nie wspomina ich w ogóle — nawet przecząco, bo samo +przeczenie na czysto sprzedażowej stronie brzmi jak reklama) i `ZAKAZANE_WSZEDZIE` (strony +treści mogą o nich pisać wyłącznie w zdaniu, które je zaprzecza). `landingContent.test.ts` +i `tresciStron.test.ts` sprawdzają odpowiednio każdą z nich, plus dwa testy pozytywne: każda +wzmianka o powiadomieniach mówi „w aplikacji"/„pod dzwonkiem", każda wzmianka o SMS-ie mówi, +że Bojo go nie wysyła. + +**Nawigacja do stron treści:** link „Zobacz krok po kroku…" pod „Trzy kroki do składu" +(`LandingHowItWorks.tsx`) do `/jak-dziala-bojo`; link „Wszystkie pytania i odpowiedzi" +pod FAQ landingu (`LandingFaq.tsx`) do `/faq`; `SiteFooter.tsx` ma teraz dwie grupy +linków („Produkt", „Bojo") zamiast jednej płaskiej listy, z czterema nowymi stronami +w grupie „Bojo". Główna nawigacja (`Header.tsx`) zostaje bez zmian — dwie pozycje +(„Znajdź grę", „Mapa boisk") to świadomy wybór, dokładanie stron treści by je rozmyło. + +--- + +## Układ `/wydarzenia` — filtry, sortowanie, sekcje dzienne + +Widok jest rozdzielony na dwie warstwy: **`EventsListView.tsx`** (sama treść) i +**`EventsListClient.tsx`** (`
` + widok). Podział jest po to, żeby ten sam widok +mógł posłużyć za tło ekranu logowania — patrz niżej. + +**Nagłówek zależy od tego, kto patrzy.** Zalogowany na mobile (Header ma tu schowany +pasek, patrz wyżej) dostaje jeden wiersz: pole szukania (placeholder **„Znajdź grę"**, +bez osobnego `

`) + `MobileIdentityRow` (dzwonek, awatar); plakietka „Zaproszenia N" +schodzi pod spód, bo na 360px szerokości cała czwórka nie mieści się bezpiecznie w +jednej linii. Wylogowany (dowolna szerokość) i zalogowany na desktopie widzą klasyczny +układ: `

Znajdź grę

` + plakietka, potem osobny wiersz szukania z placeholderem +„Nazwa, boisko albo dzielnica…". Oba warianty pola szukania są osobnymi blokami JSX +(nie jednym elementem sterowanym media query) — dokładnie ten sam wzorzec, co mobile/ +desktop gałęzie w `Header.tsx`. + +**Jeden pasek kafelków**, w tej kolejności, scrolluje się w bok gdy nie mieści się w +jednej linii (`overflow-x-auto` z ukrytym scrollbarem): + +| Element | Zachowanie | +|---|---| +| **„Sortuj"** *(dropdown)* | `PillDropdown` (`components/ui/FilterPill.tsx`), single-select, aplikuje się **natychmiast** po kliknięciu opcji (nie przez szkic modala): Najbliższy termin *(domyślnie)* / **Najbliżej mnie** (pyta o lokalizację od razu, pokazuje „Szukam Cię…" w trakcie) / Najwięcej wolnych miejsc | +| **„Filtry"** *(przycisk → modal)* | otwiera `FilterSheet` z czterema suwakami: Kiedy / Odległość / Cena / Wolne miejsca | +| **Sport** *(dropdown)* | `PillDropdown`, multi-select, źródło `FOCUS_SPORTS` (4 opcje); „piłka nożna" łapie też `futsal` | +| „Wolne miejsca" *(toggle)* | odsiewa komplety (`participantsCount < maxPlayers`) — **inny** filtr niż suwak „Wolne miejsca" w modalu, patrz niżej | +| „Za darmo" *(toggle)* | `costGrosze === 0` | + +**Cztery suwaki w modalu** (`components/ui/RangeSlider.tsx` — jeden generyczny suwak, +etykieta wartości nad nim, opisy skrajów pod spodem; reużywany też w trybie gier na +`/mapa`). Skrajna prawa pozycja = brak ograniczenia: + +| Suwak | Zakres | Prawy skraj | +|---|---|---| +| Kiedy | Dzisiaj / Jutro / Ten tydzień / Ten miesiąc / Wszystko (5 pozycji) | Wszystko | +| Odległość | 1–20 km, krok 1 | Bez limitu | +| Cena | 0–100 zł, krok 5 | Bez limitu (0 zł = Za darmo) | +| Wolne miejsca | 0–14, krok 1 | 0 = dowolna liczba (nie ogranicza) | + +Suwak „Wolne miejsca" w modalu to **próg minimum** (`freeSpots(e) >= N`, +`filterByMinFreeSpots()`), świadomie osobny od toggle'a „Wolne miejsca" w pasku (który +tylko odsiewa komplety) — oba filtry łączą się przez AND, gdy oba aktywne. „Kiedy" nie +ma już opcji „Weekend" (zastąpiona „Ten miesiąc" — `matchesDateFilter` case `'miesiac'`, +`isSameMonth()` z `date-fns`). + +**Modal filtrów działa na szkicu, nie na żywym stanie** (styl Booking: wybierz kilka +rzeczy, potem zatwierdź). Otwarcie kopiuje bieżące `dateFilter`/`radiusKm`/ +`maxPriceGrosze`/`minFreeSpots` do stanu szkicu; dotykanie suwaków zmienia wyłącznie +szkic. Przycisk zatwierdzenia pokazuje na żywo `Pokaż N meczów` i dopiero jego kliknięcie +commituje szkic do prawdziwego stanu — jeśli suwak Odległości jest ustawiony i pozycja +użytkownika jeszcze nie jest znana, pyta wtedy raz o zgodę na lokalizację (przy odmowie +promień wraca do wyłączonego). „Sortuj" ma **własny**, niezależny geo-trigger (patrz +tabela wyżej) — nie czeka na zatwierdzenie modala. „Wyczyść" resetuje szkic bez +zamykania modala (i przy okazji resetuje `sortBy` do „Najbliższy termin" — wcześniej +zostawał); zamknięcie przez tło/X/Escape odrzuca szkic bez dotykania prawdziwych filtrów. + +**Licznik wyników nad listą usunięty** — zostaje tylko link „Wyczyść filtry", widoczny +wyłącznie gdy jest co czyścić. + +| Element | Zachowanie | +|---|---| +| Szukanie | po tytule, sporcie, boisku i **dzielnicy**, przez `foldText` — „pilka" znajduje „piłka" | +| Sekcje dzienne | Dzisiaj / Jutro / W tym tygodniu / Później — **tylko** przy sortowaniu po terminie | +| Stronicowanie | 20 pozycji + „Pokaż więcej"; licznik resetuje się przy zmianie filtrów | + +Sekcje dzienne wyłączają się przy sortowaniu po odległości i po liczbie miejsc: dwa +porządki naraz („po czasie" w nagłówkach, „po dystansie" w treści) wprowadzałyby w błąd. + +Logika filtrowania, grupowania, sortowania, promienia, ceny i minimalnych wolnych miejsc +(`filterByRadius`, `filterByMaxPrice`, `filterByMinFreeSpots`) żyje w +`lib/eventFilters.ts` — w komponencie nie dałoby się jej przetestować. Ten sam plik +eksportuje `multiLabel`/`toggleInArray` (etykieta dropdownu multi-select, przełącznik +wartości w tablicy) — reużywane przez sportowy dropdown na `/wydarzenia` **i** na +`/mapa` w trybie gier. + +**Modal filtrów** (`components/ui/FilterSheet.tsx`) jest wspólny z mapą boisk +(`VenueExplorer.tsx`) — jedna powłoka (portal do ``, bottom sheet na mobile, +wyśrodkowana karta od `md:`), różna wyłącznie treść sekcji. **Pigułki filtrów** +(`components/ui/FilterPill.tsx`: `PillDropdown`, `TogglePill`) też są wspólne z mapą. + +### Widok mapy w `/wydarzenia` (mobile-only) + +Przycisk obok dzwonka powiadomień (mobile, zalogowany) przełącza treść strony między +listą a mapą — **to nie jest nawigacja na `/mapa`**, tylko stan komponentu +(`viewMode: 'lista' | 'mapa'`) w tym samym `EventsListView`. Desktop zawsze pokazuje +listę (ma już osobny link „Mapa boisk" w nawigacji) — przełącznik jest `md:hidden`. + +Pigułka „Sortuj" **nie pokazuje się** w tym widoku — na mapie nie ma listy do +sortowania, chowa się razem z przełączeniem na `viewMode === 'mapa'` (`sortBy` samo +w sobie zostaje bez zmian, po prostu nie jest tu eksponowane w UI). + +Mapa (`components/map/GamesMapCanvas.tsx`, ładowany przez `next/dynamic({ ssr: false })`) +renderuje pinezki dla **całego już przefiltrowanego zbioru** (`sorted` z pipeline'u +strony) — bez własnego zapytania ograniczonego do widocznego kadru: zbiór publicznych +wydarzeń jest już w całości w pamięci (`getPublicEvents()`, bez limitu). Klastrowanie +przez `L.markerClusterGroup` (`leaflet.markercluster`) w nowym, współdzielonym +`components/map/GamesMarkersLayer.tsx` — ten sam komponent montowany też wewnątrz +`VenueExplorer.tsx` w trybie „Gry", patrz „Układ `/mapa`" niżej. Mapa robi +`fitBounds` na cały zbiór przy każdej zmianie filtrów. + +**Pinezka pojedynczego meczu** to kółko w kolorze sportu (`sportColor()`) z emoji +sportu w środku — odpowiada wprost na „jaki sport", bez potrzeby legendy — i etykietą +„kiedy + godzina" pod spodem (`matchWhenLabel(date, time)`: dziś · 18:00 / jutro · 18:00 +/ w piątek · 20:30 / 12 wrz · 18:00, ten sam format co gdzie indziej w apce, np. +`NextMatchCard`). Cena i reszta szczegółów zostają +w panelu po dotknięciu — na samej pinezce więcej tekstu byłoby nieczytelne. Klaster +(kilka meczów blisko siebie) pokazuje kolorowe kółko z liczbą, tym samym +`clusterDivIcon()` co klastry boisk na `/mapa`. + +Dotknięcie pinezki otwiera dolną kartę `EventBrowseCard` (ten sam komponent co lista), +bez natywnych popupów Leaflet: +- **Swipe w lewo/prawo** na karcie przełącza na kolejny/poprzedni mecz w tej samej + kolejności co pinezki (`swipeEventId()` w `lib/eventFilters.ts` — indeks w `rows`, + zawija się na końcach listy). Wykrywanie gestu: `lib/useSwipe.ts` (próg 50px, wymaga + wyraźnej przewagi ruchu poziomego nad pionowym, żeby nie kolidować ze scrollem). +- **Dotknięcie mapy poza pinezką zamyka kartę** — `GamesMarkersLayer` nasłuchuje + `map.on('click', …)` i czyści zaznaczenie; kliknięcie samej pinezki nie dociera do + tego listenera, bo Leaflet nie propaguje kliknięcia markera do mapy. +- **Przycisk „Zlokalizuj mnie"** (prawy dolny róg) — `components/map/LocateMeButton.tsx`, + wspólny z `/mapa` (patrz niżej), ikona `LocateFixed` (celownik), nie pinezka. + +### `/logowanie` na tle listy meczów + +`app/logowanie/page.tsx` renderuje pod kartą formularza **prawdziwy** `EventsListView` +(`components/auth/LoginBackdrop.tsx`), przykryty mgiełką `bg-black/20` + delikatnym +rozmyciem. `/logowanie` **zostaje zwykłą trasą**, nie modalem przechwytującym: większość +wejść na ten ekran to twarde `window.location.href`, których intercepting route i tak by +nie złapał, a trasa musi działać po odświeżeniu i z linku w mailu. + +Tło jest dekoracją i jest całkowicie bierne: `pointer-events-none`, `overflow-hidden`, +`aria-hidden` **oraz `inert`**. Samo `aria-hidden` nad kontenerem pełnym odnośników +byłoby błędem dostępności — czytnik ekranu ich nie widzi, ale Tab dalej w nie wchodzi. +React 18 nie zna propa `inert` (doszedł w 19), więc atrybut ustawiany jest przez `ref`. + +### Gdzie ląduje zalogowany + +Domyślny cel po zalogowaniu/rejestracji to **`/`** — strona główna, gdzie świeże konto +trafia na modal wyboru roli (niżej). `?next=` (brama kreatora, strona boiska, grupa, +dołączanie do meczu, przejęcie wpisu gościa) ma pierwszeństwo i zawsze wygrywa z +domyślnym celem. `AuthForm.tsx` i `app/auth/callback/page.tsx` (Google, magic link) +deklarują ten sam domyślny cel — wcześniej się rozjeżdżały (`/wydarzenia` kontra `/`), +co przy braku `?next=` dawało niedeterministyczny wynik zależny od kolejności async +między ręcznym `router.push` w `AuthForm` a efektem w `app/logowanie/page.tsx` +reagującym na zmianę stanu zalogowania. + +Konsekwencja: baner „Gracze zobaczą Cię jako…" (`UzupelnijProfilBanner`) renderuje się +**także na `/wydarzenia`**, nie tylko na pulpicie. Bez tego konto bez imienia — typowo +Google bez `full_name` — nie zobaczyłoby go nigdy. Powiadomienie z migracji `070`/`071` +tej luki nie zamykało (wyzwalacz w praktyce nigdy nie wstawiał wiersza — patrz sekcja +„Powiadomienia — co realnie istnieje" niżej); od migracji `086` RPC wołane z +`lib/auth.tsx` robi to niezawodnie dla świeżych kont. + +**Modal wyboru roli po rejestracji** (`components/onboarding/PostSignupRoleModal.tsx`, +montowany globalnie w `layout.tsx`) pokazuje się raz, tylko po organicznej rejestracji +(konto młodsze niż 10 minut, cel logowania jeden z `/`, `/wydarzenia`, `/moje-gry`, +`/mapa` — czyli bez konkretnego kontekstu w rodzaju dołączania do meczu albo przejęcia +wpisu gościa, sprawdzane przez `ostatniZamierzonyCel()` w `lib/powrotPoLogowaniu.ts`). +Proponuje „Jestem organizatorem" (`/grupy/nowe`, wizualnie pierwsze) albo „Jestem +graczem" (`/grupy` albo `/wydarzenia`). Zamknięcie krzyżykiem też oznacza wpis jako +widziany (`localStorage`, klucz `bojo:onboarding-rola:`) — nie wraca przy kolejnym +logowaniu. + +--- + +## Układ `/grupy` — lista ekip + +Karta ekipy pokazuje od razu to, po co się tu wchodzi: **kiedy gramy**, nie tylko nazwę. +`getMyGroupsZTerminem()` (`lib/groups.ts`) dociąga do listy grup najbliższy nadchodzący +mecz każdej z nich — dwa zapytania na cały ekran. Karta ma termin, miejsce i pasek +zapełnienia składu; gdy grupa nie ma terminu, pokazuje „Brak terminu" z odnośnikiem do +kreatora. **Lista jest posortowana po najbliższym terminie** (rosnąco: grupa z meczem +jutro przed grupą z meczem za miesiąc), nie po dacie założenia ekipy; grupy bez terminu +lądują na końcu, w kolejności `created_at` malejąco. Kod zaproszenia (jedyna droga +samodzielnego dołączenia, patrz niżej) żyje w dyskretnym wierszu na dole, nie w karcie +na pół ekranu jak wcześniej — otwiera bottom sheet (`KodGrupySheet.tsx`). + +**Formularz `/grupy/nowe`** dorównuje dziś zakładce Ogólne w ustawieniach — dochodzi +wgrywanie okładki (`CoverUpload`, ścieżka w storage generowana lokalnie przed +utworzeniem grupy, bo prawdziwe `id` powstaje dopiero po zapisie) i zdanie „Wszystko +zmienisz później w ustawieniach ekipy". Po utworzeniu formularz przekierowuje na +`/grupy/{id}?zapros=1` zamiast na goły `/grupy/{id}` — `GroupDetailClient` widząc ten +parametr od razu otwiera `ZaprosDoGrupySheet` i czyści adres (ten sam wzorzec, co +obsługa `?dolacz=`). Powód: ekipa z jedną osobą jest martwa, a chwila tuż po utworzeniu +to jedyny moment, w którym organizator na pewno chce zapraszać. + +## Układ `/grupy/[id]` + +Trasa jest rozdzielona na serwerowy `page.tsx` (z `generateMetadata`) i +`GroupDetailClient.tsx`, który składa cztery komponenty z `components/groups/`: +`NajblizszyMeczGrupy`, `RozmowaGrupy`, `SkladGrupy`, `StatystykiGrupy`. Metadane są tu +istotne, bo **strona grupy jest jednym z celów linku zaproszenia** `/g/[kod]` — bez nich +każde udostępnienie pokazywało generyczny tytuł całej aplikacji. + +Układ od góry: **niska belka** łącząca powrót, tożsamość ekipy i akcje w jednym rzędzie — +strzałka powrotu, mały kafelek 32×32 (okładka albo emoji sportu), nazwa, przycisk +„Zaproś" (otwiera `ZaprosDoGrupySheet.tsx`, widoczny tylko z `can_invite`) i na mobile +dzwonek (`NotificationBell`) na końcu. **Kod dołączenia i zębatka ustawień zniknęły +z belki** — miała za dużo elementów. Kod dołączenia żyje wyłącznie w arkuszu „Zaproś" +(`ZaprosDoGrupySheet`); ustawienia dostały własny wpis w pasku zakładek (Link do +`/grupy/[id]/edytuj`, stylowany identycznie jak reszta zakładek, widoczny dla +założyciela/`can_manage_members`) zamiast osobnej ikony. Awatar też zniknął — sam dzwonek +wystarczy, profil jest w dolnej nawigacji. Osobny wiersz pod belką niesie meta +(sport/miasto/boisko/liczba członków) — dawniej to wszystko zajmowało osobny wiersz +„← Ekipy" plus kartę nagłówka z okładką na pół ekranu, co zgłoszono wprost jako +zajmujące za dużo miejsca. + +**Nazwa ekipy w belce jest przełącznikiem, nie tylko tytułem** — kliknięcie rozwija +listę pozostałych ekip użytkownika (`getMyGroups()`) pod belką, z ikoną, nazwą i +strzałką ChevronDown, która się obraca po otwarciu; wybór innej ekipy nawiguje do jej +`/grupy/[id]`. Widoczne (nazwa klikalna, strzałka) tylko gdy jest co przełączać — dla +kogoś w jednej ekipie nazwa zostaje zwykłym `

`. Zamyka się automatycznie po wyborze +i przy każdej zmianie `id` w URL-u; tło na cały ekran (`fixed inset-0`) łapie kliknięcie +poza listą, tak jak każdy inny dropdown w apce. + +Zaraz pod belką stoją **zakładki** — nawigacja ma być +najwyżej, nad treścią którą przełącza, nie pod pierwszą kartą. **Belka i zakładki dzielą +jeden `sticky top-0` kontener** (poza zakładką Rozmowa, patrz niżej) — dwa osobne sticky +elementy na tej samej wysokości nakładałyby się na siebie zamiast układać w stos, więc +to jest jedna sticky całość, nie dwie. Poziome przewijanie zakładek na wąskim telefonie +nie pokazuje paska przewijania (`.scrollbar-hide` w `globals.css`). + +**„Najbliższy mecz" (`NajblizszyMeczGrupy.tsx`) jest widoczny wyłącznie w zakładce +Mecze** — to jest jej treść (skrót najbliższego terminu), nie uniwersalny nagłówek +strony; wcześniej wyświetlał się na każdej zakładce oprócz Rozmowy, co pod Statystykami +czy Składem po prostu zajmowało miejsce. **Tu żyje cotygodniowa pętla**: gdy grupa ma +nadchodzący mecz, ten sam komponent karty co na `/wydarzenia` (`EventBrowseCard`, z moim +statusem uczestnictwa) plus osobny przycisk „Udostępnij mecz" pod spodem; gdy nie ma, ale +ma historię, przycisk „Powtórz na {dzień} {data}" tworzy nowy termin jednym kliknięciem +(`repeatEvent()` + `domyslnyTerminPowtorki()`, ta sama data i godzina co poprzednio — +całą ekipę powiadamia trigger `powiadom_o_nowym_meczu_w_grupie`, migracja `072`/`093`); +gdy grupa nie miała jeszcze żadnego meczu, link prosto do kreatora. Środkowy FAB dolnej +nawigacji na trasie `/grupy/` sam prowadzi do `/wydarzenia/nowe?group=` +(`BottomNav.tsx`) — to samo działanie na desktopie robi tekstowy „+ Nowy termin" +w zakładce Mecze. + +Cztery zakładki plus link „Ustawienia" na końcu paska (nawiguje do `/grupy/[id]/edytuj`, +nie przełącza stanu `tab` — ta strona ma już własne zakładki Ogólne/Zaproszenia/ +Uprawnienia, więc nie duplikujemy ich treści tutaj — **zakładka Zaproszenia sama jest +widoczna tylko dla founder/`can_invite`**, Uprawnienia jak dawniej wyłącznie dla +foundera; kogo dana zakładka nie dotyczy, ten jej w ogóle nie widzi). + +**Link „Ustawienia" migał widoczny osobie bez żadnej roli w nowej ekipie** — `/grupy/[id]` +jest trasą dynamiczną: przejście z ekipy, gdzie ktoś jest założycielem, do ekipy, gdzie nie +ma żadnej roli, nie odmontowuje `GroupDetailClient`, tylko zmienia `id`. `load()` woła teraz +`setMember(false)`/`setPermissions(null)` na SAMYM POCZĄTKU, przed pobraniem danych nowej +ekipy — inaczej `permissions` z poprzedniej ekipy zostawało w stanie, dopóki nowe zapytanie +nie wróciło, i link „Ustawienia" (gated `perms.isFounder || perms.canManageMembers`) świecił +się przez chwilę komuś, kogo nie dotyczy. Ten sam wzorzec błędu naprawiono na +`/wydarzenia/[id]` — tam zakładka „Ustawienia" w ogóle nie była gated na poziomie przycisku, +tylko treści (patrz „Zakładki na `/wydarzenia/[id]`" niżej). + +**Mecze** +(nadchodzące/historia, jak dawniej — sekcja „Najbliższy mecz" nad zakładkami pokazuje +najbliższy termin raz; „Nadchodzące" niżej filtruje go z listy, żeby nie dublować tego +samego meczu na jednym ekranie) / **Rozmowa** (dawniej +„Tablica" — patrz niżej, różowa plakietka z liczbą nieprzeczytanych; własne wpisy nigdy +się nie liczą — wysyłający już je widział w momencie wysyłania) / **Skład** (mała belka +„Zaproś do ekipy" + kod dołączenia + ikona udostępnienia nad rzędem awatarów — ten sam +kod/link co w `ZaprosDoGrupySheet`, tylko bez otwierania arkusza; widoczna z tych samych +warunków co dawny przycisk „Zaproś" w belce, `member && can_invite` — **powyżej niej, +wyłącznie dla założyciela i wyłącznie gdy `memberCount > 30`, informacja „Nie musisz +dodawać do ekipy jak najwięcej osób — publiczny mecz i tak widzą gracze z okolicy"**: +duża prywatna ekipa zwykle znaczy, że organizator rozrasta grupę zamiast po prostu +otworzyć mecz publicznie (patrz „Otwórz dla okolicy" niżej) — potem rząd awatarów ++ lista, plakietka „Założyciel"/„Współorganizator", zębatka „Uprawnienia" rozwijająca +panel z czterema przełącznikami inline — dla założyciela — i kebab „Usuń z ekipy" dla +`can_manage_members`) / **Statystyki** (patrz „Wyniki i statystyki" w `docs/domena.md`; +kafelki liczbowe mają wspólną minimalną wysokość i wyśrodkowaną treść — „nadchodzące" +jest dłuższe niż sąsiednie etykiety i na wąskim telefonie łamie się do dwóch linii, bez +tego kafelek wyglądał na rozjechany względem reszty rzędu). +Zmiana uprawnień innego członka jest dostępna w dwóch miejscach o identycznej treści +panelu (`UprawnieniaCzlonkaPanel.tsx`): tu, w Składzie, i w Ustawieniach — obie ścieżki +działają tylko dla założyciela, bo politykę UPDATE na `group_members` ma wyłącznie on. + +**Rozmowa wygląda i przewija się jak WhatsApp**, nie jak lista wpisów odgórnie na +najnowszy. `RozmowaGrupy.tsx` wypełnia wysokością cały dostępny ekran (`h-full` w +elastycznym kontenerze rodzica — na tej zakładce `GroupDetailClient` ustawia stronę na +`h-[100dvh] overflow-hidden`, żeby po ukryciu `BottomNav` rozmowa sięgała do samego dołu +ekranu, zamiast zostawiać pod sobą pustą przestrzeń — a niska belka na tej jednej +zakładce traci `position: sticky` (zostaje zwykłym, statycznie pozycjonowanym elementem): +`sticky` wewnątrz `overflow-hidden`, nieprzewijalnego kontenera liczy punkt zaczepienia +inaczej niż przy zwykłym scrollu i belka lądowała niżej niż na pozostałych zakładkach — +zgłoszone wprost), chronologię +rosnącą (najstarsza u góry, najnowsza na dole) i composer pod listą, nie nad nią — +auto-scroll na dół po wejściu i po wysłaniu wiadomości, przycisk powrotu (strzałka w +kółku, `sticky` wewnątrz kontenera, nie `fixed` względem ekranu — dzięki temu nigdy nie +wchodzi w konflikt z dolną nawigacją) pojawia się dopiero, gdy ktoś odjedzie od dołu. +Wiadomości tej samej osoby pod rząd grupują się bez powtarzania nazwy, dni rozdzielają +wyśrodkowane pigułki („Dzisiaj"/„Wczoraj"/data), godzina siedzi w rogu dymka zamiast +w osobnym wierszu pod spodem. Przypięty wpis (`can_moderate_wall`) nie wskakuje na górę +listy — wisi jako osobny pasek nad kontenerem przewijania, tapnięcie przewija do niego. +Akcje (przypnij/usuń) chowają się pod małym „⋮" przy dymku, nie stoją stale widoczne. +`getGroupPosts()` (`lib/groupPosts.ts`) nie zmienił kontraktu — nadal zwraca +przypięty-pierwszy/malejąco (tego wciąż potrzebuje licznik nieprzeczytanych); kolejność +chronologiczną liczy sam komponent, do wyświetlenia. + +**„Opuść ekipę" mieszka pod listą w Składzie**, nie na dole strony grupy jak wcześniej — +`/grupy/[id]/edytuj` jest dostępne wyłącznie dla założyciela i `can_manage_members`, więc +zwykły członek bez żadnych uprawnień nigdy tam nie trafi; Skład jest jego jedyną drogą +wyjścia z ekipy. **„Usuń ekipę"** (wyłącznie założyciel) mieszka tylko w Ustawieniach → +Ogólne — nie duplikuje się już na stronie grupy. + +Zakładka trzyma stan w URL (`?tab=tablica` — nazwa parametru zostaje bez zmian mimo +etykiety „Rozmowa", żeby nie psuć zapisanych linków), ale przez +`window.history.replaceState`, **nie** `router.replace` jak na `/moje-gry`. Powód: +`/moje-gry` jest trasą statyczną i nawigacja nic nie kosztuje, a `/grupy/[id]` jest +dynamiczna — każde `router.replace` byłoby round-tripem po dane z serwera (łącznie +z `generateMetadata`), przez co adres w praktyce w ogóle się nie zmieniał. + +Członkostwo pochodzi z **osobnego** zapytania `isGroupMember()`, nie z listy członków: +gdy dogrywka danych padnie, członek grupy nie zobaczy przycisku „Dołącz do grupy". + +## Zakładki na `/wydarzenia/[id]` + +`EventDetailClient.tsx` ma od tej zmiany pięć zakładek nad treścią, analogicznie do +`/grupy/[id]`, ale dostosowane do pojedynczego meczu: **Skład** (domyślna — dawne „Info": +prośby o dołączenie, panel „Czy gramy?", licznik miejsc, awatary i lista uczestników, +podział na drużyny jako zwinięty panel — patrz niżej, „Wypisz się"/„Nie gram" i inne +banery statusu uczestnictwa, zarządzanie graczami, karta „Po meczu", panel „Zaproś +znajomych", status zaproszeń), **Rozmowa** (`RozmowaWydarzenia.tsx`, zastępuje dawny +komponent `EventComments` — usunięty, nic innego go nie importowało; **wyłącznie okno +czatu**, żadnych innych elementów), **Wynik** (drużyny i formularz wyniku — dawna +`skladWynikSection`), **Rozliczenia** (podział kosztów per uczestnik — dawna +`platnosciSection`) i **Ustawienia** (panel „Zarządzaj wydarzeniem", **domyślnie +rozwinięty** — dawniej zwinięty, bo był jedną z wielu kart na długiej stronie; teraz to +cała treść osobnej zakładki, więc zwijanie na wejściu nie miało już sensu: widoczność, +goście, edycja, powtórka, uprawnienia, odwołanie/przywrócenie, usunięcie). Stan zakładki +w `?tab=`, odczytany ręcznie z `window.location.search` przez `useEffect`, **nie** przez +`useSearchParams()` — ta trasa jest prerenderowana i ten hak wywala produkcyjny build +(`missing-suspense-with-csr-bailout`, patrz pułapka w `AGENTS.md`); dokładnie ten sam +powód, dla którego `?utworzono=`/`?cykliczne=`/`?dolacz=` na tej stronie też są czytane +ręcznie. + +**Zakładka „Ustawienia" znika z paska dla kogokolwiek bez `canManageEvent`** — treść +panelu zawsze była gated (`tab === 'ustawienia' && canManageEvent`), ale sam **przycisk +zakładki** renderował się dla każdego (`EVENT_TAB_LABELS.map(...)` bez filtra), więc ktoś +bez żadnej roli w meczu widział w pasku zakładkę, która po kliknięciu okazywała się pusta. +Pokrewny błąd co „Ustawienia" na `/grupy/[id]` (patrz „Układ `/grupy/[id]`" wyżej) — inny +mechanizm (tam stan nie zerował się między ekipami, tu przycisk zakładki w ogóle nie był +gated), ten sam efekt: widoczny, ale martwy element UI dla kogoś bez odpowiedniej roli. + +**Nazwa meczu przeniosła się nad zakładki** — pasek na samej górze to teraz `[Wróć]` +(bez etykiety, sama strzałka) + nazwa (`

` obcinany wielokropkiem), tak jak belka na +`/grupy/[id]`. „Udostępnij"/„Kopiuj", które wcześniej tam stały, przeniosły się **pod +zakładki** — w miejsce, które kiedyś zajmował `

`. To jest świadoma zamiana miejscami, +nie usunięcie: obie pary elementów zostały, zmieniła się tylko ich kolejność w pionie. +Ten pasek nazwy i zakładki dzielą jeden `sticky top-0` kontener (poza zakładką Rozmowa, +z tego samego powodu co na `/grupy/[id]`), a poziome przewijanie zakładek chowa pasek +przewijania (`.scrollbar-hide`). + +**Kilka elementów zostaje uniwersalnych** — renderują się niezależnie od aktywnej +zakładki (poza Rozmową, patrz niżej), bo dotyczą całego meczu, nie treści jednej +podstrony: baner odwołania meczu, panel „Mecz gotowy" tuż po publikacji, blok +„Udostępnij"/„Kopiuj" + chipy meczu (data/miejsce/cena/widoczność/grupa), sticky pasek +„Dołącz"/„Obserwuj" na dole ekranu oraz modale (zaproszenie z grupy, wybór grupy, zakres +edycji terminu serii). Bez tego np. osoba przeglądająca zakładkę Rozliczenia nie +widziałaby przycisku dołączenia do meczu. **Karta „Po meczu" (`PoMeczuCard`) NIE jest +uniwersalna** — żyje wyłącznie w zakładce Skład, żeby nie duplikować się z jej własną +treścią (roster, zarządzanie graczami) na każdej innej zakładce. + +**Zakładka Rozmowa nie pokazuje nic poza oknem czatu** — baner odwołania, „Mecz gotowy", +blok „Udostępnij"/chipy i sticky pasek dołączenia mają jawny warunek `tab !== 'rozmowa'`. +Bez niego uniwersalne elementy zaśmiecały jedyny ekran, który ma wyglądać jak zwykły czat. +Zakładka nosi różową plakietkę z liczbą nieprzeczytanych, tym samym mechanizmem co +Rozmowa/Tablica w `/grupy/[id]` (patrz „Kropki na »Moje« i »Grupy«" wyżej) — +`kluczRozmowyWidziano()`, własne komentarze wyłączone z liczenia. + +**Zakładka Wynik pokazuje treść uczestnikowi, nie tylko organizatorowi, zanim mecz się +zacznie.** Przed poprawką pusty ekran widział każdy, kto nie jest organizatorem/`can +ManageSquad` — trzy warunkowe bloki w `wynikFormSection` wymagały tej roli albo +`resultsAvailable`, a zwykły uczestnik przed startem meczu nie spełniał żadnego. Dziś +uczestnik widzi ten sam komunikat „Wynik pojawi się po zakończeniu meczu", co organizator +(z inną treścią — organizator widzi „Wynik można wpisać po rozpoczęciu…"). + +Karta „Po meczu" wskazuje zadania na innych zakładkach, więc jej przyciski **przełączają +zakładkę zamiast (albo obok) przewijania** — `onWpiszWynik` woła `goToTab('wynik')`, +`handleZaprosGosciaPoMeczu()` woła `goToTab('sklad')` przed `setRosterOpen(true)` i +`scrollIntoView`. Bez tego klik z innej zakładki niż cel trafiał w treść, która nie była +jeszcze zamontowana w DOM. Pełny opis → sekcja „Karta »Po meczu«" wyżej. + +**Podział na drużyny renderuje się w dwóch zakładkach naraz** — Skład (jako domyślnie +zwinięty panel z przyciskiem „Podział na drużyny" ▾, stan `druzynyOtwarteWSkladzie`) i +Wynik (zawsze rozwinięty, bo to jej główna treść). To nie są dwie kopie: obie zakładki +renderują dokładnie ten sam JSX (`druzynySection`, wydzielony ze `skladWynikSection`) na +tym samym stanie z rodzica (`teamA`/`teamB`/handlery `TeamsPanel`), więc zmiana w jednym +miejscu — przypisanie gracza, losowanie, publikacja — jest natychmiast widoczna w drugim +bez żadnej synchronizacji: to dosłownie ten sam stan React, wyświetlony dwa razy. + +`RozmowaWydarzenia.tsx` to ten sam mechanizm i wygląd co `RozmowaGrupy.tsx` (chronologia +rosnąca, grupowanie wiadomości tej samej osoby, separatory dni, własny scroll z +auto-przewijaniem i przyciskiem powrotu, composer pod listą), ale **bez przypinania i bez +moderacji** — dane to płaskie `event_comments` (`lib/comments.ts`), bez kolumny na +przypięcie i bez odpowiednika `can_moderate_wall` na poziomie meczu. Każdy usuwa +wyłącznie swoją wiadomość, tak jak w dawnym `EventComments`. **Widoczna dla uczestników, +organizatora (bez względu na to, czy sam gra) i — gdy mecz jest przypięty do ekipy — dla +całej ekipy** (`myParticipation || isOwner || czlonekGrupyMeczu`, ten ostatni z osobnego +`isGroupMember()` doładowanego razem z `groupInfo`) — dawne komentarze widzieli wyłącznie +zapisani uczestnicy, co odcinało organizatora niegrającego i resztę ekipy od rozmowy +o własnym meczu. Na tej zakładce strona zachowuje się jak `/grupy/[id]` na Rozmowie: +`BottomNav` chowa się (`HideBottomNav`), a strona dostaje `h-[100dvh] overflow-hidden`, +żeby czat sięgał do dołu ekranu. Klawiatura ekranowa nie zostawia już pustej przestrzeni +pod composerem — `viewport.interactiveWidget: 'resizes-content'` w `app/layout.tsx` każe +przeglądarce faktycznie skurczyć layout (a więc i `100dvh`) razem z klawiaturą, zamiast +tylko przesuwać widoczny fragment stałej wysokości strony. + +## Uprawnienia w grupie i lądowanie zaproszenia `/g/[kod]` + +**Cztery niezależne przełączniki** (`can_manage_members`, `can_create_events`, +`can_invite`, `can_moderate_wall`, migracje `092`/`096`) — panel „Uprawnienia", dostępny +z dwóch miejsc: rozwijany przy członku w zakładce Skład i w osobnej zakładce +„Uprawnienia" na `/grupy/[id]/edytuj` (akordeon, rozwijany po imieniu). Obie ścieżki +widoczne wyłącznie założycielowi (RLS pozwala zmieniać te kolumny tylko jemu). Pełny +model → [docs/domena.md § Uprawnienia w grupie](./domena.md#uprawnienia-w-grupie). + +Strona ustawień grupy (`/grupy/[id]/edytuj`) ma od tej zmiany zakładki: **Ogólne** +(nazwa, sport, miasto, boisko, opis, okładka, strefa niebezpieczna), **Zaproszenia** +(link, kod, rotacja kodu) i, wyłącznie dla założyciela, **Uprawnienia**. + +**`/g/[kod]` to dziś lądowanie, nie sam redirect.** Serwerowy `page.tsx` czyta grupę, +najbliższy mecz i (gdy w adresie jest `?od=`, zweryfikowane w bazie) imię +zapraszającego kluczem anonimowym — `groups` i `group_members` są publicznie czytelne, +więc to działa bez konta. `ZaproszenieClient.tsx` renderuje to wszystko i, dla +wylogowanego, formularz rejestracji (`AuthForm` w trybie `signup`, `next` wskazuje +z powrotem na `/grupy/[id]?dolacz=&od=`) — dokładnie ta sama miękka ścieżka, +co przejęcie wpisu gościa (`/gracz/przejmij/[token]?auto=1`). Zalogowany odwiedzający +jest przekierowany od razu, bez migania tego widoku; `GroupDetailClient` widząc +`?dolacz=` dołącza go kodem automatycznie (`dolacz_do_grupy_kodem()`, migracja `094`) +i czyści adres. Stare linki `/grupy/[id]?join=1` (bez kodu) nadal się otwierają, ale +pokazują komunikat, że trzeba poprosić o nowy — bez kodu dołączenie od tej migracji nie +jest już możliwe (patrz niżej). + +**Dołączenie do grupy wymaga kodu — zawsze.** Migracja `094` zdjęła politykę INSERT na +`group_members`, którą wcześniej wystarczało obejść, znając samo UUID grupy (publicznie +czytelne). Jedyne drogi wejścia: `dolacz_do_grupy_kodem()` (trzeba znać kod), +`dodaj_czlonka_do_grupy()` (trzeba mieć `can_manage_members`) i trigger przy założeniu +grupy. `joinGroup()` (surowy INSERT) zostało usunięte z `lib/groups.ts` — +zastępuje je `joinGroupByCode()`. + +--- + +## Układ `/mapa` — szukanie, filtry, powrót z boiska + +**Szukanie po tekście działa poza bieżącym kadrem.** Wcześniej pole szukania filtrowało +wyłącznie `allFields` — to, co i tak było już wczytane dla widocznego fragmentu mapy: przy +oddaleniu (tryb skupisk) ta lista jest pusta, więc szukanie nic nie znajdowało; przy +przybliżeniu ograniczało się do tego, co widać, więc wpisanie miasta spoza kadru też nic +nie dawało. Od dwóch znaków zapytania (debounce 300 ms) `VenueExplorer` woła +`searchExplorerFields()` z `lib/api.ts` — funkcję, która już istniała (używają jej +pickery lokalizacji), tylko nigdy nie była tu wpięta — i mapa robi `fitBounds` do +wyników. Tryb skupisk wyłącza się na czas aktywnego szukania niezależnie od przybliżenia. + +**Powrót ze strony boiska wraca na ten sam obiekt.** Karta „Zobacz boisko" (`VenueCard`) +linkuje teraz z `?wroc=/mapa?boisko=` zamiast gołego `/boisko/`. Strona boiska +(`VenueDetailClient.tsx`) już umiała wrócić pod dowolny adres z parametru `wroc`, a +`VenueExplorer` już umiał obsłużyć `?boisko=` po wejściu z linku (`boiskoZLinku`) — +brakowało tylko połączenia obu gotowych mechanizmów. + +**Filtry — przycisk „Filtry" + modal, jak na `/wydarzenia`.** Sport i przełącznik +„Gry dziś" zostają zawsze widoczne; Typ obiektu i Nawierzchnia przenoszą się do +`FilterSheet` (ten sam współdzielony komponent, patrz „Układ `/wydarzenia`"), bo są +drugorzędne i rzadziej dotykane: + +| Filtr | Gdzie | Uwaga | +|---|---|---| +| Sport | inline, dropdown | źródło `MAP_FILTER_SPORTS` (`lib/sports.ts`) — **6** opcji, nie 4: dołożone `wielofunkcyjne` (4118 obiektów) i `piłka ręczna` (806), które miały już kolorową pinezkę na mapie, ale nie dało się ich wybrać w filtrze | +| „Gry dziś" | inline, przełącznik | bez zmian | +| Typ obiektu | w modalu | lista bez zmian, tylko przeniesiona z zawsze-widocznego dropdownu | +| Nawierzchnia *(nowość)* | w modalu | checklist: Trawa naturalna / Sztuczna trawa / Nawierzchnia twarda / Piasek / Beton / Mączka ceglana; etykiety przez `surfaceLabel()` z `lib/labels.ts` | + +„Otwarte gry" (obiekt ma co najmniej jeden mecz, na który da się jeszcze dołączyć) było +tu przez chwilę jako osobny przełącznik — usunięte jako zbędne obok „Gry dziś" i trybu +„Gry | Obiekty" (patrz niżej), którego tryb „Gry" pokazuje realnie otwarte mecze wprost jako pinezki. + +**Dlaczego Typ obiektu przestał być zawsze widoczny, a Nawierzchnia się pojawiła:** +`venue_type` ma dziś **98,3%** publicznych obiektów jako `NULL` (import z OSM go nie +ustawia) — wybranie jakiegokolwiek konkretnego typu wyglądało jak zepsuta wyszukiwarka, +bo odsiewało niemal cały katalog. `surface` ma dane w **37%** wierszy z realnym +zróżnicowaniem (trawa, nawierzchnia twarda, piasek, beton, sztuczna trawa, mączka) — to +jest facet, który realnie coś filtruje, mimo że wcześniej nie dało się po nim szukać. +Kolumna `surface` dołączona do okrojonego `EXPLORER_COLS` w `lib/api.ts` (istniała w +tabeli, po prostu nie była pobierana) — zero migracji. + +Modal ma tę samą mechanikę szkicu co na `/wydarzenia`: wybory w „Typ obiektu"/ +„Nawierzchnia" aplikują się dopiero po „Pokaż N obiektów", „Wyczyść" resetuje szkic bez +zamykania. Renderowany **raz** na komponent (nie raz na sidebar desktopu i raz na +mobilny overlay) — oba przyciski „Filtry" otwierają ten sam, współdzielony stan. + +**Licznik „Pokaż N obiektów" w trybie skupisk** (domyślny widok całej Polski, mapa +oddalona) liczy się z `wKadrze` (suma z kółek skupisk, uwzględnia już filtr sportu), +nie z `allFields` — w tym trybie `allFields` jest zawsze pustą tablicą (obiekty +pobiera się dopiero po przybliżeniu, patrz niżej), więc liczenie z niej dawało zawsze +„Pokaż 0 obiektów" niezależnie od tego, ile realnie było w kadrze. Typ obiektu +i Nawierzchnia i tak nie mają w tym trybie efektu (brak per-obiektowego rozbicia +w danych ze skupisk), więc podgląd pokazuje to, co faktycznie widać na mapie. + +Filtr nawierzchni działa **tylko w trybie pojedynczych obiektów** (przybliżenie ≥ próg +skupisk) — w trybie skupisk (oddalona mapa) nie jest przekazywany do +`getExplorerClusters()`, dokładnie tak jak już wcześniej działało „Gry dziś". Sport +i Typ obiektu działają w obu trybach — RPC `mapa_skupiska` przyjmuje +generyczne tablice `p_sporty`/`p_typy`, więc nowe wartości sportu przechodzą bez żadnej +zmiany funkcji. + +**Zalogowany na mobile** dostaje w tym samym pływającym wierszu co pole szukania również +`MobileIdentityRow` (dzwonek + awatar) — Header na tej trasie chowa swój pasek, patrz +„Górny pasek nawigacji" wyżej. + +**Przycisk „Zlokalizuj mnie"** (prawy dolny róg) ma ikonę `LocateFixed` (celownik) — +wcześniej był tu `MapPin` (pinezka), myląca ikona dla akcji „pokaż moją okolicę". +Wspólny komponent `components/map/LocateMeButton.tsx`, patrz niżej. + +### Tryb gier — przełącznik „Gry | Obiekty" + +Segmentowany przełącznik (`components/ui/SegmentedToggle.tsx`) na początku paska +przełącza **cały** pasek i **cały** `` między dwoma trybami, bez +remontowania mapy (zoom/pan usera zostaje, tylko podmieniają się warstwy pinezek). + +Wcześniej był to `TogglePill` „Pokaż gry" — wyłączony pill nie mówił, w jakim trybie +mapa jest teraz, tylko czego brakuje. Oba tryby są równorzędne, więc widać oba naraz; +semantyka i URL bez zmian („Gry" = dotychczasowe `?gry=1`). + +`SegmentedToggle` jest generyczny (dwie opcje `{ value, label }`, `role="radiogroup"`), +z kontenerem `grid grid-cols-2` — wskaźnik ma stałą szerokość połowy kontenera, więc +przy `flex` szerszy tekst przesunąłby podświetlenie obok przycisku, który podświetla. + +| | „Obiekty" (domyślnie) | „Gry" | +|---|---|---| +| Pasek | Sport(6, `MAP_FILTER_SPORTS`) / Filtry (Typ+Nawierzchnia) / Gry dziś | Filtry (suwaki) / Sport(4, `FOCUS_SPORTS`) / Wolne miejsca / Za darmo | +| Pinezki | boiska, `MapLayer`/`WarstwaSkupisk` (bez zmian) | mecze, `GamesMarkersLayer` (współdzielony z widokiem mapy w `/wydarzenia`, patrz wyżej — emoji sportu + etykieta „kiedy", swipe w panelu, zamykanie kliknięciem w puste miejsce mapy) | +| Źródło danych | `getExplorerFields`/`getExplorerClusters` (viewport-scoped) | `events` — **to samo**, co już pobierane wyżej dla `fieldStats`; zero nowego zapytania | +| Karta wyniku (mobile/sidebar) | `VenueCard` | `EventBrowseCard` | +| Modal „Filtry" | Typ obiektu + Nawierzchnia (bez zmian) | Kiedy / Odległość / Cena / Wolne miejsca (te same suwaki co `/wydarzenia`) | + +**Sortuj nie pojawia się w tym trybie** — `/mapa` jest zawsze widokiem mapy (w +odróżnieniu od `/wydarzenia`, gdzie ta sama pigułka ma sens na liście), więc kolejność +pinezek/karty sidebara zostaje na stałe chronologiczna (`gamesSort` to dziś stała +`'termin'`, bez UI do zmiany) — nie warto było duplikować UI, którego i tak nie ma gdzie +sensownie użyć na mapie. + +Stan trybu gier (`gamesSort`, `gamesDate`, `gamesRadius`, `gamesMaxPriceGrosze`, +`gamesMinFreeSpots`, `gamesOnlyFreeSpots`, `gamesOnlyNoCost`) jest **lokalny**, nie w URL +— spójnie z tym, że `/wydarzenia` też nie trzyma swoich filtrów w adresie. Jedyny stan +trybu w URL to sam przełącznik: `?gry=1`, ten sam wzorzec co `today`/`open`. + +Filtr `sports` jest **współdzielony** między oboma trybami (ten sam parametr URL +`?sport=`). Przełączenie na „Gry" ma guard: jeśli w `sports` jest wartość spoza +`FOCUS_SPORTS` (np. `wielofunkcyjne` — sensowna tylko jako opis obiektu, żaden mecz nigdy +nie ma takiego sportu), filtr się czyści zamiast po cichu zerować wyniki. + +--- + +## Powiadomienia — co realnie istnieje + +Wbrew starszym notatkom kanał powiadomień **jest zbudowany**: + +| Element | Gdzie | +|---|---| +| Tabela `notifications` | migracja `025` | +| Logika | `lib/notifications.ts` | +| UI (dzwonek) | `components/layout/NotificationBell.tsx`, renderowany w `Header.tsx` | +| E-mail | Edge function `notify-game-alert` → Resend | +| SMS | Edge function `send-event-sms` → SMSAPI + Twilio | +| Zaproszenia cykliczne | Edge function `send-invites` | + +Wpisy do `notifications` powstają wyłącznie z wyzwalaczy w bazie albo z wąsko +uprawnionych funkcji RPC (`SECURITY DEFINER`) — tabela ma polityki SELECT i UPDATE dla +własnych wierszy i **żadnej polityki INSERT**, więc przeglądarka nie może wpisać +powiadomienia nawet sobie bez przejścia przez taką funkcję. Dziś jest ich pięć: oferta +zwolnionego miejsca (`062`), akceptacja zapisu i zmiana terminu (`065`), imienne +zaproszenie (`067`) oraz **odwołanie meczu i konto bez nazwy** (`070`). + +**Konto bez nazwy — wyzwalacz z `070`/`071` w praktyce nigdy nie zadziałał.** +Potwierdzone zapytaniem po danych produkcyjnych: zero wierszy typu `uzupelnij_profil` +mimo dziesiątek kont bez pełnej nazwy, przyczyna nieznana. Migracja `086` dodaje RPC +`zglos_brak_pelnej_nazwy()`, wołaną z `lib/auth.tsx` przy `SIGNED_IN` dla świeżych kont +(< 10 min), tym samym warunkiem `isPelneImie()` co baner na pulpicie +(`UzupelnijProfilBanner.tsx`) — niezawodny odpowiednik po stronie klienta. Wyzwalacz +zostaje jako potencjalny drugi nadawca; `NOT EXISTS` w RPC chroni przed duplikatem. + +`NotificationBell` linkuje powiadomienie do meczu przez `event_id`; te bez `event_id`, +ale z `group_id` (ogłoszenie na tablicy grupy, migracja `093`) — na `/grupy/{group_id}`; +resztę bez żadnego z nich — przez mapę `TYP_NA_TRASE` (dziś: `uzupelnij_profil` → +`/profil`). Bez tego routingu renderowały się jako martwy, nieklikalny wiersz. + +**Nowy mecz w grupie ma wyzwalacz** — `powiadom_o_nowym_meczu_w_grupie()`, migracja +`072`: każdy `INSERT` do `events` z ustawionym `group_id` wstawia powiadomienie +wszystkim członkom grupy poza organizatorem. Jedyna otwarta luka wobec wizji to +`game_alerts` (promień + sport, oparte o lokalizację, nie o członkostwo) — wciąż za +flagą `SHOW_GAME_ALERTS`, [luka 2 wobec wizji](./wizja.md#3-luki), i to jest inna +funkcja niż powiadomienie o meczu w grupie. + +**Trzy nowe typy z migracji `097`** (patrz „Czy gramy?" wyżej): `pytanie_o_udzial` — +RPC `zapytaj_milczacych()`, wołana ręcznie przez organizatora, nie wyzwalacz; jedyny typ +w `WYMAGA_AKCJI` z zamknięciem po DWÓCH stronach (dołączenie **albo** jawna odmowa w +`event_declines` zamykają sprawę jednakowo). `gra_potwierdzona`/`gra_zagrozona` — +wyzwalacz `powiadom_o_progu_gry()` na `event_participants`, wzorem `079`: reaguje na +PRZEKROCZENIE `min_players` w obie strony, nie na każdy zapis, i pomija osobę, której +własny zapis/wypis spowodował zmianę (ona już wie). + +**Przypięty wpis na tablicy grupy też powiadamia** (`ogloszenie_w_grupie`, migracja +`093`) — jedyny typ wpisu na tablicy, który to robi; zwykły wpis nikogo nie powiadamia, +żeby dzwonek nie zamienił się w kanał czatu. + +**Komplet i zwolnione miejsce (migracja `079`).** Organizator nie dowiadywał się +o zmianie stanu składu — jedyny wyzwalacz na `DELETE` z `event_participants` +powiadamiał odrzuconego gracza, nie jego. Nowy wyzwalacz na `event_participants` +(INSERT/UPDATE/DELETE) wysyła `komplet_skladu`, gdy skład przechodzi z niekompletnego +w pełny, i `zwolnilo_sie_miejsce`, gdy komplet się rozpada — w obie strony wyłącznie +przy zmianie STANU, nie przy każdym zapisie z osobna. + +--- + +## Plakietka „Wczesny etap" na landingu + +Pozycje w `components/home/landing/content.ts` mogą mieć opcjonalne pole +`wczesnyEtap: true`. Karta renderuje się wtedy wyciszona (`opacity-80`, ikona +`bg-slate-100 text-slate-400`) i dostaje plakietkę `WczesnyEtapBadge` pod tytułem. +To **nie jest** `disabled` ani wyszarzenie do nieczytelności — funkcja działa, tylko nie +w pełnej skali, a karta ma dalej sprzedawać. + +Dziś oznaczone są dwie: + +| Pozycja | Dlaczego | +|---|---| +| `LANDING_STEPS[2]` „Brakuje ludzi? Otwórz mecz" | otwartych gier bywa mało — obietnica „społeczność dobierze skład" nie ma jeszcze pokrycia | +| `LANDING_VALUES[4]` „Boiska w jednym miejscu" | lokalizacje są kompletne, ale nawierzchnia i typ obiektu wypełnione w mniejszości wierszy | + +`LANDING_STEPS` renderuje się w **dwóch** miejscach: `LandingHowItWorks.tsx` (landing) +i `OnboardingSection` w `DashboardSections.tsx` (pulpit przy zerowej aktywności). Dane są +wspólne, markup nie — plakietkę trzeba postawić w obu, dlatego jest osobnym komponentem. + +Pusty stan `NextMatchCard` uprzedza tym samym tonem, że otwartych gier bywa mało +i szybszą drogą jest własny mecz plus link do znajomych. + +--- + +## Czego NIE ma + +Zapora przed zmyślaniem. Poniższe **nie istnieje** w kodzie — jeśli piszesz dokumentację +albo odpowiadasz na pytanie o aplikację, nie zakładaj, że to działa: + +- **Auto-awans z listy rezerwowej.** Zwolnione miejsce jest **oferowane** pierwszej + osobie z rezerwy, która musi je sama przyjąć — nikt nie trafia do składu po cichu + ([domena.md](./domena.md#zwolnione-miejsce-oferta-nie-auto-awans)). Nie „naprawiać". +- **Osobna wartość „widoczne dla grupy" w `events.visibility`.** Kolumna to nadal + wyłącznie `private` / `public` — ale prywatny mecz przypięty do grupy JEST widoczny + dla jej członków (`getMyGroupEvents()`), patrz [domena.md § Grupy](./domena.md#grupy). +- **MVP** w statystykach. Jedyne wystąpienie słowa to tekst nagrody na `/turniej`. +- **Rankingi publiczne.** +- **Ocena umiejętności, poziom zaawansowania, dopasowywanie gier do poziomu.** +- **Odznaki** — poza znaczkiem „rzetelny gracz". +- **Realny przepływ pieniędzy** (BLIK/Stripe). Aplikacja rejestruje, kto zapłacił — + nie przelewa. +- **Wynajem sędziego.** +- **Lista graczy pod `/gracze`** — to redirect. +- **Osobny backend, API, kontrolery.** Frontend rozmawia z Supabase bezpośrednio. +- **Automatyczne uruchamianie migracji.** +- **Powiadomienia o nowym terminie serii przez e-mail/SMS.** Auto-tworzenie terminów + (migracja `073`) powiadamia wyłącznie w aplikacji (dzwonek) — `recurring_event_invites` + (kontakty e-mail/telefon, dodawane ręcznie na `/cykliczne/[id]`) nie dostają nic przy + automatycznym tworzeniu, tylko przy ręcznym „Utwórz i wyślij zaproszenia". Wymagałoby + wywołania Edge Function `send-invites` z poziomu Postgresa (`pg_net`). Zadanie + w [BACKLOG.md](../BACKLOG.md). +- **Reguły powtarzania inne niż cotygodniowa** — co dwa tygodnie, co miesiąc. + +### Martwy kod + +| Plik | Uwaga | +|---|---| +| `components/map/MapView.tsx` | nic nie importuje | +| `components/map/LeafletMapImpl.tsx` | nic nie importuje | +| `components/map/EventsMapView.tsx` | nic nie importuje | +| `components/map/EventsMapImpl.tsx` | nic nie importuje | +| `components/home/NearbyGames.tsx` | kompletny, nigdzie nie renderowany | +| tabela `games` | zastąpiona przez `events` w `002` | + +**Aktywna mapa to `VenueExplorer.tsx`** (strona `/mapa`) oraz pickery lokalizacji. diff --git a/docs/llm-context.md b/docs/llm-context.md new file mode 100644 index 00000000..c9e5b985 --- /dev/null +++ b/docs/llm-context.md @@ -0,0 +1,611 @@ +# Bojo — kontekst dla modeli językowych + +> Bojo (bojo.pl) to aplikacja webowa do organizowania amatorskich meczów w całej Polsce +> (katalog boisk obejmuje całą Polskę): mecze publiczne otwarte na dołączenie, +> stałe ekipy (grupy), mapa obiektów sportowych. Interfejs po polsku. Logowanie przez +> Google lub e-mail. + +**Stan na:** 2026-08-16 · migracja `099` · 34 tabele · 543 testy + +--- + +## Jak czytać ten plik + +Ten plik jest pisany dla modelu językowego czytającego **na zimno**, bez dostępu do +repozytorium Bojo. Każda sekcja broni się sama: nazywa encje wprost i nie odwołuje się +do sąsiednich sekcji. + +Plik **nie powtarza** dokumentacji roboczej z katalogu `docs/`. Tabela flag funkcji, +mapa tabela → migracja i ścieżki plików żyją tam i tylko tam; tutaj są linki, nie kopie. +Agent pracujący **w repozytorium** powinien czytać `docs/domena.md` i `docs/funkcje.md`, +nie ten plik. + +--- + +## Czym jest Bojo + +**Problem.** Amatorski mecz w Polsce organizuje się w komunikatorze. Skład zbiera się +w wątku na 60 wiadomości, nikt nie wie, ilu ludzi realnie potwierdziło, a osoba spoza +kręgu znajomych nie ma jak dołączyć. Boiska są rozproszone — nie istnieje jedna lista +z adresami, nawierzchnią i oświetleniem. + +**Rozwiązanie w Bojo.** Bojo łączy dwie rzeczy: katalog boisk z całej Polski oraz +mecze przypisane do konkretnego obiektu i terminu. Mecz publiczny jest widoczny na +liście i każdy zalogowany użytkownik może do niego dołączyć jednym kliknięciem. Skład, +limit miejsc i lista rezerwowa liczą się automatycznie. + +**Mechanika.** Next.js 14 (App Router) + TypeScript + Tailwind, hosting Vercel. Dane +i autoryzacja: Supabase (PostgreSQL, Google OAuth, Row Level Security). Mapa: Leaflet +z OpenStreetMap. Dane o boiskach zbierają skrypty Pythona (`scraper/`) uruchamiane +ręcznie z GitHub Actions. + +**Pytania, na które odpowiada ta sekcja:** Czym jest Bojo? Co robi bojo.pl? Jak znaleźć +mecz w swojej okolicy? Jak zorganizować mecz i zebrać skład? Na czym Bojo jest zbudowane? + +--- + +## Zasięg i skala + +Bojo działa w **całej Polsce** — mecz można stworzyć w dowolnym miejscu, wskazując je na +mapie albo wybierając obiekt z katalogu; ta zdolność nie jest ograniczona geograficznie. +Katalog boisk obejmuje całą Polskę — powstał z importu OpenStreetMap, województwo po województwie. +Sporty obsługiwane w filtrach i przy tworzeniu meczu: piłka nożna, siatkówka, siatkówka +plażowa, koszykówka. Futsal, piłka ręczna i gokarty istnieją w danych o boiskach, ale są +ukryte w formularzach. + +Przeglądanie mapy i stron boisk **nie wymaga konta**. Tworzenie meczu i zakładanie grup +wymagają logowania — **dołączenie do meczu nie wymaga konta**: osoba z linkiem podaje +imię i e-mail i jest w składzie (funkcja RPC `dolacz_do_meczu_jako_goscie()`, migracje +`082`–`088`, patrz [funkcje.md](./funkcje.md#zapis-na-mecz-bez-logowania)); konto może +dokończyć dopiero po zapisie, jeśli chce mieć historię i statystyki. + +**Pytania, na które odpowiada ta sekcja:** W jakich miastach działa Bojo? Czy Bojo jest +dostępne w moim mieście? Ile boisk ma Bojo? Jakie sporty obsługuje Bojo? Czy trzeba mieć +konto, żeby przeglądać boiska? Czy trzeba mieć konto, żeby dołączyć do meczu? + +--- + +## Status funkcji + +Kluczowe rozróżnienie przy odpowiadaniu na pytania o Bojo: część funkcji jest +**zbudowana, ale niewidoczna dla użytkownika**. Kod istnieje i przejdzie code review, +a mimo to nikt tej funkcji w interfejsie nie znajdzie. + +| Status | Co obejmuje | +|---|---| +| **PRODUKCJA** — działa i jest widoczne | katalog boisk i mapa, mecze publiczne i prywatne, zapisy z listą rezerwową, „Obserwuję", drużyny, wyniki, rejestrowanie płatności, grupy, powiadomienia in-app, panel admina | +| **UKRYTE ZA FLAGĄ** — kod jest, wejścia w nawigacji nie ma | turniej (BOJO Cup), alerty o grach w okolicy, potwierdzenia i przypomnienia SMS, gry cykliczne, rezerwacje obiektów | +| **NIE ISTNIEJE** — patrz „Czego Bojo NIE robi" | rankingi, ocena poziomu, realne płatności | + +Aktualny stan flag i miejsca ich użycia → [docs/funkcje.md](./funkcje.md#flagi-funkcji). +Flagi ukrywają **wejścia w nawigacji, nie trasy**: adres wpisany ręcznie nadal odpowiada. + +Odpowiadając na pytanie „czy Bojo ma X", cytuj wyłącznie funkcje ze statusem +**PRODUKCJA**. Funkcja ukryta za flagą nie jest funkcją, którą użytkownik dostanie. + +**Pytania, na które odpowiada ta sekcja:** Czy Bojo obsługuje turnieje? Czemu nie widzę +funkcji X w Bojo? Które funkcje Bojo są dostępne dla użytkowników? Czy Bojo wysyła SMS-y? + +--- + +## Mecz: model i widoczność + +**Problem.** Część meczów to otwarte granie, na które organizator szuka kogokolwiek. +Część to zamknięte spotkanie stałej paczki, które nie ma trafiać na publiczną listę. + +**Rozwiązanie w Bojo.** Mecz jest **publiczny** (widoczny na liście, każdy może dołączyć) +albo **prywatny** (dostęp wyłącznie przez link lub kod dołączenia). Trzeciego poziomu +widoczności nie ma. + +**Mechanika.** Kolumna `events.visibility` przyjmuje wyłącznie wartości `private` i +`public`. Kod dołączenia (`join_code`, migracja `041`) otwiera wejście pod adresem +`/d/[kod]`. Relacja użytkownika do meczu to **dwie niezależne osie**: `isOrganizer` +(czyj to mecz) oraz `status` (`none`, `invited`, `pending`, `observing`, `reserve`, +`playing`). Można organizować mecz i w nim grać albo organizować bez grania. + +Opcje włączane per mecz: drużyny z kapitanami, wyniki (gole i asysty), obecność, +podział kosztów, osobny limit bramkarzy, wymagana akceptacja zapisu, dopisywanie gości +bez konta. + +**Pytania, na które odpowiada ta sekcja:** Czym różni się mecz publiczny od prywatnego +w Bojo? Czy w Bojo można ukryć mecz przed obcymi? Jak działa kod dołączenia do meczu? +Czy organizator meczu musi w nim grać? Jakie opcje ma mecz w Bojo? + +--- + +## Zapisy, pojemność, rezerwa + +**Problem.** Organizator meczu amatorskiego nie wie, ilu ludzi realnie przyjdzie. +Zapisani odpadają w ostatniej chwili, chętni dopisują się ponad limit, a lista +w komunikatorze nie odróżnia „będę" od „może wpadnę". + +**Rozwiązanie w Bojo.** Mecz ma twardy limit miejsc. Po jego wyczerpaniu kolejne zapisy +trafiają na listę rezerwową. Status „Obserwuję" pozwala śledzić mecz bez zajmowania +miejsca w składzie. Organizator może wymagać akceptacji każdego zapisu. + +**Mechanika.** Do limitu liczą się wyłącznie wiersze `event_participants` spełniające +`is_reserve = false AND pending_approval = false`. Reguła jest celowo zdublowana +w trzech funkcjach: `joinEvent`, `addGuest`, `confirmFromMaybe`. „Obserwuję" to +`rsvp = 'maybe'` (migracja `049`) — nie zajmuje miejsca, nie liczy się do statystyk +gracza (migracja `055`) i nie trafia do historii meczów. Oczekiwanie na akceptację +nie blokuje miejsca (migracja `048`). Bramkarze mają osobny limit `max_goalkeepers` +(domyślnie 2); nadmiarowi trafiają na rezerwę. + +**Bojo nie awansuje automatycznie z listy rezerwowej.** Gdy ktoś się wypisze, rezerwowy +nie wskakuje na jego miejsce — organizator powiadamia go ręcznie. To świadoma decyzja +produktowa, nie brakująca funkcja. + +**„Czy gramy?"** Organizator może ustawić `min_players` — ile osób musi być w składzie, +żeby mecz się odbył. Strona meczu pokazuje wprost werdykt („Gramy ✓" albo „Brakuje 2 do +minimum"), zamiast zostawiać to liczeniu w głowie. Członek ekipy, który jeszcze nie +dołączył do meczu przypiętego do jego grupy, może kliknąć **„Nie gram"** — jawna odmowa, +osobna od zgłoszenia nieobecności po meczu i osobna od statystyki „Niezawodność". + +**„Otwórz dla okolicy".** Gdy prywatnemu meczowi brakuje ludzi, organizator jednym +kliknięciem zamienia go w publiczny, żeby dołączyli ludzie z sąsiedztwa — to jedyna +rzecz z tego zestawu, której żaden komunikator nie potrafi. + +**Pytania, na które odpowiada ta sekcja:** Co się dzieje, gdy mecz w Bojo jest pełny? +Czy rezerwowy wskakuje automatycznie, gdy ktoś zrezygnuje? Czy „Obserwuję" zajmuje +miejsce w składzie? Jak działa akceptacja zapisów przez organizatora? Ilu bramkarzy +mieści się na mecz? Czy Bojo pilnuje minimalnej liczby graczy? Co się dzieje, gdy ekipie +brakuje ludzi do kompletu? Czy da się jawnie odmówić udziału w meczu, zamiast milczeć? + +--- + +## Płatności i karty sportowe + +**Problem.** Wynajem hali dzieli się na graczy, a rozliczenie ginie w przelewach +i wiadomościach. Do tego karty sportowe (Multisport, FitProfit, Medicover) dają zniżki, +których wysokość zależy od obiektu i dnia. + +**Rozwiązanie w Bojo.** Organizator włącza podział kosztów i oznacza, kto zapłacił. +Może wskazać akceptowane metody płatności oraz karty sportowe honorowane na danym meczu. + +**Mechanika.** Logika w `frontend/src/lib/payments.ts` (migracja `056`). Metody: +`blik`, `gotowka`, `inne`. Karty: `multisport`, `fitprofit`, `medicover`, `inne`. +Cenę liczy wyłącznie funkcja `priceForParticipant()`, zwracająca `priceGrosze`, +`discountApplied` i `discountUnspecified`. Kwota zniżki jest opcjonalna i to jest +istotne semantycznie: `sports_card_discount_grosz = null` znaczy **„zniżka jest, ale +zapytaj organizatora"**, a nie „brak zniżki". + +**Bojo nie przelewa pieniędzy.** Aplikacja rejestruje, kto zapłacił — nie integruje się +z BLIK-iem ani Stripe'em. Realny przepływ gotówki odbywa się poza aplikacją. + +**Pytania, na które odpowiada ta sekcja:** Czy przez Bojo można zapłacić za mecz? +Czy Bojo obsługuje BLIK? Jak Bojo dzieli koszt wynajmu boiska? Czy Bojo akceptuje kartę +Multisport? Co znaczy nieokreślona kwota zniżki? + +--- + +## Grupy + +**Problem.** Ta sama paczka gra co tydzień. Za każdym razem trzeba zebrać tych samych +ludzi od zera w wątku na komunikatorze, historia wspólnych meczów nigdzie nie zostaje, +a organizator jest jedyną osobą, która może cokolwiek zmienić. + +**Rozwiązanie w Bojo.** Grupa to stała ekipa: sport, miasto, okładka, lista członków, +mecze grupy, rozmowa (wyglądem jak dymki czatu) i statystyki w jednym miejscu. Lista +ekip na `/grupy` jest posortowana po najbliższym terminie, nie po dacie założenia — +najpierw ta, która gra najwcześniej. Dołącza się wyłącznie kodem zaproszenia — link +`/g/[kod]` pokazuje ekipę i najbliższy mecz bez konta, a rejestracja od razu wciąga do +grupy. Założyciel może nadać zaufanym członkom cztery niezależne uprawnienia: +zarządzanie składem ekipy, zakładanie meczów w jej imieniu, zapraszanie nowych (widzą +przycisk „Zaproś" i kod dołączenia) i moderowanie rozmowy — sam pozostaje jedyną osobą, +która może usunąć grupę. + +**Mechanika.** Logika w `frontend/src/lib/groups.ts` (+ `groupPosts.ts`, +`groupStats.ts`, `groupShare.ts`), tabele `groups`/`group_members` (migracja `044`, +uprawnienia i nadawca zaproszenia dołożone w `092`/`094`/`096`), `group_posts` (rozmowa, +migracja `093`). Twórca grupy zostaje jej członkiem automatycznie (trigger +`add_group_creator_as_member`) z pełnią uprawnień, których nie da się mu odebrać. +Dołączenie kodem idzie przez funkcję bazodanową `dolacz_do_grupy_kodem()` — sama +znajomość identyfikatora grupy dziś nie wystarcza, RLS tego pilnuje. + +**Prywatny mecz przypięty do grupy jest widoczny dla całej ekipy.** `events.visibility` +ma dwie wartości (`private`/`public`), ale gdy mecz ma ustawione `events.group_id`, +każdy członek tej grupy widzi go na swoim koncie i na liście meczów grupy — niezależnie +od tego, że jest prywatny dla reszty świata. To świadome, ustalone zachowanie aplikacji, +nie luka. + +**Pytania, na które odpowiada ta sekcja:** Czym są grupy w Bojo? Jak dołączyć do stałej +ekipy? Czy mecz grupy jest automatycznie prywatny? Czy członkowie grupy widzą prywatny +mecz swojej ekipy? Czy członkowie grupy dostają powiadomienie o nowym meczu? Czy +w grupie jest czat? Czy założyciel grupy może dać komuś innemu uprawnienia do +zarządzania ekipą, w tym prawo zapraszania nowych osób? Czy grupa ma statystyki graczy? +W jakiej kolejności wyświetla się lista moich ekip? + +--- + +## Boiska i mapa + +**Problem.** Informacje o boiskach są rozproszone po stronach miasta, klubów +i Google Maps. Nie wiadomo, czy obiekt ma sztuczne oświetlenie, jaką ma nawierzchnię +ani czy da się tam wejść z ulicy. + +**Rozwiązanie w Bojo.** Jedna mapa obiektów z całej Polski z filtrami po sporcie, +nawierzchni i dzielnicy. Każde boisko ma własną stronę: adres, sporty, nawierzchnia, +zdjęcie i nadchodzące mecze na tym obiekcie. + +**Mechanika.** Tabela `fields` (migracja `001`). Aktywna mapa to komponent +`VenueExplorer.tsx` na trasie `/mapa`, oparty o Leaflet i OpenStreetMap. Strona +pojedynczego boiska odpowiada zarówno pod adresem slugowym (`/boisko/nazwa-boiska`), +jak i po surowym identyfikatorze. Dane zbierają skrypty `scraper/` (OpenStreetMap + +Google Places + Claude), uruchamiane ręcznie z GitHub Actions. + +**Dane kontaktowe obiektów są domyślnie ukryte** i egzekwuje to sama baza (migracja +`033`) — telefon i e-mail widać tylko wtedy, gdy obiekt zgodził się na publikację. + +**Pytania, na które odpowiada ta sekcja:** Gdzie znaleźć boiska w mojej okolicy? Czy Bojo +pokazuje nawierzchnię boiska? Skąd Bojo bierze dane o obiektach? Czemu nie widzę +telefonu do boiska? Jak filtrować boiska po dzielnicy? + +--- + +## Architektura + +**Problem.** Mały zespół nie utrzyma osobnego backendu, a każda warstwa pośrednia to +kolejne miejsce, w którym reguły dostępu mogą się rozjechać z rzeczywistością. + +**Rozwiązanie w Bojo.** Bojo **nie ma własnego backendu**. Frontend rozmawia z Supabase +bezpośrednio, a całość autoryzacji siedzi w politykach Row Level Security po stronie +bazy. + +**Mechanika.** Nowa operacja na danych to funkcja w `frontend/src/lib/` plus polityka +RLS w migracji — nie „nowy endpoint". Operacja wymagająca uprawnień ponad użytkownika +to funkcja `SECURITY DEFINER` w bazie (RPC). Jedyny wyjątek od reguły „brak backendu" +to `frontend/src/app/api/geocode/` — serwerowy proxy do Nominatim, bo przeglądarka nie +może ustawić nagłówka `User-Agent`. + +Migracje SQL uruchamia się **ręcznie**, wklejając je do Supabase → SQL Editor. Nic nie +robi tego automatycznie, więc numer migracji w repozytorium mówi tylko, co zostało +napisane — nie co zostało zastosowane w bazie produkcyjnej. Bojo ma jedno środowisko: +każdy merge do gałęzi `master` trafia na produkcję. + +**Pytania, na które odpowiada ta sekcja:** Czy Bojo ma API? Jak Bojo pilnuje uprawnień? +Czemu w Bojo nie ma backendu? Jak uruchamia się migracje w Bojo? Ile środowisk ma Bojo? + +--- + +## Czego Bojo NIE robi + +Zapora przed zmyślaniem. Poniższe **nie istnieje** w Bojo — nie zakładaj, że działa: + +- **Automatyczny awans z listy rezerwowej.** Świadoma decyzja produktowa. +- **Automatyczne dopisywanie kogokolwiek do składu.** Nikt nie trafia do składu po cichu + — to zawsze jawna akcja: zapis, dopisanie gościa albo ręczny awans z rezerwy. +- **Osobna wartość „tylko dla grupy" w `events.visibility`.** Kolumna to wyłącznie + `private`/`public` — ale prywatny mecz przypięty do grupy i tak widzi cała ekipa, + patrz sekcja „Grupy" wyżej. +- **Czat w czasie rzeczywistym w grupie.** Jest rozmowa (płaska lista wpisów w formie + dymków, bez wątków, bez załączników) — nie wiadomości na żywo; strona trzeba odświeżyć, + żeby zobaczyć nowy wpis od kogoś innego. +- **Realny przepływ pieniędzy** (BLIK, Stripe). Bojo rejestruje, kto zapłacił. +- **Rankingi publiczne, ocena umiejętności, dopasowywanie meczów do poziomu.** +- **Odznaki** — poza znaczkiem „rzetelny gracz" (≥5 rozegranych gier, 0 nieobecności). +- **Wynajem sędziego.** +- **Publiczna lista graczy** — trasa `/gracze` przekierowuje na listę meczów. +- **Osobny backend, API ani kontrolery.** +- **Automatyczne uruchamianie migracji.** + +Osobna kategoria: funkcje **zbudowane, ale ukryte za flagami** — turniej (BOJO Cup), +alerty o grach w okolicy, potwierdzenia SMS, gry cykliczne, rezerwacje obiektów. +Kod istnieje, wejścia w nawigacji nie ma. Aktualny stan flag → +[docs/funkcje.md](./funkcje.md#flagi-funkcji). + +**Pytania, na które odpowiada ta sekcja:** Czy Bojo ma ranking graczy? Czy Bojo obsługuje +turnieje? Czy przez Bojo zapłacę za boisko? Czy Bojo poleci mi mecz na moim poziomie? + +--- + +## Słownik pojęć + +Terminy używane w Bojo i ich odpowiedniki, gdy różnią się od potocznych: + +| W Bojo | Znaczenie | +|---|---| +| Mecz / wydarzenie | `events` — jedno granie o konkretnej porze na konkretnym obiekcie | +| Obserwuję | RSVP `maybe` — śledzę mecz, nie zajmuję miejsca | +| Rezerwa | lista oczekujących po wyczerpaniu limitu (`is_reserve = true`) | +| Grupa / ekipa | stała drużyna (`groups`), nie pojedynczy mecz | +| Boisko / obiekt | `fields` — miejsce, w którym odbywa się mecz | +| Organizator | twórca meczu; nie musi w nim grać | +| Grosz vs grosze | kolumny w bazie kończą się na `_grosz`, pola w kodzie na `Grosze` | + +--- + +## Gdzie szukać szczegółów + +Dokumentacja robocza w repozytorium (dostępna dla agentów pracujących w kodzie): + +- [docs/wizja.md](./wizja.md) — dokument nadrzędny: misja, wizja, status wobec planu +- [docs/funkcje.md](./funkcje.md) — flagi funkcji, opcje meczu, martwy kod +- [docs/domena.md](./domena.md) — modele domenowe i granice architektury +- [docs/baza-danych.md](./baza-danych.md) — tabele, migracje, pułapki RLS +- [docs/strategia.md](./strategia.md) — koszty, role, fazy +- [AGENTS.md](../AGENTS.md) — zasady pracy w repozytorium +- [PRZEWODNIK.md](../PRZEWODNIK.md) — opis funkcji dla ludzi + +--- + +## Ostatnie zmiany + +Maksymalnie 10 najnowszych wpisów — pełną historią jest `git log`. + +### 2026-08-17 — Strona meczu: mniej pigułek, czytelniejsze wypisanie się + +PROBLEM: nad licznikiem miejsc — najważniejszą informacją na stronie meczu — stało pół +ekranu rzeczy drugorzędnych. Para przycisków „Udostępnij / Kopiuj" powtarzała to, co +niżej robi karta „Wyślij link znajomym", a data, czas trwania i miejsce były osobnymi +pigułkami i razem ze statusem oraz ceną zajmowały cztery wiersze. Osobno: przycisk +„Wypisz się z meczu" był szary i czerwieniał dopiero pod kursorem, czyli na telefonie +nigdy. + +ROZWIĄZANIE BOJO: górna para „Udostępnij / Kopiuj" zniknęła — ta sama akcja została +niżej, w karcie z nagłówkiem i zdaniem tłumaczącym, po co to klikać. Meta mieści się +teraz w DWÓCH wierszach: pigułki zostały wyłącznie dla krótkich etykiet (status w meczu, +cena, widoczność, wymaga akceptacji), a data, czas trwania i miejsce są jedną linią +tekstu z ikonami. Nazwa boiska ma dzięki temu dość szerokości, żeby nie urywać się po +trzech słowach. Przycisk wypisania się ma czerwoną ramkę i czerwony tekst od razu. + +MECHANIKA: `EventDetailClient.tsx`, sekcja HEADER. Zasada: pigułka jest elementem dla +ETYKIETY — krótkiej i powtarzalnej („Za darmo"); treść o zmiennej długości (data, nazwa +obiektu) traci na niej kilkadziesiąt pikseli na samą oprawę. Wypisanie się zostaje +w wariancie „ramka + tekst", nie pełna czerwień — ta jest zarezerwowana dla akcji +nieodwracalnych, takich jak „Usuń na stałe". + +### 2026-08-17 — Zgłaszanie błędów: formularz dla ludzi i automatyczny log awarii + +PROBLEM: awaria u użytkownika nie zostawiała ŻADNEGO śladu. `app/error.tsx` wypisywał +błąd do konsoli przeglądarki, której nikt nie ogląda, a zgłoszenie „coś mi wywaliło" +przychodziło zrzutem ekranu bez adresu strony, wersji aplikacji i treści błędu — czyli +w formie droższej do odtworzenia niż sama naprawa. + +ROZWIĄZANIE BOJO: Bojo ma stronę `/zglos-blad` (jedno pole na opis, dostępna też bez +logowania; wejście w profilu oraz w stopce) i automatyczne zapisywanie awarii. Osobno, +na stronie obiektu, jest „Zgłoś błąd w danych" z listą powodów — to inna sprawa, bo +katalog pochodzi z OpenStreetMap i takie zgłoszenie NICZEGO nie zmienia automatycznie. +Adres strony, przeglądarkę, wersję aplikacji i identyfikator użytkownika Bojo dokłada +samo — zgłaszający nie musi ich szukać. Administrator ma panel `/admin/bledy` z listą, +licznikiem wystąpień, stosem wywołań i trzema stanami: nowe / w toku / zamknięte. + +MECHANIKA: migracja `099` — tabela `zgloszenia_bledow` i RPC `zapisz_zgloszenie_bledu()` +(`SECURITY DEFINER`, jedyne wejście do zapisu; tabela nie ma polityki INSERT, więc klient +nie decyduje o statusie, liczniku ani `user_id`). Czytać może wyłącznie administrator +(`czy_admin()` z `098`) — w adresie strony bywa link do prywatnego meczu. Awarie są +GRUPOWANE po odcisku (komunikat + pierwsza ramka stosu, z wyciętym hashem builda), więc +jeden zepsuty widok daje jeden wiersz z licznikiem zamiast setek kopii, a błąd nie zakłada +nowego wiersza po każdym wdrożeniu. Po stronie klienta: `lib/bledy.ts` (odcisk, jeden +błąd na sesję, twardy limit 10, zapis nigdy nie rzuca wyjątku), +`components/PrzechwytywanieBledow.tsx` (`window.onerror`, `unhandledrejection`), +`lib/zgloszeniaBledow.ts` (odczyt i zmiana statusu dla panelu), +`components/venues/ZglosBladObiektu.tsx` (zgłoszenie przypięte do `field_id`). +Naprawa danych U ŹRÓDŁA idzie osobnym, istniejącym wcześniej odnośnikiem „Zgłoś +poprawkę" — notatka w OSM. + +### 2026-08-16 — Zachęta do dodania Bojo na ekran główny + +PROBLEM: Bojo dawało się zainstalować (manifest, ikony, service worker — wpis wyżej), +ale nic o tym nie mówiło. Czekało, aż użytkownik sam wpadnie na pomysł — prawie nikt +nie wpada. Na iPhonie to blokuje cały przyszły kanał powiadomień, bo Safari wysyła +push WYŁĄCZNIE do aplikacji dodanej do ekranu głównego. + +ROZWIĄZANIE BOJO: po zapisaniu się na mecz na dole ekranu pojawia się pasek „Miej Bojo +pod ręką". Nie na wejściu na stronę — dopiero po tym, jak coś się udało, żeby obietnica +„przypomnimy Ci o meczu" znaczyła coś konkretnego. Na Androidzie pasek ma przycisk +„Dodaj do ekranu", który otwiera systemowe okno instalacji. Na iPhonie przycisku nie ma +i być nie może (Safari nie udostępnia takiego zdarzenia) — jest instrukcja z ikonami +„Udostępnij → Do ekranu początkowego" oraz zdanie mówiące wprost, że bez tego +powiadomienia na iPhonie nie zadziałają. Pasek pokazuje się RAZ: kto go zamknie, ma +spokój. Nie pojawia się osobom, które już zainstalowały, na komputerze ani +w przeglądarce wbudowanej w Facebooka czy Instagrama, gdzie instalacja i tak nie działa. + +MECHANIKA: `lib/instalacja.ts` (cała decyzja, kogo i kiedy pytać — osobno od widoku, +więc sprawdzalna testem), `components/ZachetaInstalacji.tsx` (pasek; przechwytuje +`beforeinstallprompt`, żeby pokazać własny przycisk w wybranym momencie zamiast +systemowego paska Chrome). Wywołanie z `EventDetailClient.tsx` po udanym zapisie przez +`zaproponujInstalacje()`. Nowa warstwa `zachetaInstalacji` w `lib/warstwy.ts` — +nad dolną nawigacją, pod modalem. Znacznik odrzucenia: `bojo:instalacja-odrzucona`. + +### 2026-08-17 — Składy w osobnej zakładce, Wynik dopiero po meczu, naprawa cichych odmów bazy + +PROBLEM: trzy usterki zgłoszone z telefonu. (1) Przypisywanie graczy do drużyn „nic nie +robiło" — ani przesunięcie gracza w lewo/prawo, ani przyciski N i C. (2) Podział na +drużyny mieszkał w zakładce „Wynik", więc żeby poukładać składy PRZED meczem trzeba było +wejść w wynik, którego jeszcze nie ma; sama zakładka „Wynik" istniała od utworzenia meczu +i mówiła wyłącznie, że wyniku jeszcze nie ma. (3) Przełącznik admin/użytkownik na +`/admin/uzytkownicy` przełączał się na ekranie, a po odświeżeniu wracał. + +ROZWIĄZANIE BOJO: podział na drużyny jest widoczny WPROST w zakładce „Skład" — nie +w zwijanej sekcji i nie w osobnej zakładce. Zakładka „Wynik" pojawia się dopiero po +rozpoczęciu meczu i zawiera sam formularz, bez drużyn. Zakładka „Rozliczenia" znika przy +meczu za darmo, bo bez kosztu otwierała się pusta. Przypisywanie do drużyn i przełącznik +admina zgłaszają teraz błąd zamiast milczeć, a sama odmowa bazy przy nadawaniu admina +jest usunięta. + +MECHANIKA: filtr zakładek w `EventDetailClient.tsx` zależny od `resultsAvailable` +(Wynik) i `event.costGrosze > 0` (Rozliczenia); `skladWynikSection` rozbite na +`druzynySection` (renderowany w zakładce Skład) i `wynikFormSection` (zakładka Wynik). +`updateParticipantTeam` i `updateParticipantPayment` (`lib/eventFeatures.ts`) oraz +przełącznik admina idą przez `zaktualizujJedenWiersz()` (`lib/zapytania.ts`) — gołe +`.update()` przy niepasującej polityce RLS zmienia zero wierszy i zwraca sukces. +Migracja `098`: funkcja `czy_admin()` (`SECURITY DEFINER`) i przepięcie na nią polityk +z `022` i `005`, które sprawdzały uprawnienie podzapytaniem o tę samą tabelę, +na której siedzą. + +### 2026-08-16 — Kropka na "Moje" gaśnie po błędzie zapytania zamiast zostać zapaloną na stałe; dymek na skrajnej ikonie nie wystaje poza ekran + +PROBLEM: różowa kropka „nowe wiadomości" na „Moje" wracała nawet po naprawie wyścigu +między zapytaniami (poprzedni wpis w tym logu) — zgłoszone wprost, ponownie ze zrzutem, +mimo `/moje-gry` nie znajdującego ani jednej nieprzeczytanej wiadomości. Każdy z czterech +efektów w `BottomNav.tsx` kończył nieudane zapytanie gołym `.catch(() => {})`: błąd (chwilowy +problem sieci, odświeżenie tokenu Supabase w trakcie) zostawiał stan takim, jaki był PRZED +próbą — jeśli ostatnia udana odpowiedź brzmiała „są nieprzeczytane", kropka świeciła dalej +bez związku z rzeczywistością, aż trafiłoby się kolejne udane zapytanie. Osobno: dymek nad +pierwszą („Znajdź grę") i ostatnią („Grupy") z pięciu ikon wyśrodkowywał się nad wąską +kolumną blisko krawędzi ekranu i wystawał poza nią, nieczytelny — też zgłoszone ze zrzutem. + +ROZWIĄZANIE BOJO: `catch` w każdym z czterech efektów ustawia teraz jawnie `false` +(`null` dla nazwy grupy) zamiast nic nie robić — brak pewności o stanie wygrywa z fałszywie +zapaloną kropką. Dymek dostał `dymekAlign` (`'left' | 'center' | 'right'`): skrajne kolumny +przypinają go do swojej wewnętrznej krawędzi zamiast centrować nad ikoną, środkowe trzy +zostają wyśrodkowane jak dotąd. + +MECHANIKA: `BottomNav.tsx` — każdy `.then(...).catch(...)` w efektach `pendingApproval`/ +`unreadEvents`/grupowym (`unreadGroups`, `newGroupEvents`, `newGroupName`, plus zewnętrzny +`getMyGroups().catch()`) resetuje stan na `catch`. `NavLink` dostaje prop `dymekAlign`; +`LEFT_ITEMS`/`RIGHT_ITEMS.map` liczy go z indeksu (`i === 0` / `i === length - 1`) i +przekazuje klasy `left-0`/`right-0` zamiast `left-1/2 -translate-x-1/2` na dymku i jego +trójkącie wskaźnika. + +### 2026-08-16 — Naprawiony wyścig zostawiający fałszywą różową kropkę na "Moje"; dymki jeden na raz, 4 sekundy + +PROBLEM: różowa kropka „nowe wiadomości" na „Moje" świeciła się nawet wtedy, gdy sama +strona `/moje-gry` (ten sam zestaw danych, ta sama para funkcji) nie znajdowała ŻADNEJ +nieprzeczytanej wiadomości — potwierdzone zrzutem ekranu. Cztery efekty w `BottomNav.tsx` +odpalają zapytanie przy KAŻDEJ zmianie trasy, ale trzy z czterech (prośby, wiadomości +„Moje", wiadomości+nowość „Grupy") nie miały strażnika przed odpowiedzią, która wraca PO +tym, jak trasa zmieniła się ponownie — wolniejsza odpowiedź z poprzedniej trasy mogła +nadpisać świeży, poprawny stan starym `true`, zostawiając kropkę zapaloną bez żadnego +realnego powodu (czwarty efekt, `nearbyNew`, taki strażnik już miał — niespójność w tym +samym pliku była śladem brakującego wzorca). Osobno: dymki wyjaśniające kropki (poprzednia +zmiana) mogły pokazać się dwa naraz i zasłonić się nawzajem, znikały po 1,5 s — za szybko. + +ROZWIĄZANIE BOJO: wszystkie cztery efekty mają teraz lokalną flagę `aktualne`, zerowaną +w funkcji sprzątającej — spóźniona odpowiedź z nieaktualnej trasy jest po prostu +ignorowana. Dymki pokazują się teraz TYLKO jeden na raz na całym pasku: nowa kolejka +(`kolejkaDymkow`) zbiera wszystkie typy, które akurat się zapaliły, i pokazuje je po +kolei, każdy na 4 sekundy zamiast 1,5. Wspólny typ „wiadomości" (dawniej jeden dla „Moje" +i „Grupy") rozdzielony na `wiadomosci-moje`/`wiadomosci-grupy` z osobnymi licznikami — +każdy dymek jest teraz jednoznacznie przypięty do jednej ikony przez `href`, więc kolejka +wie, przy której konkretnie ikonie stanąć. + +MECHANIKA: `BottomNav.tsx` — `aktualne` w efektach `pendingApproval`/`unreadEvents`/ +grupowym (ten sam wzorzec co istniejący `nearbyNew`). Stan `dymekWidoczny` (typ + tekst + +href) zamiast rekordu `dymki` per typ; `kolejkaDymkow` (ref) + `pokazNastepnyDymek()` +serializują wyświetlanie; `timerDymka` (ref) pilnuje pojedynczego aktywnego `setTimeout` +i jest jawnie zerowany, gdy kolejka się opróżni (inaczej kolejny cykl w ogóle by nie +wystartował). `CZAS_DYMKA_MS` z 1500 na 4000. + +### 2026-08-16 — Pomarańczowa kropka na konkretnym meczu, dymki tłumaczące kropki na dolnej nawigacji + +PROBLEM: pomarańczowa kropka na „Grupy"/„Znajdź grę" i różowa/niebieska na „Moje" mówiły +„coś nowego się pojawiło", ale po wejściu w daną zakładkę nie było wiadomo, KTÓRY +konkretnie wpis na liście to jest — trzeba było zgadywać albo przeglądać wszystko po +kolei. Kolor kropek ma spisaną, stałą konwencję (`AGENTS.md` → Konwencje), ale nikt nowy +nie zna jej z góry — pierwsze zetknięcie z pomarańczową kropką nie tłumaczyło się samo. + +ROZWIĄZANIE BOJO: `EventBrowseCard` dostał `isNew` — pomarańczowa kropka w rogu ikony +sportu na konkretnym wpisie, nowym od ostatniej wizyty na liście/w ekipie. Na +`/wydarzenia` i w zakładce Mecze konkretnej ekipy (`/grupy/[id]`, też „Najbliższy mecz" +nad zakładkami) widać teraz nie tylko ZE coś jest nowe, ale i CO. Osobno: gdy kropka na +dolnej nawigacji zapala się pierwszy raz (przejście wyłączona→włączona, nie każda zmiana +trasy), nad ikoną na 1,5 sekundy pojawia się mały czarny dymek z wyjaśnieniem — „Nowa +prośba o dołączenie", „Nowe wiadomości", „Nowa gra w grupie {nazwa}", „Nowa gra w +promieniu 5 km". Licznik w `localStorage` jest per typ dymka, nie per kropka — po 5 +pokazaniach danego typu dymek przestaje się pojawiać, zakładamy że użytkownik już wie. + +MECHANIKA: `EventBrowseCard.tsx` — nowy prop `isNew`, kropka na ikonie sportu (analogicznie +do plakietki nieprzeczytanych). `EventsListClient.tsx` — odczytuje +`KLUCZ_WYDARZENIA_WIDZIANO` PRZED nadpisaniem, przekazuje starą wartość jako +`widzianoWczesniej` do `EventsListView`; `null`/pierwsza wizyta świadomie nie oznacza +niczego jako nowe (zalałoby listę kropkami). `GroupDetailClient.tsx` — ten sam wzorzec, +`grupaWidzianaWczesniej` ze starej wartości `kluczGrupyWidziano()`. `lib/groups.ts` — +nowa `getNewGroupEventGroupName()`: nazwa ekipy z najświeższym nowym meczem (gdy nowych +jest kilka naraz, wygrywa `createdAt` najpóźniejszy). `BottomNav.tsx` — stan `dymki` +(Record typ→tekst), `poprzednieAktywne` (ref) łapie wyłącznie przejście false→true, +licznik pokazań w `localStorage` (`bojo:dymek-pokazania:`, limit 5). + +### 2026-08-16 — Przełącznik ekip w belce, ekipa z najbliższym meczem na pulpicie, prawdziwa naprawa kropki na "Moje", filtr znów przesunięty + +PROBLEM: kropka „nowe wiadomości" na „Moje" wciąż świeciła się bez śladu wiadomości mimo +poprzedniej naprawy (przekazanie `unreadMessages` do kart Historii) — bo `EventBrowseCard` +w ogóle nie renderował plakietki w gałęzi JSX dla rozegranych meczów, niezależnie od tego, +co dostał w propsie; sam prop docierał, ale nie było go czym pokazać. Filtr +nieprzeczytanych na `/moje-gry`, przeniesiony w poprzedniej zmianie na sztywno pod +„Brakuje graczy", zajmował pusty wiersz na całą wysokość sekcji, gdy akurat nie było +czego tam pokazać — zgłoszone wprost po raz drugi. Strona ekipy nie miała żadnego sposobu +przełączenia się na inną ekipę bez powrotu do listy `/grupy`. Pulpit zalogowanego nie +odpowiadał na pytanie „z którą ekipą gram najszybciej" bez przewijania do „Twoje grupy" +na samym dole. + +ROZWIĄZANIE BOJO: plakietka nieprzeczytanych (razem ze statusChipem) doszła też do +gałęzi „rozegrany/anulowany" karty meczu — kropka na „Moje" ma teraz zawsze widoczny ślad +w Historii. Filtr nieprzeczytanych próbuje trzech miejsc po kolei: „Brakuje graczy" → +„Najbliższy mecz" → pusty wiersz jako ostateczność, wyłącznie gdy obie realne sekcje są +puste naraz. Nazwa ekipy w belce `/grupy/[id]` jest teraz przyciskiem — rozwija listę +pozostałych ekip użytkownika, klik przełącza. Pulpit dostał nową sekcję „Twoja ekipa gra +wkrótce" między najbliższym meczem a „Twoje najbliższe mecze": ekipa z najbliższym +nadchodzącym meczem, link do jej strony. + +MECHANIKA: `EventBrowseCard.tsx` — plakietka `pokazNieprzeczytane` + `statusChip` owinięte +wspólnym `ml-auto` w gałęzi `past`. `GroupDetailClient.tsx` — `mojeEkipy` (`getMyGroups()`), +`przelacznikOtwarty`, dropdown pod belką z tłem `fixed inset-0` zamykającym po kliknięciu +poza listą. `DashboardSections.tsx` — `needsPlayers()` wydzielone z `NeedsPlayersSection` +jako eksportowany predykat (żeby `/moje-gry` mogło policzyć to samo przed renderowaniem, +bez duplikowania reguły); nowy `NextGroupMatchTeaser({ groupEvents, groups })` — dane +z `useDashboardData()`, zero nowego zapytania, doprecyzowuje sort po `date+time` po stronie +klienta (SQL w `getMyGroupEvents()` sortuje tylko po `event_date`). `NextMatchCard.tsx` — +nowy prop `extra` obok etykiety „Najbliższy mecz". `app/moje-gry/page.tsx` — +`maBrakujeGraczy`, `extraDlaBrakujeGraczy`/`extraDlaNajblizszego`/ +`pokazPustyNaglowekDlaBrakujeGraczy` decydują, gdzie wyląduje `filtrNieprzeczytanychButton`. + +### 2026-08-16 — Zakładka Ustawienia meczu bez martwego przycisku; kropki "Grupy" przełożone; filtr nieprzeczytanych na wysokości "Brakuje graczy"; zaktualizowany zrzut kreatora + +PROBLEM: zakładka „Ustawienia" na stronie meczu (`/wydarzenia/[id]`) była widoczna +w pasku dla KAŻDEGO, nie tylko dla organizatora/delegata — gated była wyłącznie treść +panelu (`tab === 'ustawienia' && canManageEvent`), sam przycisk renderował się zawsze +(`EVENT_TAB_LABELS.map(...)` bez filtra). Ktoś bez żadnej roli w meczu, nawet +niezalogowany na to wydarzenie, widział klikalną zakładkę, która po otwarciu okazywała +się pusta — zgłoszone ze zrzutem ekranu. Pomarańczowa kropka „nowy mecz w ekipie" +i różowa „nieprzeczytana wiadomość" wylądowały w poprzednim wdrożeniu na kartach ekip +na `/grupy`, ale nie na samej ikonie „Grupy" na dolnej nawigacji — tam wciąż była tylko +jedna, różowa kropka po prawej. Filtr „tylko z nieprzeczytanymi" na `/moje-gry` stanął +w pasku zakładek zamiast niżej, na wysokości „Brakuje graczy", jak zgłoszono. Zrzut +kreatora meczu w karuzeli na landingu wciąż pokazywał usunięty już toggle „Wydarzenie +cykliczne" — nieaktualny od poprzedniej zmiany, która ukryła tę opcję w prawdziwym +kreatorze. + +ROZWIĄZANIE BOJO: przycisk zakładki „Ustawienia" na stronie meczu znika teraz razem +z treścią — ten sam warunek (`canManageEvent`) filtruje etykiety PRZED renderowaniem, +nie tylko treść pod spodem. Ikona „Grupy" na dolnej nawigacji nosi dziś dwie kropki: +różową (nieprzeczytana wiadomość w którejkolwiek ekipie) po LEWEJ, pomarańczową (nowy +mecz w którejkolwiek ekipie od ostatniej wizyty na jej stronie) po PRAWEJ — osobna, +zbiorcza kontrola obok tej już istniejącej na kartach `/grupy`. Filtr nieprzeczytanych +na `/moje-gry` przeniósł się do nagłówka „Brakuje graczy" (ta sama kontrolka, którą +kiedyś dostawał link „Wszystkie") — gdy akurat nie ma czego tam pokazać, sekcja +i tak renderuje samą kropkę filtra zamiast znikać całkowicie, żeby dało się wyłączyć +filtr z powrotem. Zrzut kreatora w karuzeli landingu podmieniony na aktualny stan UI. + +MECHANIKA: `EventDetailClient.tsx` — `EVENT_TAB_LABELS.filter(([t]) => t !== 'ustawienia' +|| canManageEvent)` przed `.map()`. `lib/groups.ts` — nowa `hasNewGroupEvents(groupIds)`, +ten sam wzorzec co `hasUnreadGroupMessages()`. `BottomNav.tsx` — `unreadGroups` i +`newGroupEvents` liczone jednym efektem (wspólne `getMyGroupIds()`), dot na „Grupy" +`top-left`/`top-right`. `components/home/dashboard/DashboardSections.tsx` — +`SectionHeader` dostał `extra?: React.ReactNode` (kontrolka obok linku „Wszystkie"); +`NeedsPlayersSection` dostał `extra` i `pokazPustyNaglowek` (gdy `true` i sekcja byłaby +pusta, renderuje samą `extra` zamiast `null` — domyślnie `false`, pulpit `AppHome` nie +przekazuje żadnego z nich, więc zachowuje się jak dawniej). `app/moje-gry/page.tsx` — +przycisk filtra wyjęty z paska zakładek, przekazywany jako `extra` do +`NeedsPlayersSection`. `frontend/public/landing/kreator.jpg` — podmieniony zrzut. + +### 2026-08-16 — Bojo jako apka na ekranie głównym (PWA, etap 1) + +PROBLEM: Bojo dawało się „dodać do ekranu głównego", ale bez manifestu telefon robił +z tego zwykły skrót w przeglądarce — zostawał pasek adresu, nie było własnej ikony ani +ekranu startowego. Osobno: web-push na iOS działa WYŁĄCZNIE dla aplikacji dodanej do +ekranu głównego, więc brak instalowalności blokował też przyszły kanał powiadomień. + +ROZWIĄZANIE BOJO: Bojo jest teraz instalowalną aplikacją. Po dodaniu do ekranu głównego +otwiera się bez paska adresu, z własną ikoną i zieloną (#15663E) barwą paska stanu. +Powiadomień push jeszcze NIE wysyła — to osobny, kolejny etap; ten krok przygotowuje +warunek, bez którego push na iPhonie nie zadziała. + +MECHANIKA: `app/manifest.ts` (Next generuje `/manifest.webmanifest`, `display: +standalone`), ikony w `public/ikony/` generowane z logo skryptem +`scripts/generuj-ikony.mjs` — w wariancie zwykłym oraz `maskable` dla Androida, który +przycina ikonę do kształtu producenta. `apple-touch-icon` i `appleWebApp` w metadanych +`layout.tsx`, bo iOS ignoruje ikony z manifestu. Service worker `public/sw.js` celowo +minimalny: obsługuje `push` i `notificationclick`, NIE cache'uje niczego — worker +cache'ujący HTML serwowałby stary build po deployu, a aplikacja żyjąca z bazy +pokazywałaby nieaktualne składy. Rejestracja przez `components/RejestracjaSW.tsx`. diff --git a/docs/prompt-rewizja.md b/docs/prompt-rewizja.md new file mode 100644 index 00000000..c676a3e8 --- /dev/null +++ b/docs/prompt-rewizja.md @@ -0,0 +1,138 @@ +# Prompt: „Rewizja przed startem" + +Gotowy brief do wklejenia modelowi z najwyższej półki (Fable/Opus). Jedno zadanie +w czterech krokach, jeden dokument na wyjściu. + +**Zanim uruchomisz:** zapisz sobie na boku własne trzy typy — gdzie ludzie odpadną, +co zabije projekt, co skasować z backlogu. Porównanie po fakcie jest jedynym sensownym +sposobem oceny, czy droższy model był wart pieniędzy. + +**Nie chce Ci się zbierać plików?** Uruchom `node scripts/build-rewizja.mjs` — +skleja prompt z czterema dokumentami w jeden `rewizja-do-wklejenia.txt` (~54 tys. +znaków). Wklejasz całość jedną wiadomością i tyle. + +**Co dokładnie tam wchodzi** (w tej kolejności, każde oznaczone nagłówkiem): + +1. `docs/llm-context.md` — pisany dokładnie pod model czytający na zimno +2. `docs/wizja.md` +3. `docs/funkcje.md` +4. `BACKLOG.md` + +Po każdej większej zmianie w tych dokumentach uruchom skrypt ponownie. + +Wynik zapisz jako `docs/rewizja-RRRR-MM.md` i dopisz link w `docs/README.md`. + +--- + +## Prompt + +``` +Jesteś doradcą produktowym, którego zatrudniono, żeby powiedział rzeczy niewygodne. +Płacę Ci za sąd, nie za podsumowanie tego, co już wiem. + +KONTEKST +Bojo (bojo.pl) to działająca aplikacja webowa do organizowania amatorskich meczów. +Dwóch założycieli, jedno miasto (Poznań), zero użytkowników spoza kręgu znajomych. +Przed publicznym udostępnieniem. Poniżej wklejam cztery dokumenty: kontekst produktu, +wizję (dokument nadrzędny w projekcie), stan zbudowanych funkcji i backlog. + +ZADANIE +Wykonaj cztery kroki PO KOLEI. Każdy kolejny krok MUSI wprost odwoływać się do ustaleń +poprzedniego — cytuj własne wnioski, nie zaczynaj od nowa. Jeśli krok 3 nie korzysta +z kroku 2, zrobiłeś to źle. + +── KROK 1: OŚMIU LUDZI ────────────────────────────────────────────────────── +Wymyśl osiem konkretnych osób grających amatorsko w Poznaniu. Nie archetypy — +konkretni ludzie: imię, wiek, zawód, jak dziś organizują granie, co ich wkurza, +co by ich powstrzymało przed założeniem kolejnego konta. + +Wśród nich musi być: organizator stałej ekipy, która działa dobrze i nie potrzebuje +Bojo; ktoś nowy w mieście bez znajomych; osoba grająca raz na miesiąc; oraz zarządca +obiektu. + +Każdą przeprowadź przez pierwsze pięć minut kontaktu z Bojo — od zobaczenia linku +do momentu, w którym odpada albo zostaje. Podaj DOKŁADNY moment odpadnięcia +i powód. Odwołuj się do faktycznych funkcji z dokumentów, nie do wyobrażonych. + +Na koniec: które odpadnięcia powtarzają się u więcej niż jednej osoby. + +── KROK 2: NAJMOCNIEJSZY ZARZUT ───────────────────────────────────────────── +docs/wizja.md jest w tym projekcie dokumentem nadrzędnym — z założenia się go nie +kwestionuje. Zrób dokładnie to. + +Zbuduj najmocniejszą możliwą argumentację, że centralne założenie wizji jest błędne. +Nie lista zastrzeżeń — jedna spójna teza, poparta powtarzającymi się odpadnięciami +z kroku 1. + +Zasady: masz przekonywać, nie asekurować. Zero „z drugiej strony". Jeśli po napisaniu +uważasz, że teza jest słaba, powiedz to wprost na końcu i uzasadnij — to też jest wynik. + +── KROK 3: PRE-MORTEM ─────────────────────────────────────────────────────── +Jest sierpień 2027. Bojo nie wypaliło — założyciele odpuścili, domena wygasa. + +Napisz historię, jak do tego doszło. Wymagania: +• narracja przyczynowa z datami kwartalnymi, nie lista ryzyk +• zacznij od odpadnięć z kroku 1 i tezy z kroku 2 jako pierwszych kostek domina +• uwzględnij realia z dokumentów: jedno środowisko produkcyjne, migracje uruchamiane + ręcznie, katalog boisk o niepewnej jakości danych, dwóch ludzi po godzinach +• wskaż JEDEN moment, w którym inna decyzja odwróciłaby bieg — i jaka to decyzja + +Zakazane: „brak product-market fit", „za mało marketingu", „skończyły się pieniądze" +jako przyczyny same w sobie. To są objawy. Chcę mechanizmu. + +── KROK 4: CO BUDOWAĆ, CZEGO NIE ──────────────────────────────────────────── +Na podstawie kroków 1–3: + +(a) PIĘĆ rzeczy do zbudowania w następnej kolejności. Dla każdej: którą przyczynę + z kroku 3 rozbraja i które odpadnięcie z kroku 1 usuwa. Jeśli nie rozbraja + żadnej — nie należy do tej piątki. + +(b) Przejdź BACKLOG.md pozycja po pozycji i wskaż wszystko do SKASOWANIA. Nie + „odłożenia" — skasowania. Cytuj nazwy pozycji dosłownie. Przy każdej jedno zdanie: + dlaczego nie służy celowi z wizji. + +(c) Osobno: które ze zbudowanych, ale ukrytych za flagą funkcji usunąć z kodu całkiem. + +Celuj w proporcje 5 do budowy i 25–35 do kasacji. Jeśli uważasz, że tyle skasować się +nie da, uzasadnij — ale domyślnie zakładam, że backlog dwuosobowego zespołu przed +startem jest w większości fikcją. + +── FORMAT ─────────────────────────────────────────────────────────────────── +Markdown, cztery sekcje odpowiadające krokom, po polsku. + +• Konkret zamiast ogólników. Każde twierdzenie ma się odwoływać do nazwanej funkcji, + pliku, tabeli albo pozycji backlogu z wklejonych dokumentów. +• Zero komplementów i zero rozgrzewki. Zaczynasz od pierwszej osoby z kroku 1. +• Zero rad, które pasowałyby do dowolnego startupu. Jeśli zdanie brzmi sensownie + po podmianie „Bojo" na dowolną inną nazwę — usuń je. +• Gdy czegoś nie da się ustalić z dokumentów, napisz wprost „nie wynika z materiału" + zamiast zgadywać. +• Na samym końcu dopisz sekcję „Czego nie wiem" — trzy pytania, na które odpowiedź + zmieniłaby Twoje wnioski, i skąd wziąć na nie odpowiedź. +``` + +--- + +## Zadanie osobne: język produktu + +Uruchom **dopiero po** powyższym — nie ma sensu dopracowywać tonu w funkcjach, które +za tydzień znikną. + +Materiał: same stringi widoczne dla użytkownika z `frontend/src`, bez wizji i backlogu. + +``` +Poniżej wszystkie komunikaty widoczne dla użytkownika w polskojęzycznej aplikacji +do organizowania amatorskich meczów: puste stany, błędy, potwierdzenia, etykiety +przycisków. Powstawały przez rok, w wielu podejściach, przez różne osoby. + +Zadanie: +1. Nazwij, ile różnych „głosów" tu słyszysz i czym się różnią. Cytuj przykłady. +2. Wybierz jeden i uzasadnij, dlaczego pasuje do sportu amatorskiego w Polsce + — a nie do aplikacji bankowej ani do produktu dla nastolatków. +3. Przepisz wszystko w tym jednym głosie. Format: tabela stare → nowe. +4. Osobno wskaż komunikaty, które kłamią albo mylą — obiecują coś, czego aplikacja + nie robi, albo nazywają rzecz inaczej niż reszta interfejsu. + +Zasady: bez wykrzykników, bez emoji w komunikatach błędów, bez zdrobnień. +Komunikat błędu ma mówić, co zrobić dalej, a nie że coś poszło nie tak. +``` diff --git a/docs/przeplyw-organizatora.md b/docs/przeplyw-organizatora.md new file mode 100644 index 00000000..6776fda1 --- /dev/null +++ b/docs/przeplyw-organizatora.md @@ -0,0 +1,240 @@ +# Przepływ organizatora — audyt + +> Przejście całej ścieżki człowieka, który pierwszy raz widzi bojo.pl i chce szybko +> zorganizować mecz: landing → brama logowania → trzy kroki kreatora → publikacja → +> zarządzanie meczem. Powstało pod priorytet z [strategia.md §0](./strategia.md) +> („pozyskiwanie organizatorów i szlifowanie przepływu organizacji gry"). +> +> **Stan: 2026-08-08, `O-26`/`O-27` dopisane 2026-08-10, `O-28`…`O-31` dopisane +> 2026-08-12, `O-32`…`O-35` dopisane 2026-08-13.** Wnioski są czytaniem kodu, nie +> obserwacją użytkowników — patrz „Czego ten audyt nie sprawdził" na końcu. + +Ustalenia mają numery `O-n`; [BACKLOG.md](../BACKLOG.md) odwołuje się do tych samych. +Kolumna **stan** mówi, czy rzecz została zrobiona, czy czeka. + +--- + +## Decyzje właściciela produktu (2026-08-08) + +Zapisane, żeby nie wracały co kwartał jako „a może by jednak". + +1. **Brama logowania przed kreatorem ZOSTAJE.** Odroczenie logowania — kreator dostępny bez + konta, konto dopiero przy „Opublikuj mecz" — było rozważone i **odrzucone**. Techniczne + warunki były spełnione (szkic w `localStorage` z TTL 12 h oraz `?next=` już istnieją), + więc to decyzja produktowa, nie brak możliwości. Poprawiamy bramę, nie znosimy jej. +2. **Nazwa organizatora.** Rejestracja e-mailem wymaga imienia i nazwiska. Konto z Google + bez nazwy dostaje powiadomienie kierujące do `/profil`. `displayName()` nigdy nie zwraca + pełnego adresu e-mail, a kreator pokazuje przed publikacją, pod jaką nazwą organizator + się pojawi. +3. **Kanoniczny link do udostępniania meczu to `/wydarzenia/{id}`**, nie `/d/{kod}`. + Powód jest twardy: `robots.ts` trzyma `/d/` poza indeksowaniem (kod dołączenia to jedyna + kontrola dostępu do meczu prywatnego), a crawlery Facebooka i WhatsAppa respektują + `robots.txt` — więc krótki link leci na czat **bez podglądu**. Trasa `/d/[code]` żyje + dalej dla linków już rozesłanych; znika tylko jako drugi przycisk w interfejsie. + +--- + +## Faza 0 — landing `/` + +**Działa i zostaje bez zmian.** Hero mówi językiem organizatora („Zorganizuj mecz w dwie +minuty", `components/home/landing/content.ts`), jedno mocne CTA zamiast czterech słabych, +pasek zaufania („Za darmo · Google lub e-mail · Bez instalacji"), sekcja „Trzy kroki do +składu", FAQ odpowiadające wprost na „ile to zajmie" i „czy muszę mieć konto". + +| # | Ustalenie | Stan | +|---|---|---| +| **O-1** | **Wylogowany traci wejście do kreatora poza stroną główną.** `BottomNavGate.tsx` nie pokazuje dolnej nawigacji niezalogowanym, a pływające `+` (`StickyCta`) żyje wyłącznie na landingu. Oglądając **niepustą** listę na `/wydarzenia` wylogowany nie miał stamtąd żadnego wejścia do kreatora — pusty stan miał swoje CTA od dawna, brakowało go dokładnie w tym przypadku. | zrobione | + +`/mapa` świadomie pominięta: mapa jest pełnoekranowa, a dokładanie CTA psuje układ +pływających kontrolek. + +--- + +## Faza 1 — brama logowania (`/wydarzenia/nowe`) + +**Ekran-brama zostaje.** To nie jest redirect, tylko strona sprzedażowa: nagłówek, trzy +korzyści, karta „Zaloguj się, żeby opublikować mecz" i rozmyty podgląd kreatora z podpisem +„↑ tak wygląda kreator po zalogowaniu". + +| # | Ustalenie | Stan | +|---|---|---| +| **O-2** | **`?next=` gubił query string.** Cel powrotu liczony był z samej ścieżki. Wejście „Zorganizuj tu mecz" ze strony boiska (`?fieldId=`) i „Stwórz mecz w grupie" (`?group=`) wracało po zalogowaniu na goły `/wydarzenia/nowe` — bez boiska i bez grupy, czyli do formularza wyglądającego na zaczęty od zera. | zrobione | +| **O-3** | **Rejestracja e-mailem gubiła cel całkowicie.** `signUpWithEmail` ustawiało `emailRedirectTo` bez argumentu, więc po kliknięciu w link potwierdzający organizator lądował na `/`. Google i magic link przekazywały `next` od zawsze — wyłamywała się tylko rejestracja hasłem. | zrobione | +| **O-4** | **Mecz publikował się pod pełnym adresem e-mail organizatora.** `displayName()` spadało na `user.email`, podczas gdy `firstName()` obcinało adres na „@" od dawna; ta niespójność dwóch funkcji w jednym pliku była wyciekiem na publiczną, indeksowaną stronę meczu z JSON-LD. | zrobione | + +--- + +## Faza 2 — krok 1 „Co i gdzie" + +**Zostaje:** cztery sporty jako chipsy + zapasowy ` setSelectedDate(e.target.value)} + className={inputCls} + /> +

+ + {/* Bookings list */} + {bookingsLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : bookingsForDate.length === 0 ? ( +
+ +

Brak rezerwacji na ten dzień

+
+ ) : ( +
+ {bookingsForDate.map((booking, idx) => { + const isBusy = busy.has(booking.id); + return ( +
+
+
+ {/* Time + status */} +
+ + {booking.startTime.slice(0, 5)}–{booking.endTime.slice(0, 5)} + + +
+ {/* Player name */} +

{booking.userName}

+ {/* Phone */} + {booking.phone && ( +

{booking.phone}

+ )} + {/* Players count + sport */} +

+ {booking.playersCount} os. + {booking.sport ? ` · ${booking.sport}` : ''} +

+
+ + {/* Action buttons */} + {booking.status !== 'cancelled' && ( +
+ {booking.status === 'pending' && ( + + )} + +
+ )} +
+
+ ); + })} +
+ )} +
+ )} + + {/* === Tab: Ustawienia === */} + {tab === 'ustawienia' && ( +
+
+

Typ rezerwacji

+ + {/* Radio: none */} + + + {/* Radio: internal */} + + + {/* Radio: external */} + + + {/* bookingUrl input — shown only for external */} + {bookingType === 'external' && ( +
+ + setBookingUrl(e.target.value)} + placeholder="https://..." + className={`${inputCls} w-full`} + maxLength={500} + /> +
+ )} +
+ + {/* booking_enabled override */} +
+

Widoczność rezerwacji

+ +
+ + {/* Feedback */} + {saveSuccess && ( +
+ Ustawienia zostały zapisane. +
+ )} + {saveError && ( +
+ {saveError} +
+ )} + + +
+ )} +
+ + ); +} diff --git a/frontend/src/app/admin/analityka/page.tsx b/frontend/src/app/admin/analityka/page.tsx new file mode 100644 index 00000000..06a1de32 --- /dev/null +++ b/frontend/src/app/admin/analityka/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import Link from 'next/link'; +import { + Lock, BarChart3, Users, LogIn, CalendarPlus, UserPlus, + UsersRound, RefreshCw, TrendingUp, +} from 'lucide-react'; +import Header from '@/components/layout/Header'; +import { useAuth } from '@/lib/auth'; +import { supabase } from '@/lib/supabase'; +import type { AnalyticsEvent } from '@/lib/analytics'; + +interface Row { + id: string; + user_id: string | null; + user_email: string | null; + event_type: string; + path: string | null; + metadata: Record | null; + created_at: string; +} + +const TYPE_LABELS: Record = { + login: 'Logowanie', + event_created: 'Utworzył mecz', + event_joined: 'Dołączył do meczu', + group_created: 'Utworzył grupę', + group_joined: 'Dołączył do grupy', +}; + +const DAY = 24 * 60 * 60 * 1000; + +/** Local YYYY-MM-DD key for day-bucketing. */ +function dayKey(d: Date): string { + return d.toLocaleDateString('sv-SE'); // ISO-like in local tz +} + +export default function AnalyticsAdminPage() { + const { user, loading: authLoading } = useAuth(); + const [adminState, setAdminState] = useState<'checking' | 'yes' | 'no'>('checking'); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + + // --- Admin check --- + useEffect(() => { + if (authLoading) return; + if (!user) { setAdminState('no'); return; } + supabase.from('profiles').select('is_admin').eq('id', user.id).maybeSingle() + .then(({ data }) => setAdminState(data?.is_admin ? 'yes' : 'no'), () => setAdminState('no')); + }, [authLoading, user]); + + // --- Load last 30 days --- + const load = useCallback(async () => { + setLoading(true); + const since = new Date(Date.now() - 30 * DAY).toISOString(); + const { data } = await supabase + .from('analytics_events') + .select('*') + .gte('created_at', since) + .order('created_at', { ascending: false }) + .limit(10000); + setRows((data as Row[]) ?? []); + setLoading(false); + }, []); + + useEffect(() => { if (adminState === 'yes') load(); }, [adminState, load]); + + // --- Derived metrics --- + const stats = useMemo(() => { + const now = Date.now(); + const since1 = now - DAY; + const since7 = now - 7 * DAY; + + const within = (r: Row, from: number) => new Date(r.created_at).getTime() >= from; + const distinctUsers = (rs: Row[]) => + new Set(rs.filter((r) => r.user_id).map((r) => r.user_id)).size; + + const today = rows.filter((r) => within(r, since1)); + const week = rows.filter((r) => within(r, since7)); + + const countType = (rs: Row[], t: string) => rs.filter((r) => r.event_type === t).length; + + // Returning users: distinct users active on ≥2 different days in the window + const daysByUser = new Map>(); + for (const r of rows) { + if (!r.user_id) continue; + const set = daysByUser.get(r.user_id) ?? new Set(); + set.add(dayKey(new Date(r.created_at))); + daysByUser.set(r.user_id, set); + } + let returning = 0; + daysByUser.forEach((days) => { if (days.size >= 2) returning += 1; }); + const totalUsers30 = daysByUser.size; + + // Daily activity (distinct active users) for last 14 days + const daily: { day: string; users: number; events: number }[] = []; + for (let i = 13; i >= 0; i--) { + const d = new Date(now - i * DAY); + const key = dayKey(d); + const dayRows = rows.filter((r) => dayKey(new Date(r.created_at)) === key); + daily.push({ + day: key.slice(5), // MM-DD + users: new Set(dayRows.filter((r) => r.user_id).map((r) => r.user_id)).size, + events: dayRows.length, + }); + } + + return { + activeToday: distinctUsers(today), + active7: distinctUsers(week), + active30: totalUsers30, + loginsToday: countType(today, 'login'), + logins7: countType(week, 'login'), + eventsCreated7: countType(week, 'event_created'), + eventsJoined7: countType(week, 'event_joined'), + groupsCreated7: countType(week, 'group_created'), + groupsJoined7: countType(week, 'group_joined'), + returning, + totalUsers30, + retentionPct: totalUsers30 > 0 ? Math.round((returning / totalUsers30) * 100) : 0, + daily, + }; + }, [rows]); + + const maxDaily = Math.max(1, ...stats.daily.map((d) => d.events)); + + // ---- Guards ---- + if (authLoading || adminState === 'checking') { + return ( +
+
+
+
+
+ {[1, 2, 3, 4].map((i) =>
)} +
+
+
+ ); + } + + if (adminState === 'no') { + return ( +
+
+
+
+ +

Brak dostępu

+

+ Analityka jest dostępna tylko dla administratorów. +

+ Wróć na stronę główną +
+
+
+ ); + } + + return ( +
+
+
+
+
+

+ Analityka +

+

+ {loading ? 'Ładowanie…' : `Ostatnie 30 dni · ${rows.length} zdarzeń`} +

+
+ +
+ + {/* Active users */} +
+ + + +
+ + {/* Retention highlight */} +
+
+ +

Retencja (30 dni)

+
+

+ {stats.retentionPct}% + + {stats.returning} z {stats.totalUsers30} użytkowników wróciło w innym dniu + +

+

+ To kluczowy miernik — ilu graczy wraca po pierwszej wizycie. Tę liczbę chcemy ruszyć w górę. +

+
+ + {/* Action counts (7d) */} +
+ + + + + +
+ + {/* Daily activity chart */} +
+

Aktywność dzienna (14 dni)

+
+ {stats.daily.map((d) => ( +
+
+
+
+ {d.day} +
+ ))} +
+
+ + {/* Raw log */} +
+

Ostatnie zdarzenia

+ {loading ? ( +
+ {[1, 2, 3, 4, 5].map((i) =>
)} +
+ ) : rows.length === 0 ? ( +

Brak zdarzeń. Pojawią się gdy użytkownicy zaczną korzystać z aplikacji.

+ ) : ( +
    + {rows.slice(0, 100).map((r) => ( +
  • + + {TYPE_LABELS[r.event_type] ?? r.event_type} + + + {r.user_email ?? anonim} + + {formatWhen(r.created_at)} +
  • + ))} +
+ )} +
+
+
+ ); +} + +function StatCard({ + icon: Icon, label, value, sub, accent = false, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: number; + sub?: string; + accent?: boolean; +}) { + return ( +
+
+ + {label} +
+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +function formatWhen(iso: string): string { + const d = new Date(iso); + const diff = Date.now() - d.getTime(); + if (diff < 60_000) return 'teraz'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min temu`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h temu`; + return d.toLocaleDateString('pl-PL', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); +} diff --git a/frontend/src/app/admin/bledy/page.tsx b/frontend/src/app/admin/bledy/page.tsx new file mode 100644 index 00000000..a1f8413c --- /dev/null +++ b/frontend/src/app/admin/bledy/page.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { AlertTriangle, Bug, Loader2, MapPin, MessageSquareWarning, RefreshCw } from 'lucide-react'; +import Link from 'next/link'; +import Header from '@/components/layout/Header'; +import { useAdmin } from '@/lib/admin'; +import { useToast } from '@/lib/toast'; +import { + pobierzZgloszenia, + zmienStatusZgloszenia, + type StatusZgloszenia, + type ZgloszenieBledu, +} from '@/lib/zgloszeniaBledow'; + +const FILTRY: [StatusZgloszenia | 'wszystkie', string][] = [ + ['nowe', 'Nowe'], + ['w_toku', 'W toku'], + ['zamkniete', 'Zamknięte'], + ['wszystkie', 'Wszystkie'], +]; + +const NASTEPNY: Record = { + nowe: 'w_toku', + w_toku: 'zamkniete', + zamkniete: 'nowe', +}; + +const ETYKIETA: Record = { + nowe: 'Nowe', + w_toku: 'W toku', + zamkniete: 'Zamknięte', +}; + +function kiedy(iso: string) { + const d = new Date(iso); + return d.toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +/** + * Panel zgłoszeń błędów. + * + * Dwa rodzaje na jednej liście, bo administrator ma jedno miejsce, w które + * patrzy — ale odróżnione ikoną i kolorem, bo czyta się je inaczej: awaria + * niesie stos i licznik wystąpień, zgłoszenie od człowieka niesie zdanie + * napisane ręką. + * + * Kolejność po `ostatni_raz`: błąd sprzed tygodnia, który wciąż się dzieje, + * jest pilniejszy od wczorajszego, który ucichł. + */ +export default function AdminBledyPage() { + const jestAdmin = useAdmin(); + const { toast } = useToast(); + const [filtr, setFiltr] = useState('nowe'); + const [zgloszenia, setZgloszenia] = useState([]); + const [ladowanie, setLadowanie] = useState(true); + const [rozwiniete, setRozwiniete] = useState(null); + + const wczytaj = useCallback(async () => { + setLadowanie(true); + try { + setZgloszenia(await pobierzZgloszenia(filtr === 'wszystkie' ? undefined : filtr)); + } catch (e) { + toast(e instanceof Error ? e.message : 'Nie udało się wczytać zgłoszeń', 'error'); + } finally { + setLadowanie(false); + } + }, [filtr, toast]); + + useEffect(() => { if (jestAdmin) wczytaj(); }, [jestAdmin, wczytaj]); + + const przelacz = async (z: ZgloszenieBledu) => { + const nowy = NASTEPNY[z.status]; + try { + await zmienStatusZgloszenia(z.id, nowy); + setZgloszenia((cur) => cur.map((x) => (x.id === z.id ? { ...x, status: nowy } : x))); + toast(`Status: ${ETYKIETA[nowy]}`); + } catch (e) { + toast(e instanceof Error ? e.message : 'Nie udało się zmienić statusu', 'error'); + } + }; + + if (!jestAdmin) { + return ( +
+
+
+

Ta strona jest tylko dla administratorów.

+
+
+ ); + } + + return ( +
+
+
+
+

Zgłoszenia błędów

+ +
+ +
+ {FILTRY.map(([wartosc, etykieta]) => ( + + ))} +
+ + {ladowanie ? ( +
+ +
+ ) : zgloszenia.length === 0 ? ( +

+ Nic tu nie ma. {filtr === 'nowe' && 'To dobra wiadomość.'} +

+ ) : ( +
    + {zgloszenia.map((z) => ( +
  • +
    + {z.rodzaj === 'awaria' + ? + : z.rodzaj === 'obiekt' + ? + : } + +
    +

    {z.opis}

    + +
    + {kiedy(z.ostatniRaz)} + {/* Licznik wystąpień to najważniejsza liczba przy awarii: + mówi, czy dotyczy jednej osoby, czy wszystkich. */} + {z.liczba > 1 && ( + + ×{z.liczba} + + )} + {z.wersja && {z.wersja.slice(0, 7)}} +
    + + {/* Przy zgłoszeniu o obiekcie liczy się jedno: dojść do + tego obiektu jednym kliknięciem. */} + {z.fieldId ? ( + + Otwórz obiekt + + ) : z.adres && ( +

    {z.adres}

    + )} + + {rozwiniete === z.id && ( +
    + {z.przegladarka && ( +

    {z.przegladarka}

    + )} + {z.slad && ( +
    +                            {z.slad}
    +                          
    + )} +

    + Pierwszy raz: {kiedy(z.pierwszyRaz)} + {z.userId && ` · użytkownik ${z.userId.slice(0, 8)}`} +

    +
    + )} + +
    + + {(z.slad || z.przegladarka) && ( + + )} +
    +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/admin/boisko/[id]/page.tsx b/frontend/src/app/admin/boisko/[id]/page.tsx new file mode 100644 index 00000000..6c5a4f2d --- /dev/null +++ b/frontend/src/app/admin/boisko/[id]/page.tsx @@ -0,0 +1,428 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { ArrowLeft, Lock, Eye, EyeOff, Trash2 } from 'lucide-react'; +import Link from 'next/link'; +import Header from '@/components/layout/Header'; +import Button from '@/components/ui/Button'; +import { useAuth } from '@/lib/auth'; +import { useAdmin } from '@/lib/admin'; +import { updateField } from '@/lib/api'; +import { supabase } from '@/lib/supabase'; + +const SPORTS = [ + 'piłka nożna', + 'futsal', + 'koszykówka', + 'siatkówka', + 'siatkówka plażowa', + 'piłka ręczna', +] as const; + +const SURFACES = [ + { value: '', label: '— nieznana —' }, + { value: 'grass', label: 'Trawa naturalna' }, + { value: 'artificial', label: 'Trawa sztuczna' }, + { value: 'concrete', label: 'Beton / asfalt' }, + { value: 'sand', label: 'Piasek' }, + { value: 'indoor', label: 'Hala / parkiet' }, +] as const; + +const inputCls = + 'w-full border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent'; + +export default function AdminVenueEditorPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { user, loading: authLoading } = useAuth(); + const isAdmin = useAdmin(); + + const [pageLoading, setPageLoading] = useState(true); + const [notAllowed, setNotAllowed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const [name, setName] = useState(''); + const [address, setAddress] = useState(''); + const [sport, setSport] = useState([]); + const [available, setAvailable] = useState(true); + const [surface, setSurface] = useState(''); + const [isIndoor, setIsIndoor] = useState(false); + const [phone, setPhone] = useState(''); + const [website, setWebsite] = useState(''); + const [contactVisible, setContactVisible] = useState(false); + const [mapVisibility, setMapVisibility] = useState<'public' | 'hidden' | 'organizer_only'>('organizer_only'); + const [visibilityBusy, setVisibilityBusy] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + + useEffect(() => { + if (authLoading) return; + if (!user) { setNotAllowed(true); setPageLoading(false); return; } + }, [authLoading, user]); + + useEffect(() => { + if (authLoading) return; + if (!user) return; + + (async () => { + const { data: f, error } = await supabase + .from('fields') + .select('*') + .eq('id', id) + .single(); + if (error || !f) { + setNotAllowed(true); + } else { + setName(f.name); + setAddress(f.address); + setSport(f.sport ?? []); + setAvailable(f.available); + setSurface(f.surface ?? ''); + setIsIndoor(f.is_indoor ?? false); + setPhone(f.phone ?? ''); + setWebsite(f.website ?? ''); + setContactVisible(f.contact_visible ?? false); + setMapVisibility(f.map_visibility ?? 'organizer_only'); + } + setPageLoading(false); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user]); + + const toggleSport = (s: string) => { + setSport((prev) => + prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s], + ); + }; + + const handleToggleVisibility = async () => { + if (!isAdmin) return; + const next = mapVisibility === 'public' ? 'hidden' : 'public'; + const nextModeration = next === 'public' ? 'approved' : 'hidden'; + setVisibilityBusy(true); + try { + await supabase.from('fields').update({ + map_visibility: next, + moderation_status: nextModeration, + }).eq('id', id); + setMapVisibility(next); + } finally { setVisibilityBusy(false); } + }; + + const handleDelete = async () => { + if (!isAdmin || !deleteConfirm) return; + setDeleteBusy(true); + try { + await supabase.from('fields').delete().eq('id', id); + router.push('/admin/moderacja'); + } finally { setDeleteBusy(false); } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!isAdmin) return; + setSubmitting(true); + setError(null); + try { + await updateField(id, { + name, + address, + sport, + available, + surface, + isIndoor, + phone: phone.trim() || undefined, + website: website.trim() || undefined, + contactVisible, + }); + router.push('/mapa'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Nie udało się zapisać zmian'); + setSubmitting(false); + } + }; + + if (pageLoading || authLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (notAllowed || !isAdmin) { + return ( +
+
+
+
+ +

Brak dostępu

+

Ta strona jest dostępna tylko dla administratorów.

+ + Wróć do mapy + +
+
+
+ ); + } + + return ( +
+
+
+
+ + + +

Edytuj boisko

+
+ +
+
+
+ + setName(e.target.value)} + className={inputCls} + required + maxLength={120} + /> +
+ +
+ + setAddress(e.target.value)} + className={inputCls} + required + maxLength={200} + /> +
+ +
+ +
+ {SPORTS.map((s) => ( + + ))} +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+
+ +
+
+

Dane kontaktowe

+

+ Telefon i e-mail ze scrapera OSM są domyślnie ukryte. Zaznacz poniżej, żeby udostępnić je użytkownikom. +

+ +
+
+ + setPhone(e.target.value)} + placeholder="+48 123 456 789" + className={inputCls} + maxLength={30} + /> +
+ +
+ + setWebsite(e.target.value)} + placeholder="https://" + className={inputCls} + maxLength={200} + /> +
+ + +
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+

Panel rezerwacji

+ + → Panel rezerwacji + +
+ + {/* ── Admin: widoczność + usuwanie ── */} + {isAdmin && ( +
+

Widoczność na mapie

+ +
+
+ {mapVisibility === 'public' + ? + : } +
+

+ {mapVisibility === 'public' ? 'Widoczny publicznie' : 'Ukryty'} +

+

+ {mapVisibility === 'public' + ? 'Obiekt pojawia się na mapie dla wszystkich' + : 'Obiekt nie jest widoczny na mapie'} +

+
+
+ +
+ +
+

Strefa niebezpieczna

+ {!deleteConfirm ? ( + + ) : ( +
+

Na pewno? Tej operacji nie można cofnąć.

+
+ + +
+
+ )} +
+
+ )} + +
+ + + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/moderacja/page.tsx b/frontend/src/app/admin/moderacja/page.tsx new file mode 100644 index 00000000..3c41e3ac --- /dev/null +++ b/frontend/src/app/admin/moderacja/page.tsx @@ -0,0 +1,496 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import Link from 'next/link'; +import { + Lock, Search, Trash2, EyeOff, Check, Satellite, RefreshCw, MapPin, ChevronDown, ChevronRight, +} from 'lucide-react'; +import Header from '@/components/layout/Header'; +import { useAuth } from '@/lib/auth'; +import { supabase } from '@/lib/supabase'; +import { sportEmoji } from '@/lib/sports'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ModerationStatus = 'pending' | 'approved' | 'hidden'; + +interface VenueRow { + id: string; + name: string; + address: string; + sport: string[]; + lat: number; + lng: number; + photo_url: string | null; + photo_reference: string | null; + photo_source: string | null; + map_visibility: string; + moderation_status: ModerationStatus | null; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function satelliteUrl(lat: number, lng: number): string { + const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; + if (!token) return ''; + return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/${lng},${lat},17,0/400x240@2x?access_token=${token}`; +} + +function bestPhotoSrc(v: VenueRow): string { + if (v.photo_reference) return `/api/venue-photo?ref=${encodeURIComponent(v.photo_reference)}&w=400`; + if (v.photo_url) return v.photo_url; + return satelliteUrl(v.lat, v.lng); +} + +function photoLabel(v: VenueRow): string { + if (v.photo_reference) return 'Google'; + if (v.photo_url) { + if (v.photo_source === 'wikimedia') return 'Wikimedia'; + if (v.photo_source === 'satellite') return 'Satelita (scraper)'; + return 'URL'; + } + return 'Satelita'; +} + +const STATUS_META: Record = { + pending: { label: 'Do sprawdzenia', cls: 'bg-amber-100 text-amber-700' }, + approved: { label: 'Zatwierdzone', cls: 'bg-green-100 text-green-700' }, + hidden: { label: 'Ukryte', cls: 'bg-red-100 text-red-700' }, +}; + +// --------------------------------------------------------------------------- +// Toast +// --------------------------------------------------------------------------- +interface Toast { id: number; msg: string; ok: boolean } +let _tid = 0; + +function useToasts() { + const [toasts, setToasts] = useState([]); + const add = useCallback((msg: string, ok = true) => { + const id = ++_tid; + setToasts((t) => [...t, { id, msg, ok }]); + setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500); + }, []); + return { toasts, add }; +} + +// --------------------------------------------------------------------------- +// VenueCard +// --------------------------------------------------------------------------- + +function VenueCard({ + venue, onUpdate, onDelete, onSkip, isSkipped, +}: { + venue: VenueRow; + onUpdate: (id: string, patch: Partial) => Promise; + onDelete: (id: string) => Promise; + onSkip?: () => void; + isSkipped?: boolean; +}) { + const [busy, setBusy] = useState(false); + const [imgErr, setImgErr] = useState(false); + const status = venue.moderation_status ?? 'pending'; + const smeta = STATUS_META[status] ?? STATUS_META.pending; + + const hasExternalPhoto = !!(venue.photo_reference || venue.photo_url); + const photoSrc = imgErr ? satelliteUrl(venue.lat, venue.lng) : bestPhotoSrc(venue); + + async function act(patch: Partial) { + setBusy(true); + await onUpdate(venue.id, patch); + setBusy(false); + } + + const btnBase = 'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40'; + const btnGhost = `${btnBase} border border-slate-200 bg-white text-slate-600 hover:border-slate-400`; + const btnGreen = `${btnBase} bg-green-600 text-white hover:bg-green-700`; + const btnAmber = `${btnBase} bg-amber-500 text-white hover:bg-amber-600`; + const btnRed = `${btnBase} bg-red-500 text-white hover:bg-red-600`; + const btnSkip = `${btnBase} border border-slate-200 bg-white text-slate-500 hover:bg-slate-50`; + + return ( +
+ {/* Photo */} +
+ {photoSrc && ( + // eslint-disable-next-line @next/next/no-img-element + setImgErr(true)} + /> + )} + + {imgErr ? 'Satelita' : photoLabel(venue)} + + + {smeta.label} + + {isSkipped && ( + + ← Pominięty + + )} +
+ + {/* Info */} +
+

+ {venue.name} +

+

+ {venue.address} +

+
+ {venue.sport.map((s) => ( + + {sportEmoji(s)} {s} + + ))} +
+ + {/* Photo controls */} +
+ {hasExternalPhoto ? ( + + ) : ( + + Satelita + + )} +
+ + {/* Status + action controls */} +
+ {status !== 'approved' && ( + + )} + {status !== 'hidden' && ( + + )} + {status !== 'pending' && ( + + )} + {onSkip && !isSkipped && ( + + )} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +const TABS: { key: ModerationStatus | 'all'; label: string }[] = [ + { key: 'all', label: 'Wszystkie' }, + { key: 'pending', label: 'Do sprawdzenia' }, + { key: 'approved', label: 'Zatwierdzone' }, + { key: 'hidden', label: 'Ukryte' }, +]; + +export default function ModeracjaPage() { + const { user, loading: authLoading } = useAuth(); + const [adminState, setAdminState] = useState<'checking' | 'yes' | 'no'>('checking'); + + useEffect(() => { + if (authLoading) return; + if (!user) { setAdminState('no'); return; } + supabase + .from('profiles').select('is_admin').eq('id', user.id).maybeSingle() + .then( + ({ data }) => setAdminState(data?.is_admin ? 'yes' : 'no'), + () => setAdminState('no'), + ); + }, [authLoading, user]); + + const [venues, setVenues] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('pending'); + const [search, setSearch] = useState(''); + const { toasts, add: addToast } = useToasts(); + + // Skip set — session-only, resets on reload + const [skipped, setSkipped] = useState>(new Set()); + const [skippedOpen, setSkippedOpen] = useState(false); + + // Session progress tracking — record how many were pending when we started + const [sessionTotal, setSessionTotal] = useState(null); + const [sessionDone, setSessionDone] = useState(0); + + const load = useCallback(async () => { + setLoading(true); + const { data, error } = await supabase + .from('fields') + .select('id,name,address,sport,lat,lng,photo_url,photo_reference,photo_source,map_visibility,moderation_status') + .order('name'); + if (!error && data) setVenues(data as VenueRow[]); + setLoading(false); + }, []); + + useEffect(() => { + if (adminState === 'yes') load(); + }, [adminState, load]); + + // Set session total once data is loaded (only once) + useEffect(() => { + if (!loading && sessionTotal === null && venues.length > 0) { + const pending = venues.filter((v) => (v.moderation_status ?? 'pending') === 'pending').length; + setSessionTotal(pending); + } + }, [loading, venues, sessionTotal]); + + const filtered = useMemo(() => { + let list = venues; + if (tab !== 'all') list = list.filter((v) => (v.moderation_status ?? 'pending') === tab); + if (search.trim()) { + const q = search.toLowerCase(); + list = list.filter((v) => v.name.toLowerCase().includes(q) || v.address?.toLowerCase().includes(q)); + } + return list; + }, [venues, tab, search]); + + // Separate active (non-skipped) from skipped within filtered + const activeVenues = useMemo(() => filtered.filter((v) => !skipped.has(v.id)), [filtered, skipped]); + const skippedVenues = useMemo(() => filtered.filter((v) => skipped.has(v.id)), [filtered, skipped]); + + const counts = useMemo(() => ({ + pending: venues.filter((v) => (v.moderation_status ?? 'pending') === 'pending').length, + approved: venues.filter((v) => v.moderation_status === 'approved').length, + hidden: venues.filter((v) => v.moderation_status === 'hidden').length, + }), [venues]); + + const handleSkip = useCallback((id: string) => { + setSkipped((prev) => new Set([...Array.from(prev), id])); + }, []); + + const handleUpdate = useCallback(async (id: string, patch: Partial) => { + const venue = venues.find((v) => v.id === id); + const wasPending = (venue?.moderation_status ?? 'pending') === 'pending'; + const becomesDecided = patch.moderation_status === 'approved' || patch.moderation_status === 'hidden'; + + const { error } = await supabase.from('fields').update(patch).eq('id', id); + if (error) { addToast(`Błąd: ${error.message}`, false); return; } + setVenues((prev) => prev.map((v) => v.id === id ? { ...v, ...patch } : v)); + addToast('Zapisano'); + + if (wasPending && becomesDecided) { + setSessionDone((n) => n + 1); + } + }, [venues, addToast]); + + const handleDelete = useCallback(async (id: string) => { + const { error } = await supabase.from('fields').delete().eq('id', id); + if (error) { addToast(`Błąd: ${error.message}`, false); return; } + setVenues((prev) => prev.filter((v) => v.id !== id)); + setSkipped((prev) => { const n = new Set(prev); n.delete(id); return n; }); + addToast('Usunięto obiekt'); + }, [addToast]); + + // --- Auth guards --- + if (authLoading || adminState === 'checking') { + return ( +
+
+
Ładowanie…
+
+ ); + } + if (adminState === 'no') { + return ( +
+
+
+ +

Brak dostępu

+
+
+ ); + } + + const progressPct = sessionTotal ? Math.round((sessionDone / sessionTotal) * 100) : 0; + + return ( +
+
+ + {/* Toasts */} +
+ {toasts.map((t) => ( +
+ {t.msg} +
+ ))} +
+ +
+ {/* Page header */} +
+
+

Moderacja boisk

+

+ {counts.pending} do sprawdzenia · {counts.approved} zatwierdzone · {counts.hidden} ukryte +

+
+ ← Admin +
+ + {/* Session progress bar */} + {sessionTotal !== null && sessionTotal > 0 && ( +
+
+
+ Postęp sesji + + {sessionDone} / {sessionTotal} przetworzonych + {skipped.size > 0 && ( + · {skipped.size} pominiętych + )} + +
+
+
+
+
+
+ )} + + {/* Filters */} +
+
+ {TABS.map(({ key, label }) => { + const cnt = key === 'all' ? venues.length : counts[key as ModerationStatus] ?? 0; + return ( + + ); + })} +
+
+ + setSearch(e.target.value)} + placeholder="Szukaj po nazwie lub adresie…" + className="w-full border border-slate-200 rounded-xl pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white" + /> +
+ {/* Result count */} + + {activeVenues.length} obiektów + {filtered.length !== venues.length && ` / ${venues.length} łącznie`} + +
+ + {/* Active grid */} + {loading ? ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ ) : activeVenues.length === 0 && skippedVenues.length === 0 ? ( +
Brak wyników
+ ) : activeVenues.length === 0 && skippedVenues.length > 0 ? ( +
+

Wszystkie obiekty pominięte

+

Wróć do pominiętych poniżej, żeby je przetworzyć.

+
+ ) : ( +
+ {activeVenues.map((v) => ( + handleSkip(v.id)} + /> + ))} +
+ )} + + {/* Skipped section */} + {skippedVenues.length > 0 && ( +
+ + + {skippedOpen && ( +
+ {skippedVenues.map((v) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/admin/outreach/page.tsx b/frontend/src/app/admin/outreach/page.tsx new file mode 100644 index 00000000..22da67a4 --- /dev/null +++ b/frontend/src/app/admin/outreach/page.tsx @@ -0,0 +1,916 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import Link from 'next/link'; +import { + Lock, Search, Phone, Mail, Globe, Download, Star, ChevronDown, + UserCheck, RotateCcw, Check, Sparkles, X, Building2, Clock, ExternalLink, MapPin, + AlertTriangle, Trash2, Eye, EyeOff, +} from 'lucide-react'; +import Header from '@/components/layout/Header'; +import Button from '@/components/ui/Button'; +import { useAuth, displayName } from '@/lib/auth'; +import { supabase } from '@/lib/supabase'; +import { getFields } from '@/lib/api'; +import type { MapVisibility } from '@/types'; +import { + getOutreachMap, saveOutreach, + STATUS_META, STATUS_ORDER, BOOKING_SYSTEM_META, BOOKING_SYSTEM_ORDER, + type Outreach, type OutreachStatus, type BookingSystem, type OutreachPatch, +} from '@/lib/outreach'; +import type { Field } from '@/types'; +import { + OUTREACH_DATA_FILTERS, fieldHasData, type DataKey, +} from '@/lib/fieldFilters'; + +const inputCls = + 'border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500'; + +function defaultOutreach(fieldId: string): Outreach { + return { fieldId, status: 'nowy', bookingSystem: 'nieznany', priority: 0 }; +} + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +function formatPl(iso?: string): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleDateString('pl-PL', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function siteHost(url: string): string { + try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url.slice(0, 40); } +} + +// --------------------------------------------------------------------------- +// Map visibility — shared metadata + control +// --------------------------------------------------------------------------- +const VIS_OPTIONS: { value: MapVisibility; icon: React.ElementType; label: string; short: string; cls: string }[] = [ + { value: 'public', icon: Eye, label: 'Na mapie', short: 'Mapa', cls: 'bg-green-50 text-green-700 border-green-200' }, + { value: 'organizer_only', icon: EyeOff, label: 'Tylko org.', short: 'Org.', cls: 'bg-amber-50 text-amber-700 border-amber-200' }, + { value: 'hidden', icon: X, label: 'Ukryty', short: 'Ukryty', cls: 'bg-red-50 text-red-700 border-red-200' }, +]; + +function VisibilityControl({ value, onChange, busy, compact }: { + value: MapVisibility; + onChange: (v: MapVisibility) => void; + busy?: boolean; + compact?: boolean; +}) { + return ( +
e.stopPropagation()} + > + {VIS_OPTIONS.map(({ value: v, icon: Icon, label, short, cls }) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Toast +// --------------------------------------------------------------------------- +interface Toast { id: number; message: string; type: 'success' | 'error' } +let toastCounter = 0; + +// --------------------------------------------------------------------------- +// CSV export +// --------------------------------------------------------------------------- +function csvCell(v: string | number | undefined | null): string { + const s = String(v ?? ''); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +function exportCsv(rows: { field: Field; o: Outreach }[]) { + const headers = [ + 'Nazwa', 'Dzielnica', 'Adres', 'Kod', 'Sporty', 'Telefon', 'E-mail', 'WWW', 'Operator', + 'Status', 'System rezerwacji', 'Przypisany', 'Osoba kontaktowa', + 'Ostatni kontakt', 'Followup', 'Notatki', 'AI', 'Ostatnia zmiana', + ]; + const lines = rows.map(({ field: f, o }) => [ + f.name, f.district, f.address, f.postcode, f.sport.join(' / '), + f.phone, f.email, f.website, f.operator, + STATUS_META[o.status].label, BOOKING_SYSTEM_META[o.bookingSystem], + o.assignedName, o.contactPerson, + o.lastContactedAt ? formatPl(o.lastContactedAt) : '', + o.nextFollowupAt ? formatPl(o.nextFollowupAt) : '', + o.notes, o.aiSummary, o.updatedByName, + ].map(csvCell).join(',')); + + const csv = '' + [headers.join(','), ...lines].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `obiekty-outreach-${todayISO()}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +// =========================================================================== +export default function OutreachPanel() { + const { user, loading: authLoading } = useAuth(); + const [adminState, setAdminState] = useState<'checking' | 'yes' | 'no'>('checking'); + + const [fields, setFields] = useState([]); + const [outreach, setOutreach] = useState>(new Map()); + const [loading, setLoading] = useState(true); + + const [toasts, setToasts] = useState([]); + const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => { + const id = ++toastCounter; + setToasts((p) => [...p, { id, message, type }]); + setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 3500); + }, []); + + // --- Filters --- + const [search, setSearch] = useState(''); + const [fStatus, setFStatus] = useState<'all' | OutreachStatus>('all'); + const [fSport, setFSport] = useState('all'); + const [fDistrict, setFDistrict] = useState('all'); + const [fAssign, setFAssign] = useState<'all' | 'mine' | 'unassigned'>('all'); + const [fVis, setFVis] = useState<'all' | MapVisibility>('all'); + const [fData, setFData] = useState([]); + const toggleData = useCallback((k: DataKey) => { + setFData((prev) => (prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k])); + }, []); + const [fHideDone, setFHideDone] = useState(false); + const [fDuplicates, setFDuplicates] = useState(false); + const [expanded, setExpanded] = useState(null); + + // --- Admin check (own, to avoid access-denied flash) --- + useEffect(() => { + if (authLoading) return; + if (!user) { setAdminState('no'); return; } + supabase.from('profiles').select('is_admin').eq('id', user.id).maybeSingle() + .then(({ data }) => setAdminState(data?.is_admin ? 'yes' : 'no'), () => setAdminState('no')); + }, [authLoading, user]); + + // --- Load data --- + useEffect(() => { + if (adminState !== 'yes') return; + let cancelled = false; + (async () => { + setLoading(true); + try { + const [fieldsRes, oMap] = await Promise.all([ + getFields({ limit: 5000 }), + getOutreachMap(), + ]); + if (cancelled) return; + setFields(fieldsRes.fields); + setOutreach(oMap); + } catch (e) { + if (!cancelled) addToast(e instanceof Error ? e.message : 'Błąd ładowania', 'error'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [adminState, addToast]); + + const getO = useCallback( + (fieldId: string): Outreach => outreach.get(fieldId) ?? defaultOutreach(fieldId), + [outreach], + ); + + // --- Persist a patch --- + const persist = useCallback(async (fieldId: string, patch: OutreachPatch) => { + if (!user) return; + // optimistic + setOutreach((prev) => { + const next = new Map(prev); + next.set(fieldId, { ...(prev.get(fieldId) ?? defaultOutreach(fieldId)), ...patch } as Outreach); + return next; + }); + try { + const saved = await saveOutreach(fieldId, patch, { id: user.id, name: displayName(user) }); + setOutreach((prev) => { const n = new Map(prev); n.set(fieldId, saved); return n; }); + } catch (e) { + addToast(e instanceof Error ? e.message : 'Nie udało się zapisać', 'error'); + } + }, [user, addToast]); + + // --- Suspicious contacts: phone/email shared by 3+ fields --- + const suspiciousMap = useMemo(() => { + const phoneCnt = new Map(); + const emailCnt = new Map(); + fields.forEach((f) => { + if (f.phone) phoneCnt.set(f.phone, (phoneCnt.get(f.phone) ?? 0) + 1); + if (f.email) emailCnt.set(f.email, (emailCnt.get(f.email) ?? 0) + 1); + }); + const result = new Map(); + fields.forEach((f) => { + const pc = f.phone ? (phoneCnt.get(f.phone) ?? 1) : 1; + const ec = f.email ? (emailCnt.get(f.email) ?? 1) : 1; + if (pc >= 3 || ec >= 3) result.set(f.id, { phoneCount: pc, emailCount: ec }); + }); + return result; + }, [fields]); + + // --- Toggle map_visibility on a field --- + const changeVisibility = useCallback(async (fieldId: string, v: MapVisibility) => { + const { error } = await supabase + .from('fields') + .update({ map_visibility: v }) + .eq('id', fieldId); + if (error) { addToast(error.message, 'error'); return; } + setFields((prev) => prev.map((f) => f.id === fieldId ? { ...f, mapVisibility: v } : f)); + addToast(v === 'public' ? 'Obiekt widoczny na mapie' : 'Obiekt ukryty z mapy'); + }, [addToast]); + + // --- Clear AI-enriched contact data from fields table --- + const clearContact = useCallback(async (fieldId: string) => { + const { error } = await supabase + .from('fields') + .update({ phone: null, email: null, website: null }) + .eq('id', fieldId); + if (error) { addToast(error.message, 'error'); return; } + setFields((prev) => prev.map((f) => + f.id === fieldId ? { ...f, phone: undefined, email: undefined, website: undefined } : f, + )); + addToast('Dane kontaktowe wyczyszczone'); + }, [addToast]); + + // --- Sports for filter dropdown --- + const sportOptions = useMemo(() => { + const s = new Set(); + fields.forEach((f) => f.sport.forEach((x) => s.add(x))); + return Array.from(s).sort(); + }, [fields]); + + // --- Districts for filter dropdown --- + const districtOptions = useMemo(() => { + const s = new Set(); + fields.forEach((f) => { if (f.district) s.add(f.district); }); + return Array.from(s).sort((a, b) => a.localeCompare(b, 'pl')); + }, [fields]); + + // --- Filtered + sorted rows --- + const rows = useMemo(() => { + const q = search.trim().toLowerCase(); + const list = fields + .map((f) => ({ field: f, o: getO(f.id) })) + .filter(({ field: f, o }) => { + if (q && !f.name.toLowerCase().includes(q) && !f.address.toLowerCase().includes(q)) return false; + if (fStatus !== 'all' && o.status !== fStatus) return false; + if (fSport !== 'all' && !f.sport.includes(fSport)) return false; + if (fDistrict !== 'all' && f.district !== fDistrict) return false; + if (fAssign === 'mine' && o.assignedTo !== user?.id) return false; + if (fAssign === 'unassigned' && o.assignedTo) return false; + if (fVis !== 'all' && f.mapVisibility !== fVis) return false; + // Multi-select data requirements (AND). 'booking' also looks at the + // outreach record, since the team logs the reservation system there. + for (const k of fData) { + const ok = k === 'booking' + ? (o.bookingSystem !== 'nieznany' && o.bookingSystem !== 'brak') || fieldHasData(f, 'booking') + : fieldHasData(f, k); + if (!ok) return false; + } + if (fHideDone && (o.status === 'umowiony' || o.status === 'odrzucony')) return false; + if (fDuplicates && !suspiciousMap.has(f.id)) return false; + return true; + }); + // sort: high priority first, then has-contact-info, then name + list.sort((a, b) => { + if (b.o.priority !== a.o.priority) return b.o.priority - a.o.priority; + const ca = a.field.phone || a.field.email || a.field.website ? 1 : 0; + const cb = b.field.phone || b.field.email || b.field.website ? 1 : 0; + if (cb !== ca) return cb - ca; + return a.field.name.localeCompare(b.field.name, 'pl'); + }); + return list; + }, [fields, getO, search, fStatus, fSport, fDistrict, fAssign, fVis, fData, fHideDone, fDuplicates, suspiciousMap, user?.id]); + + const filtersActive = !!search || fStatus !== 'all' || fSport !== 'all' || fDistrict !== 'all' || fAssign !== 'all' || fVis !== 'all' || fData.length > 0 || fHideDone || fDuplicates; + + // --- Pipeline stats --- + const stats = useMemo(() => { + const counts = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record; + fields.forEach((f) => { counts[getO(f.id).status]++; }); + return counts; + }, [fields, getO]); + + // ---- Guards ---- + if (authLoading || adminState === 'checking') { + return ( +
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) =>
)} +
+
+
+ ); + } + + if (adminState === 'no') { + return ( +
+
+
+
+ +

Brak dostępu

+

Panel kontaktu z obiektami jest dostępny tylko dla administratorów.

+ Wróć na stronę główną +
+
+
+ ); + } + + return ( +
+
+
+ {/* Title row */} +
+
+

Kontakt z obiektami

+

+ {loading ? 'Ładowanie…' : `${fields.length} obiektów łącznie`} +

+
+ +
+ + {/* Pipeline stats */} +
+ {STATUS_ORDER.map((s) => ( + + ))} +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + placeholder="Szukaj po nazwie lub adresie…" + className={`${inputCls} w-full pl-9`} + /> +
+ + {districtOptions.length > 0 && ( + + )} + + + + + {/* Count badge */} + {!loading && ( + + {rows.length}{filtersActive ? ` / ${fields.length}` : ''} obiektów + + )} + + {/* Multi-select: which data the venue has (AND) */} +
+ Ma dane: + {OUTREACH_DATA_FILTERS.map(({ key, label }) => { + const active = fData.includes(key); + return ( + + ); + })} + {fData.length > 0 && ( + + )} +
+
+ + {/* Table */} + {loading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) =>
)} +
+ ) : rows.length === 0 ? ( +
+

Brak obiektów spełniających filtry

+
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map(({ field: f, o }) => ( + setExpanded((cur) => (cur === f.id ? null : f.id))} + onPatch={(patch) => persist(f.id, patch)} + currentUser={user ? { id: user.id, name: displayName(user) } : null} + onToast={addToast} + suspicious={suspiciousMap.get(f.id)} + onClearContact={() => clearContact(f.id)} + onVisibilityChange={(v) => changeVisibility(f.id, v)} + /> + ))} + +
ObiektMapaKontaktStatusSystemPrzypisany
+
+ )} +
+ + {/* Toasts */} +
+ {toasts.map((t) => ( +
+ {t.message} +
+ ))} +
+
+ ); +} + +// =========================================================================== +// Row + expandable editor +// =========================================================================== +interface RowProps { + field: Field; + o: Outreach; + isExpanded: boolean; + onToggle: () => void; + onPatch: (patch: OutreachPatch) => void; + currentUser: { id: string; name: string } | null; + onToast: (m: string, t?: 'success' | 'error') => void; + suspicious?: { phoneCount: number; emailCount: number }; + onClearContact: () => Promise; + onVisibilityChange: (v: MapVisibility) => Promise; +} + +function OutreachRow({ field: f, o, isExpanded, onToggle, onPatch, currentUser, onToast, suspicious, onClearContact, onVisibilityChange }: RowProps) { + // Local draft for free-text fields (saved explicitly) + const [notes, setNotes] = useState(o.notes ?? ''); + const [contactPerson, setContactPerson] = useState(o.contactPerson ?? ''); + const [followup, setFollowup] = useState(o.nextFollowupAt ?? ''); + const [assignName, setAssignName] = useState(o.assignedName ?? ''); + const [savingDraft, setSavingDraft] = useState(false); + const [clearingContact, setClearingContact] = useState(false); + const [togglingVis, setTogglingVis] = useState(false); + + // keep drafts in sync if outreach changes underneath + useEffect(() => { setNotes(o.notes ?? ''); }, [o.notes]); + useEffect(() => { setContactPerson(o.contactPerson ?? ''); }, [o.contactPerson]); + useEffect(() => { setFollowup(o.nextFollowupAt ?? ''); }, [o.nextFollowupAt]); + useEffect(() => { setAssignName(o.assignedName ?? ''); }, [o.assignedName]); + + const dirty = + notes !== (o.notes ?? '') || + contactPerson !== (o.contactPerson ?? '') || + followup !== (o.nextFollowupAt ?? '') || + assignName !== (o.assignedName ?? ''); + + const saveDraft = async () => { + setSavingDraft(true); + const patch: OutreachPatch = { notes, contactPerson, nextFollowupAt: followup }; + if (assignName !== (o.assignedName ?? '')) { + patch.assignedName = assignName || null; + if (!assignName) patch.assignedTo = null; + } + await onPatch(patch); + setSavingDraft(false); + onToast('Zapisano'); + }; + + const claim = () => { + if (!currentUser) return; + onPatch({ assignedTo: currentUser.id, assignedName: currentUser.name }); + }; + const release = () => onPatch({ assignedTo: null, assignedName: null }); + + const markContactedToday = () => onPatch({ lastContactedAt: new Date().toISOString() }); + + const isMyRow = o.assignedTo === currentUser?.id; + const isSomeoneElsesRow = !!o.assignedTo && !isMyRow; + + return ( + <> + + {/* Obiekt */} + +
+ +
+
+

{f.name}

+
+

+ {f.district && {f.district} · } + {f.address} +

+ {f.sport.length > 0 && ( +

{f.sport.join(' · ')}

+ )} +
+
+ + + {/* Mapa — szybki przełącznik widoczności */} + + { + setTogglingVis(true); + await onVisibilityChange(v); + setTogglingVis(false); + }} + /> + + + {/* Kontakt — pełny tekst */} + +
+ {suspicious && ( + + + {Math.max(suspicious.phoneCount, suspicious.emailCount)}× ten sam kontakt + + )} + {f.phone ? ( + e.stopPropagation()}> + {f.phone} + + ) : null} + {f.email ? ( + e.stopPropagation()}> + {f.email} + + ) : null} + {f.website ? ( + e.stopPropagation()}> + {siteHost(f.website)} + + ) : null} + {!f.phone && !f.email && !f.website && ( + brak kontaktu + )} +
+ + + {/* Status */} + +
+ + +
+ + + {/* System rezerwacji */} + + + + + {/* Przypisany */} + + {o.assignedName ? ( +
+ + {o.assignedName} + + {isMyRow ? ( + + ) : ( + + )} +
+ ) : ( + + )} + + + {/* Expand chevron */} + + + + + + {/* Expanded editor */} + {isExpanded && ( + + + + {/* Dane obiektu */} +
+ {/* Header: name + address + link */} +
+
+

{f.name}

+

+ + {f.address}{f.postcode ? `, ${f.postcode}` : ''} +

+ {f.sport.length > 0 && ( +

{f.sport.join(' · ')}

+ )} +
+
+ { + setTogglingVis(true); + await onVisibilityChange(v); + setTogglingVis(false); + }} + /> + e.stopPropagation()} + className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-primary-700 bg-primary-50 hover:bg-primary-100 transition-colors" + > + Otwórz + +
+
+
+ {f.phone && ( + + {f.phone} + + )} + {f.email && ( + + {f.email} + + )} + {f.website && ( + + {f.website} + + )} + {f.operator && ( + + {f.operator} + + )} + {f.openingHours && ( + + + {f.openingHours} + + )} + {f.description && ( +

{f.description}

+ )} +
+ {suspicious && ( +
+

+ + {f.phone && suspicious.phoneCount >= 3 && <>Telefon {f.phone} używany w {suspicious.phoneCount} obiektach. } + {f.email && suspicious.emailCount >= 3 && <>E-mail {f.email} używany w {suspicious.emailCount} obiektach. } + Prawdopodobnie błędne dane z AI. +

+ +
+ )} +
+ + {/* AI enrichment */} + {(o.aiSummary || o.bookingUrl) && ( +
+ +
+

+ AI znalazł{o.aiEnrichedAt ? ` · ${formatPl(o.aiEnrichedAt)}` : ''} + {o.bookingProvider && · {o.bookingProvider}} +

+ {o.aiSummary && ( +

{o.aiSummary}

+ )} + {o.bookingUrl && ( + + + {o.bookingUrl} + + )} +
+
+ )} + +
+ {/* Notes */} +
+ +