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/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..3e6553b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +# 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` celowo NIE wchodzi do CI: wymaga kluczy Supabase +# (NEXT_PUBLIC_SUPABASE_URL/ANON_KEY) — do dodania po skonfigurowaniu +# sekretów repo, patrz BACKLOG. +name: CI + +on: + push: + branches: [master] + 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: Testy (Vitest) + working-directory: frontend + run: npm test + + - name: Spójność dokumentacji + run: node scripts/check-docs.mjs 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.yml b/.github/workflows/import-osm.yml new file mode 100644 index 00000000..b4e5fc2b --- /dev/null +++ b/.github/workflows/import-osm.yml @@ -0,0 +1,77 @@ +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: 'Region Geofabrik (lubelskie, wielkopolskie, mazowieckie…)' + default: 'lubelskie' + required: true + type: string + 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/.gitignore b/.gitignore index 3ae21f6c..e1e75351 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,117 @@ -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 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..f3f460dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,218 @@ +# 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 test # Vitest, 58 testów +``` + +`npm run lint` **nie działa** bez interaktywnej konfiguracji ESLint — pomijaj. + +## 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_RECURRING`, `SHOW_CUP`, `SHOW_GAME_ALERTS`, `SHOW_SMS_FEATURES` +(`frontend/src/lib/features.ts`) oraz `FEATURE_RESERVATIONS` +(`frontend/src/config/features.ts`) — wszystkie są dziś wyłączone. + +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. + +**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_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`. 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..a003d07b --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,332 @@ +# 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) + +_Ostatnia aktualizacja: 2026-08-04_ + +--- + +## 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 — „widoczne dla grupy" +Wizja wymienia trzy poziomy: prywatny / widoczny dla grupy / publiczny. +Kod ma dwa: `events.visibility` to CHECK `('private','public')` (`002_events_and_auth.sql`). + +Kolumna `group_id` (`051_group_field.sql`) steruje **listowaniem** meczu w grupie, nie +**dostępem** do niego — mecz przypisany do grupy jest nadal albo publiczny, albo dostępny +tylko przez kod. + +Zakres: migracja rozszerzająca CHECK + polityka RLS + opcja w kreatorze meczu i edycji. + +### 1.2 Powiadomienie dla członków grupy o utworzeniu gry +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** (patrz §3) — brakuje wyzwalacza przy `createEvent` +z `group_id`. Jedyna dzisiejsza ścieżka to `game_alerts` (promień + sport), oparta +o lokalizację, nie o członkostwo, i dodatkowo ukryta flagą `SHOW_GAME_ALERTS`. + +### 1.3 Gry cykliczne ukryte flagą +Wizja wymienia je w pierwszej propozycji wartości, na równi z grami pojedynczymi. +Kod jest kompletny (`lib/recurring.ts`, trasy `/cykliczne/*`, migracja `007`), ale +`SHOW_RECURRING = false` ukrywa wejścia w `Header.tsx`, `app/page.tsx`, `app/moje-gry`. + +Decyzja do podjęcia: odmrozić czy zapisać uzasadnienie ukrycia. + +### 1.4 Rozliczenie po meczu +Propozycja brzmi „Rozliczysz ekipę w minutę", a panel „Podział kosztów" renderuje się +pod warunkiem `isOwner && !eventStarted` (`EventDetailClient.tsx`). Czyli: +- po zakończeniu meczu panel znika — a wtedy właśnie się rozlicza, +- uczestnik nigdy nie widzi, ile ma zapłacić; widzi to tylko organizator. + +--- + +## 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 ⚠️ NASTĘPNY TEMAT + +**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** — część sprawdzana dziś po stronie przeglądarki. +- [ ] **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. + +### 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`. +- [ ] **Strona `/o-nas`** (E-E-A-T) — kim jesteśmy, dlaczego budujemy Bojo, kontakt. + Sygnał wiarygodności ważony przez Google i silniki generatywne. +- [x] **Dane własne jako treść** (GEO: statystyki podnoszą cytowalność ~40%) — sekcja + `LandingStats` na stronie głównej liczy boiska w bazie przy renderze + (`lib/landingStats.ts`, `getPublicVenueCount()`), zaokrąglone w dół do pełnych 50, + żeby liczba nigdy nie zawyżała stanu faktycznego. +- [ ] **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) — #1 z rewizji 2026-08 ⭐ +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 + + +### 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ą. + +- **Web-push (PWA)** — darmowy kanał przypomnień, zastępuje większość SMS-ów +- **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 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..95afa1cc --- /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 jest dziś najgęstszy w Poznaniu. 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..1abf7855 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,67 @@ +# 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) | + +## 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..0edb4655 --- /dev/null +++ b/docs/baza-danych.md @@ -0,0 +1,182 @@ +# Baza danych + +60 migracji (`001`–`060`) 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 | +| `event_participants` | `002` | Zapisy na mecz | +| `profiles` | `005` | Użytkownicy (+ flaga `is_admin`) | +| `recurring_events` | `007` | Szablony meczów cyklicznych | +| `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 | +| `event_comments` | `026` | Komentarze pod meczem | +| `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 | +| `group_members` | `044` | Członkowie ekip | +| `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 | + +**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 | + +--- + +## 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 | +| `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 (`SECURITY DEFINER`) | +| `accept_team_proposal` | Przenosi propozycję składów na realne drużyny (`SECURITY DEFINER`) | +| `haversine_km` | Odległość geograficzna | +| `trigger_set_updated_at`, `trigger_set_expires_at` | Triggery czasowe | + +--- + +## 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..08dd5555 --- /dev/null +++ b/docs/domena.md @@ -0,0 +1,234 @@ +# 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). + +**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, wydzielona z +`app/wydarzenia/nowe/page.tsx` pod testy); `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)). + +--- + +## 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'`. + +--- + +## 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. + +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ąć. + +--- + +## 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ść. + +--- + +## 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ę. + +**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`. + +--- + +## Grupy + +`lib/groups.ts`. Stała ekipa: sport, miasto, okładka, członkowie, mecze grupy. +Dołączanie przez link zaproszenia `/g/[code]`. + +**`events.group_id` steruje listowaniem, nie dostępem.** Przypisanie meczu do grupy +sprawia, że pojawia się on na liście meczów grupy — ale widoczność meczu nadal wynika +wyłącznie z `events.visibility` (`private` / `public`). Trzeciego poziomu widoczności +nie ma — to [luka wobec wizji](./wizja.md#3-luki). + +--- + +## 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. + +Reputacja: `reliabilityPct()` (`lib/eventFeatures.ts`) liczy frekwencję. Znaczek +„rzetelny gracz" wymaga ≥5 rozegranych gier i 0 nieobecności (`app/gracz/[id]/page.tsx`). + +Statystyki **pomijają** uczestników ze statusem `observing`. diff --git a/docs/funkcje.md b/docs/funkcje.md new file mode 100644 index 00000000..f6b6dce2 --- /dev/null +++ b/docs/funkcje.md @@ -0,0 +1,158 @@ +# 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 | `Header.tsx`, `app/page.tsx`, `app/moje-gry/page.tsx` | +| `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 `/cykliczne` odpowiada normalnie, jeśli ktoś +wpisze adres ręcznie — flaga 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 | +| Obecność | `track_attendance` | Frekwencja → reputacja gracza | +| Płatności | `track_payments`, `show_payment_status` | Podział kosztów, oznaczanie opłaconych | +| 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 | +| Kod dołączenia | `join_code` | Wejście przez `/d/[code]` | +| Potwierdzenie SMS | `require_sms_confirmation`, `confirmation_deadline_h` | **ukryte — `SHOW_SMS_FEATURES`** | + +--- + +## Zaproszenia na mecz + +Imienne zaproszenie (`event_player_invites`, migracja `060`, `lib/playerInvites.ts`) — +organizator albo dowolny potwierdzony uczestnik zaprasza konkretne osoby z ekipy +(`components/events/InviteFromGroupDialog.tsx`, przycisk „Zaproś z ekipy" 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ś ekipę" 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=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` — trzy +powyższe miejsca renderują ten sam kod, żeby nie rozjeżdżały się przy zmianie. + +Nie mylić z `lib/invites.ts` (tabela `event_invites`, migracja `036`) — zaproszenia po +e-mailu z tokenem, martwy kod, nic go nie importuje. + +--- + +## 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. + +--- + +## 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` | + +Czego brakuje: **wyzwalacza przy utworzeniu gry w grupie**. Jedyna ścieżka powiadomienia +o nowej grze to `game_alerts` (promień + sport), a ta jest ukryta flagą +`SHOW_GAME_ALERTS`. To [luka 2 wobec wizji](./wizja.md#3-luki). + +--- + +## 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ć". +- **Trzeci poziom widoczności meczu** („widoczne dla grupy"). `events.visibility` to + wyłącznie `private` / `public`. +- **Powiadomienie dla członków grupy o nowej grze.** +- **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.** + +### 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..c0422cdc --- /dev/null +++ b/docs/llm-context.md @@ -0,0 +1,483 @@ +# Bojo — kontekst dla modeli językowych + +> Bojo (bojo.pl) to aplikacja webowa do organizowania amatorskich meczów w całej Polsce +> (katalog boisk dziś najgęstszy w Poznaniu): 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-05 · migracja `061` · 30 tabel · 121 testów + +--- + +## 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 ~1400 boisk w Poznaniu 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 Poznaniu? 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 (~1400 obiektów) jest dziś najgęstszy w Poznaniu i rośnie o kolejne miasta. +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, dołączanie do +składu i zakładanie grup wymagają logowania. + +**Pytania, na które odpowiada ta sekcja:** W jakich miastach działa Bojo? Czy Bojo jest +dostępne poza Poznaniem? Ile boisk ma Bojo? Jakie sporty obsługuje Bojo? Czy trzeba mieć +konto, żeby przeglądać boiska? + +--- + +## 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, obecność, 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. + +**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? + +--- + +## 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, a historia wspólnych meczów nigdzie nie zostaje. + +**Rozwiązanie w Bojo.** Grupa to stała ekipa: sport, miasto, okładka, lista członków +i mecze grupy w jednym miejscu. Dołącza się przez link zaproszenia. + +**Mechanika.** Logika w `frontend/src/lib/groups.ts`, tabele `groups` i `group_members` +(migracja `044`). Link zaproszenia prowadzi pod `/g/[kod]`. Twórca grupy zostaje jej +członkiem automatycznie (trigger `add_group_creator_as_member`). + +**Przypisanie meczu do grupy steruje listowaniem, nie dostępem.** Kolumna +`events.group_id` sprawia, że mecz pojawia się na liście meczów grupy, ale jego +widoczność nadal wynika wyłącznie z `events.visibility`. Nie istnieje widoczność +„tylko dla mojej grupy". + +**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 dostają +powiadomienie o nowym meczu? + +--- + +## 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 ~1400 obiektów w Poznaniu 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 Poznaniu? 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. +- **Widoczność „tylko dla grupy".** `events.visibility` to wyłącznie `private`/`public`. +- **Powiadomienie dla członków grupy o nowym meczu grupy.** +- **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-06 — Strony boisk generowane na żądanie zamiast przy buildzie +PROBLEM: każda strona boiska w Bojo była generowana z góry, przy budowaniu +aplikacji — tyle stron, ile obiektów w katalogu. Dopóki katalog obejmował +Poznań, mieściło się to w kilku minutach. Po imporcie z OpenStreetMap urósł +do ~4600 obiektów i budowanie przestało się kończyć: ponad 40 minut bez +skutku, więc żadna zmiana nie docierała na produkcję. +ROZWIĄZANIE BOJO: strona boiska powstaje przy pierwszym wejściu i zostaje +w pamięci podręcznej na dobę. Czas budowania Bojo nie zależy już od wielkości +katalogu, co jest warunkiem dojścia do dziesiątek tysięcy obiektów z całej +Polski. Adresy stron i mapa witryny się nie zmieniły. +MECHANIKA: `generateStaticParams()` w `app/boisko/[id]/page.tsx` zwraca pustą +listę, `revalidate = 86400`; `resolveField()` rozwiązuje slug przez wspólny +indeks slug→id z TTL zamiast pobierać całą tabelę `fields` raz na render. + +### 2026-08-06 — Mapa otwiera się na Polsce, nie na Poznaniu +PROBLEM: mapa Bojo brała już obiekty z całego kraju, ale startowała z widokiem +ustawionym na Poznań przy dużym przybliżeniu. Po imporcie 1638 boisk +z lubelskiego użytkownik widział ten sam pusty Poznań i miał prawo sądzić, +że import nic nie dodał. Osobno: boiska opisane w OpenStreetMap jako +wielofunkcyjne (`sport=multi`) nie były na liście dyscyplin, które mapa +przepuszcza — w samym lubelskiem odpadały tak 162 obiekty. +ROZWIĄZANIE BOJO: mapa otwiera się na całej Polsce. Kto chce swoją okolicę, +klika przycisk pinezki w prawym dolnym rogu mapy — Bojo pyta wtedy przeglądarkę +o lokalizację i przeskakuje na nią. Pytanie o zgodę pada dopiero po kliknięciu, +bo mapa kraju działa i bez niej. Boiska wielofunkcyjne są widoczne na mapie, +ale nie mają własnego filtra dyscypliny: tag `multi` z OpenStreetMap nie mówi, +w co konkretnie da się tam zagrać. +MECHANIKA: `POLSKA` / `POLSKA_ZOOM` w `components/map/mapIcons.ts`, przycisk +lokalizacji i `locateMe()` w `components/map/VenueExplorer.tsx`, +`wielofunkcyjne` dopisane do `EXPLORER_SPORTS` w `lib/api.ts` i do +`SPORT_CONFIG` w `lib/sports.ts`. + +### 2026-08-05 — Mapa pokazuje boiska z całej Polski +PROBLEM: mapa Bojo miała zaszyty prostokąt wokół Poznania i pokazywała tylko to, +co się w nim mieściło. Obiekty zaimportowane z innych województw nie miały szans +się pojawić. Drugi filtr wymagał telefonu, strony albo opisu — świeży import +z OpenStreetMap nie ma żadnej z tych rzeczy, więc odsiewał je nawet w zasięgu. +ROZWIĄZANIE BOJO: mapa pokazuje obiekty z całego kraju. O tym, co jest widoczne, +decyduje wyłącznie `map_visibility = 'public'` — jedno kryterium zamiast dwóch +zachodzących na siebie. Import z OSM ustawia tę kolumnę świadomie, według bramki +jakości opartej na tagach, więc nie trzeba jej już podpierać filtrem zastępczym. +MECHANIKA: `getExplorerFields()` w `lib/api.ts` (usunięte `EXPLORER_BOUNDS` +i filtr „ma kontakt"), punkt zerowy przeplotu Mortona w +`components/map/VenueExplorer.tsx` przesunięty na 49° N / 14° E — przy starym +(52° N, 16,5° E) wszystko na południe od Poznania traciło porządek przestrzenny. + +### 2026-08-05 — Domyślny skład zależny od sportu +PROBLEM: kreator meczu w Bojo proponował 12 miejsc niezależnie od dyscypliny. +Przy siatkówce plażowej, gdzie gra się 2 na 2, organizator musiał osiem razy +kliknąć minus. Na mapie karta obiektu chowała się pod dolną nawigacją, więc +adresu nie dało się doczytać. +ROZWIĄZANIE BOJO: liczba miejsc podpowiada się z dyscypliny — piłka nożna 14 +(7v7), piłka ręczna 14, siatkówka 12 (6v6), futsal 10 (5v5), koszykówka 10 +(5v5), siatkówka plażowa 4 (2v2). Podpowiedź ustępuje, gdy organizator sam +ruszy licznik. Karuzela obiektów na mapie ma dopełnienie na wysokość paska +nawigacji. +MECHANIKA: `SPORT_PLAYERS` w `app/wydarzenia/nowe/page.tsx`, dolne dopełnienie +karuzeli w `components/map/VenueExplorer.tsx`. + +### 2026-08-05 — Uczestnik znów może zapraszać na mecz +PROBLEM: „Zaproś z ekipy" działało wyłącznie organizatorowi. Uczestnikowi Bojo +odpowiadało błędem o naruszeniu zabezpieczeń, mimo że sam zapis był dozwolony. +Do tego etykieta „już zaproszony" w oknie zapraszania kłamała uczestnikom, bo +nie widzieli cudzych zaproszeń — dało się wysłać drugie do tej samej osoby. +ROZWIĄZANIE BOJO: uczestnik meczu może zapraszać tak jak organizator, a lista +zaproszonych jest widoczna dla wszystkich, którzy na tym meczu grają. +MECHANIKA: migracja `061` rozszerza politykę SELECT na `event_player_invites` +o zapraszającego (`invited_by`) i uczestników meczu. Sedno: `invitePlayers()` +robi `INSERT … RETURNING id`, a Postgres stosuje do RETURNING politykę SELECT — +brak prawa odczytu przerywał całą operację błędem wyglądającym jak odrzucony +zapis. Sam warunek INSERT z migracji `060` był poprawny i został bez zmian. + +### 2026-08-05 — Kreator meczu bez rozproszeń, zaproszenia w Moich grach +PROBLEM: kreator meczu rozpraszał organizatora — numerki kroków 1/2/3 nic nie robiły, +przycisk „Dalej" na pierwszym kroku był zasłonięty przez dolny panel nawigacji +(„Gry Mapa Nowy Moje Grupy"), a „Dalej"/„Wróć" zajmowały dwie osobne linie. Ten sam +panel zasłaniał przyciski „Dołącz"/„Obserwuj" na stronie meczu i modal wypisania się. +Zaproszenia na mecz żyły wyłącznie na stronie głównej — bez znaczka nigdzie indziej, +łatwo je przegapić. +ROZWIĄZANIE BOJO: numerki kroków są teraz klikalne i walidują tak samo jak „Dalej" — +skok na krok, którego wymagania nie są spełnione, zatrzymuje się na pierwszym +brakującym polu. „Wróć" i „Dalej" siedzą w jednym przyklejonym pasku, w jednej linii. +Dolny panel chowa się w kreatorze przez cały czas i na stronie meczu, dopóki +użytkownik nie ma potwierdzonego miejsca ani oczekującej prośby — wraca po dołączeniu. +Zdublowany przycisk „+" na `/wydarzenia` (chowany za panelem) usunięty. Modale nad +panelem zamiast pod nim. Zaproszenia dostały zakładkę „Zaproszenia" na `/moje-gry` +(`?tab=zaproszenia`, przeżywa odświeżenie) i plakietkę z licznikiem obok pola +wyszukiwania na `/wydarzenia` — widoczną tylko, gdy jest co najmniej jedno zaproszenie. +MECHANIKA: `lib/eventWizard.ts` (walidacja kroków, wydzielona pod testy), +`lib/bottomNavVisibility.tsx` (`HideBottomNav`, kontekst z licznikiem), +`app/wydarzenia/nowe/page.tsx`, `app/wydarzenia/[id]/EventDetailClient.tsx` +(`joinBarVisible`), `lib/useMyInvites.ts`, `components/events/InviteList.tsx`, +`app/moje-gry/page.tsx`, `app/wydarzenia/EventsListClient.tsx`, +`components/layout/BottomNav.tsx` („Gry" → „Znajdź grę"). + +### 2026-08-04 — Strona meczu: mniej ozdób, więcej odpowiedzi +PROBLEM: strona meczu w Bojo otwierała się zdjęciem satelitarnym na pół ekranu, +które nic nie mówiło. Nie było widać, czy się w tym meczu gra ani czy się je +organizuje — trzeba było rozwinąć skład. Kto kliknął „Obserwuj", tracił przycisk +„Dołącz". Boisko pojawiało się dwa razy, a jego nazwa (zwykle „Boisko — piłka +nożna") mówiła mniej niż adres. Powrót ze strony boiska wyrzucał na mapę zamiast +do meczu, a miejsce wpisane ręcznie w ogóle nie dawało się kliknąć. +ROZWIĄZANIE BOJO: zdjęcie usunięte, na górze pasek z opisanymi akcjami +„Udostępnij" i „Kopiuj". Wśród plakietek widać teraz „Organizujesz", „Grasz" +(z rolą bramkarza) albo „Rezerwa · N." z pozycją w kolejce i „Obserwujesz". +Organizator zmienia termin i widoczność klikając plakietkę; zmiana terminu przy +zapisanych graczach wymaga potwierdzenia. Plakietka boiska pokazuje adres i +prowadzi na stronę obiektu, skąd strzałka wraca do meczu; miejsce spoza katalogu +otwiera okno z adresem i nawigacją. Wypisanie się ma teraz drugą opcję — +„Wypisz mnie, ale obserwuj" — oraz krzyżyk do zamknięcia. Dopisując osobę bez +konta wybiera się rolę (zawodnik z pola albo bramkarz) zamiast samego pola +wyboru „dodaj jako bramkarza". +MECHANIKA: `app/wydarzenia/[id]/EventDetailClient.tsx`, `setEventWhen()` +w `lib/events.ts` (osobna funkcja, żeby nie nadpisywać reszty pola jak +`updateEvent`), parametr `?wroc=` na trasie `/boisko/[id]` (tylko ścieżki +względne). + +### 2026-08-04 — Landing i dashboard: zwrot na organizatora, zasięg ogólnopolski +PROBLEM: Landing obiecywał „zbierz skład na mecz w dwie minuty" — nierealne dla +kilkunastoosobowej ekipy — i mówił wyłącznie o Poznaniu, mimo że tworzenie meczu +z pinezką na mapie działa już w całej Polsce. Górny pasek był zawsze biały i zjadał +pierwszy ekran telefonu, a „Zaloguj się" było dostępne wyłącznie w menu hamburgera +na mobile. Dashboard zalogowanego stawiał „Otwarte mecze" (dziś zwykle puste) nad +„Twoje grupy" (gdzie realnie dzieje się ruch) i nie miał sekcji „Jak to działa" ani FAQ. +ROZWIĄZANIE BOJO: H1 mówi o czynności, którą Bojo faktycznie dowozi w dwie minuty +(„Zorganizuj mecz"), a rotujący nagłówek nad H1 pokazuje trzy wartości organizatora +zamiast liczby boisk w jednym mieście. Górny pasek jest przezroczysty nad hero (z +widocznym od razu „Zaloguj się" na mobile) i staje się białym paskiem po zescrollowaniu +albo dla zalogowanych. Sticky CTA na mobile to teraz okrągły przycisk „+" zamiast paska +na całą szerokość. Pasek cookies pokazuje się dopiero po przewinięciu 300px albo po +6 sekundach, więc nie zjada pierwszego ekranu. Dolna nawigacja zalogowanego ma „Grupy" +zamiast „Profil" (profil jest już w menu hamburgera). Dashboard: „Twoje grupy" nad +„Otwarte mecze", a „Jak to działa" i FAQ pokazują się zawsze na dole, nie tylko przy +zerowej aktywności. +MECHANIKA: `components/home/landing/{content,LandingHero,LandingStats,LandingVenues, +StickyCta,RotatingBadge,PhoneMock}.tsx`, `components/layout/Header.tsx` (prop +`transparentOverHero`), `components/Logo.tsx` (wariant `onDark` dla `LogoPill`), +`lib/cookieConsent.ts` (hook `useCookieBannerVisible`, dzielony przez `CookieBanner.tsx` +i `StickyCta.tsx`), `components/layout/BottomNav.tsx`, +`components/home/{AppHome,dashboard/DashboardSections}.tsx`, +`components/layout/SiteFooter.tsx`. + +### 2026-08-04 — Dashboard zalogowanego przebudowany + koniec mignięcia landingu +PROBLEM: strona główna zalogowanego wciąż otwierała się marketingowym hero +(„Znajdź mecz. Albo stwórz własny.") — copy sprzedające produkt komuś, kto już +go używa. Do tego serwer nie wiedział, kto jest zalogowany, więc zalogowany +widział na moment landing dla gości, zanim JavaScript zdążył odczytać sesję +z `localStorage`. Wreszcie: dashboard robił 7–8 zapytań do Supabase na każde +wejście, z czego trzy sekcje budowały własną, osobną kopię mapy uczestnictwa, +a mecz z imiennym zaproszeniem do grupy pokazywał się dwa razy (w „Zaproszenia" +i w „Mecze Twoich ekip"). +ROZWIĄZANIE BOJO: dashboard zaczyna się kompaktowym powitaniem i kartą +najbliższego meczu (co i kiedy gram), nie hero. Serwer renderuje od razu +szkielet dashboardu dla zalogowanego dzięki ciasteczku-wskazówce (bez tokenu — +serwer wciąż nie ma prawdziwej sesji, tylko podpowiedź, którą skorupę +wyrenderować). Status uczestnictwa `invited` (zarezerwowany w kodzie, ale +wcześniej niczego nie produkujący) zaczął być realnie zwracany, co usunęło +duplikat zaproszenia. Na mobile aktywowana dolna nawigacja (wcześniej gotowa, +ale nieużywana) dla zalogowanych. +MECHANIKA: `lib/sessionHint.ts` (ciasteczko `bojo_sess`, bez tokenu), +synchronizacja w `lib/auth.tsx`, `AppHomeSkeleton.tsx`, `app/page.tsx` (odczyt +`cookies()`). `lib/useDashboardData.ts` — jedno wywołanie `Promise.allSettled` +zamiast pięciu niezależnych efektów. `lib/myEvents.ts` (`splitMyEvents`, +`nextMatch` — sortuje samodzielnie, bo `getMyParticipatedEvents()` zwraca dane +malejąco). `lib/eventDates.ts` (`matchWhenLabel` i przeniesione stąd +`isUpcoming`/`isEventJoinable`/`timeUntil`). `getMyParticipationMap()` +w `lib/events.ts` dociąga `event_player_invites` → status `invited`. +`components/home/dashboard/{GreetingBar,NextMatchCard,DashboardSections}.tsx`, +`components/layout/{BottomNav,BottomNavGate}.tsx`. + +### 2026-08-04 — Krótszy kreator meczu +PROBLEM: kreator meczu w Bojo wymagał zbyt wielu decyzji: pusta data, ręczna godzina +zakończenia, sekcja „Ustawienia zaawansowane" z przełącznikami, których każdy i tak +chciał używać, a przycisk „Dalej" był poza pierwszym ekranem telefonu. +ROZWIĄZANIE BOJO: sport wybiera się z jednej przewijanej linii (plus mały dropdown), +data domyślnie jutro, zamiast godziny zakończenia jest „Czas gry" (domyślnie 90 min, +koniec liczy się sam), liczba miejsc domyślnie 12 dla piłki nożnej z informacją, +że kolejni trafią na rezerwę. Widoczność domyślnie publiczna. Sekcja zaawansowana +zniknęła: obecność jest zawsze włączona, a mecz z ustawioną kwotą sam włącza +śledzenie płatności i pokazuje graczom status wpłat. Organizator zapisujący się +na własny mecz wybiera przy tym rolę: bramkarz lub zawodnik z pola. +MECHANIKA: `app/wydarzenia/nowe/page.tsx`, parametr `organizerIsGoalkeeper` +w `createEvent()` (`lib/events.ts`); wiersz organizatora dostaje status +„potwierdzony" — zapis znaczy „będę", organizator oznacza potem nieobecnych. + 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/rewizja-2026-08.md b/docs/rewizja-2026-08.md new file mode 100644 index 00000000..93c6568d --- /dev/null +++ b/docs/rewizja-2026-08.md @@ -0,0 +1,330 @@ +# Rewizja przed startem — 2026-08 + +> Wynik przebiegu promptu z [prompt-rewizja.md](./prompt-rewizja.md) (model: Fable). +> Cztery kroki: ludzie → zarzut → pre-mortem → decyzje. Wnioski są sądem modelu, +> nie ustaleniem zespołu — służą do konfrontacji, nie do automatycznego wykonania. + +--- + +## KROK 1: Ośmiu ludzi + +**Marcin, 34, kierownik zmiany w Volkswagenie.** Środy 19:00, hala w Luboniu opłacona +na cały sezon, czat na Messengerze od sześciu lat, 14 osób w obiegu. Organizacja to +jeden post „kto gra?" i do czwartku jest komplet. Nic go nie wkurza — system działa. +Dostaje link do Bojo od kolegi. Otwiera stronę meczu, widzi listę z limitem — „ładne". +Widzi, że musi założyć konto, żeby cokolwiek kliknąć, i że musiałby przenieść 14 osób, +z których połowa ledwo ogarnia Messengera. **Odpada w minucie 2** — nie na czymś, co +Bojo robi źle, tylko na rachunku: koszt przeniesienia działającej ekipy jest większy +niż zero, a jego problem ma wartość zero. + +**Tomek, 26, programista, trzy miesiące w Poznaniu.** Przeniósł się za pracą, w Wielkopolsce +nie zna nikogo. Grał w Krakowie co tydzień. To jest podręcznikowy idealny użytkownik: +paląca potrzeba, zero alternatyw. Wchodzi z Google na `/wydarzenia`, bo szuka „gdzie grać +w piłkę Poznań". Lista otwartych meczów jest **pusta albo prawie pusta** — bo użytkowników +jeszcze nie ma, a sekcja `LandingOpenGames` filtruje `taken < maxPlayers` i przy braku +gier zwraca `null`. **Odpada w minucie 1** z wnioskiem „martwa aplikacja". Nie zostawia +po sobie śladu — nie ma nawet jak powiedzieć „daj znać, jak coś będzie", bo alerty są +za flagą `SHOW_GAME_ALERTS = false`. + +**Aga, 31, księgowa.** Gra w siatkówkę, kiedy koleżanka ją wyciągnie — raz w miesiącu, +może rzadziej. Dostaje link `/d/[kod]` na Messengerze. Otwiera, widzi mecz, klika +„Dołącz" — trzeba się zalogować. Zamyka i odpisuje koleżance „będę" na czacie. +Koleżanka dopisuje ją jako gościa (`allow_guest_adds`). **Aga nigdy nie odpada, +bo nigdy nie wchodzi** — jest obsługiwana przez cudze konto. Z punktu widzenia +organizatorki wszystko działa. Z punktu widzenia Bojo Aga nie istnieje: jest wierszem +`is_guest = true` bez `user_id`. Funkcja gości — słusznie wygodna — **kanibalizuje +rejestrację** dokładnie tej grupy, która miała być siecią. + +**Pan Krzysztof, 55, kierownik prywatnej hali przy Głogowskiej.** Ktoś mu wysyła link +do strony jego obiektu w Bojo. Otwiera: nawierzchnia z analizy satelitarnej się nie +zgadza, w sportach jest tenis, którego nie ma, a jako kontakt widnieje — albo nic +(kontakty domyślnie ukryte, migracja `033`), albo dane innego ośrodka (audyt 5.0: +`osrodekrataje@posir.poznan.pl` na obiekcie w Dopiewie). **Odpada w minucie 1** i to +odpadnięcie jest najdroższe ze wszystkich, bo wizja zakłada rozmowę z obiektami +„z pozycji siły" — a pierwsze wrażenie pana Krzysztofa to „amatorzy, mają złe dane +o moim własnym obiekcie". Rezerwacje, które mogłyby go zainteresować, są za flagą. + +**Paweł, 29, handlowiec.** Ma ekipę na orlika, 8–10 osób, ale co tydzień brakuje dwóch +i co trzeci tydzień mecz wisi na włosku. To jest drugi podręcznikowy użytkownik — jego +ból jest dokładnie w pierwszym zdaniu wizji. Pierwsze pięć minut przechodzi wzorowo: +zakłada konto, tworzy mecz, upublicznia. **Odpada w tygodniu drugim**, gdy dwa publiczne +mecze z rzędu nie przyciągnęły ani jednej osoby z zewnątrz — bo nie ma skąd. Wraca do +grupy „Poznań — gramy w piłkę" na Facebooku, gdzie post daje trzech chętnych w godzinę, +bo tam SĄ ludzie. + +**Kuba, 20, student AWF.** Koszykówka na Chrobrego: przychodzi się na boisko i gra z tymi, +którzy są. Nie ma organizatora, terminu ani limitu miejsc. Model danych Bojo — mecz +o konkretnej godzinie z twardym limitem (`events`, `max_players`) — **nie opisuje jego +rzeczywistości w ogóle**. Jedyna wartość dla niego to mapa: gdzie są kosze z oświetleniem. +Otwiera `/mapa`, filtruje „koszykówka", dostaje wyniki, wśród których są korty tenisowe +(audyt 5.0, pkt 1: sport skażony sąsiedztwem). **Odpada w minucie 3** — narzędzie, które +myli kosz z kortem, nie jest lepsze od Google Maps. + +**Monika, 27, projektantka.** Latem organizuje siatkówkę plażową na Rusałce przez grupę +na Facebooku: 30–40 osób w obiegu, zapisy w komentarzach „1. Monika, 2. …", które są +koszmarem do ogarnięcia. Jej ból jest realny i Bojo rozwiązuje go wprost. Pierwsze pięć +minut: zachwyt, tworzy mecz, wrzuca link na grupę. I tu pętla się zacina: **jej ludzie +to trzydzieści Ag** — połowa nie zakłada konta i zapisuje się po staremu w komentarzach. +Monika przez dwa tygodnie prowadzi dwie listy równolegle, po czym wraca do jednej — +tej na Facebooku. **Odpada nie na swoim koszcie, tylko na koszcie swoich zaproszonych.** + +**Robert, 43, inżynier.** Niedzielne granie „open" na orliku, zimą hala, którą opłaca +z góry i potem zbiera po 20 zł, co jest jego jedynym realnym bólem. „Rozliczysz ekipę +w minutę" z wizji to zdanie napisane dla niego. Zakłada konto, tworzy mecz z podziałem +kosztów. Po meczu wchodzi rozliczyć — **panel „Podział kosztów" zniknął**, bo renderuje +się pod warunkiem `isOwner && !eventStarted` (luka 1.4), a uczestnicy i tak nigdy nie +widzieli, ile mają zapłacić. **Odpada w dniu pierwszego rozliczenia** — funkcja, dla +której przyszedł, nie działa w jedynym momencie, w którym jest potrzebna. + +### Powtarzające się odpadnięcia + +1. **Pusta półka publiczna** — Tomek i Paweł, czyli **obaj** użytkownicy z najsilniejszą + potrzebą. Jeden nie widzi meczów, drugi nie widzi graczy. To jest to samo odpadnięcie + z dwóch stron tego samego pustego pokoju. +2. **Koszt konta leży po stronie zapraszanych, nie organizatora** — Aga i ludzie Moniki. + Produkt wymaga rejestracji od osób z najmniejszą motywacją, a jednocześnie daje + organizatorowi obejście (goście), które sprawia, że ta rejestracja nigdy nie następuje. +3. **Złe dane katalogu niszczą zaufanie w pierwszej minucie** — Krzysztof i Kuba. + Katalog boisk to jedyna wartość Bojo dostępna bez sieci użytkowników, i właśnie ona + jest dziś niewiarygodna. + +--- + +## KROK 2: Najmocniejszy zarzut + +Centralne założenie wizji brzmi: *„najpierw skojarzenie z organizacją gry, dopiero potem, +z pozycji siły i z realną liczbą zaangażowanych userów za sobą, rozmowa z obiektami"* — +czyli: narzędzie organizacyjne skumuluje użytkowników, a ich masa stanie się dźwignią. + +**Teza: narzędzie organizacyjne nie kumuluje użytkowników, bo mecz amatorski jest +strukturalnie prywatny — i Bojo samo to potwierdza każdą swoją funkcją.** + +Rozwinięcie w trzech ruchach: + +**Po pierwsze, adopcję kontroluje organizator, a organizatorzy dzielą się na tych bez +problemu i tych z problemem nierozwiązywalnym.** Marcin (ekipa działa) nie ma po co +przychodzić — jego koszt zmiany jest dodatni, zysk zerowy. Paweł i Monika (dziurawe +składy) mają dokładnie ten problem, który obiecuje rozwiązać wizja — ale jego rozwiązaniem +jest **sieć graczy, nie funkcja**. Bojo może im dać najlepszą listę zapisów świata, +a oni i tak odejdą, gdy publiczny mecz nie przyciągnie nikogo, bo przyciągać nie ma kogo. + +**Po drugie, pętla wzrostu, która miałaby tę sieć zbudować, jest przerwana przez własny +produkt.** Jedyny naturalny strumień nowych ludzi to zaproszeni na mecze znajomych — +trzydzieści Ag Moniki co tydzień. I dokładnie ten strumień Bojo wypuszcza bokiem: +`allow_guest_adds` pozwala obsłużyć zaproszonego bez konta, więc zaproszony kontem +nie kończy. To nie jest bug, to dobrze zaprojektowana wygoda — która sprawia, że +**każda ekipa w Bojo jest wyspą**. Wyspy nie tworzą sieci. Landing dokłada swoje: +sekcja otwartych gier pokazuje wyłącznie mecze z wolnymi miejscami, czyli świeci +pustką tym mocniej, im lepiej produkt działa. + +**Po trzecie, analogia ze Stravą — jawnie przywołana w wizji — jest odwrócona.** +Strava działa, bo bieganie jest aktywnością **pojedynczą**: każdy trening każdego +biegacza z osobna staje się publicznym wpisem i buduje graf, nawet gdy biegacz nie zna +nikogo. Mecz drużynowy jest odwrotnością: wymaga dziesięciu skoordynowanych osób, +z których dziewięć przyszło, bo zna dziesiątą. Publiczny mecz z obcymi — fundament +„znajdź grę w 2 minuty" — to w polskim sporcie amatorskim wyjątek, nie norma. Wizja +buduje platformę wokół wyjątku i traktuje normę (zamknięta paczka) jako źródło ruchu, +którym ta norma nie chce być. + +Wniosek z tezy: „pozycja siły wobec obiektów" nigdy nie nadejdzie z tej pętli, bo pętla +produkuje wyspy, a nie masę. Jeśli teza jest prawdziwa, to nie znaczy, że Bojo nie ma +sensu — znaczy, że jego jedyną realną walutą na start są **konwersja gościa w gracza** +(bo tylko tam jest naturalny ruch) i **wiarygodny katalog boisk** (bo tylko on ma wartość +przy zerowej sieci). Obie rzeczy są dziś odpowiednio: niezbudowana i zepsuta. + +Po napisaniu nie uważam tej tezy za słabą. Jej najsłabszym punktem jest założenie, że +publicznych graczy „nie ma" — grupy FB typu „Poznań — gramy w piłkę" dowodzą, że jacyś +są. Ale to nie ratuje wizji: ci ludzie już mają swoje miejsce i Bojo musi ich stamtąd +**wyjąć**, a nie „skumulować" z powietrza. + +--- + +## KROK 3: Pre-mortem — jak zgasło Bojo + +**Q3 2026.** Start publiczny. Pierwsza fala: znajomi i znajomi znajomych, ~40 kont. +Dwie Moniki próbują przenieść swoje ekipy; ich zaproszeni w większości lądują jako +goście bez kont (odpadnięcie 2 z kroku 1). Tygodniowa aktywność stabilizuje się na +kilkunastu osobach — czyli na tych, którzy przyszliby i bez aplikacji. Paweł wystawia +dwa publiczne mecze, zero dołączeń z zewnątrz (odpadnięcie 1), wraca na Facebooka. +Założyciele — obaj po godzinach — interpretują płaską krzywą jako brak funkcji, nie +brak pętli: odmrażają gry cykliczne, budują powiadomienia grup. Metryki nikt nie +wybiera; `analytics_events` zbiera zdarzenia, na które nikt nie patrzy. + +**Q4 2026.** Zima zamyka orliki i plażówkę — a plażówka była głównym żywym przypadkiem +(backlog §6 mówi to wprost). Aktywność spada o połowę z przyczyn czysto sezonowych, +ale na wykresie wygląda to jak śmierć produktu, co podcina morale mocniej niż +jakakolwiek awaria. W listopadzie ręcznie wklejana migracja (jedno środowisko, brak +możliwości sprawdzenia stanu bazy z repo — backlog 5.0) rozjeżdża produkcję na pół +dnia. **Nikt tego nie zgłasza** — i to jest gorsza wiadomość niż sama awaria, bo mówi, +ilu ludzi realnie patrzy. + +**Q1 2027.** Motywacja szuka ujścia w robocie, która daje postęp bez konfrontacji +z płaską krzywą: czyszczenie katalogu boisk pod „całą Polskę" (backlog 5.0), refaktory, +dokumentacja. Wizja sama daje na to przyzwolenie — „czysta zajawka" jest w niej celem +równoległym do finansowego. Budowanie zastępuje sprzedawanie, bo budowanie jest +przyjemne, a rozmowa z dwudziestą ekipą, która nie chce się przenieść z Messengera — +nie. Ekspansja katalogu na kraj, w którym nie ma ani jednego aktywnego miasta, pochłania +kwartał. + +**Q2 2027.** Jan dostaje projekt, który zjada mu wieczory. Franek zostaje sam z aplikacją, +którą realnie żywi jedna ekipa — jego własna. W maju ktoś z tej ekipy nie może się +zapisać, bo nie chce zakładać konta; organizator (Franek) dopisuje go ręcznie jako +gościa i w tym momencie ostatnia różnica między Bojo a arkuszem w Messengerze znika +z pola widzenia jego własnych znajomych. + +**Sierpień 2027.** Przy odnowieniu domeny pada pytanie „po co" i nie pada odpowiedź. + +**Mechanizm w jednym zdaniu:** pętla wzrostu wymagała konwersji zaproszony → użytkownik, +produkt tę konwersję celowo omijał (goście), więc każda ekipa pozostała wyspą; wyspy +nie zasiliły publicznej półki, pusta półka odrzuciła jedyny segment z palącą potrzebą +(nowi w mieście), a brak jakiegokolwiek ruchu odesłał założycieli w komfort budowania +rzeczy, których nikt nie zobaczył. + +**Jeden moment, w którym inna decyzja odwraca bieg:** wrzesień 2026, pierwsza Monika. +Zamiast odmrażać funkcje — zbudować **przejmowanie profilu gościa**: każdy gość dopisany +do meczu dostaje po meczu link „Twoje mecze i statystyki czekają — przejmij profil". +Infrastruktura już istnieje (`added_by`, `player_stats`, historia meczów, znaczek +rzetelnego gracza). Trzydzieści Ag Moniki co tydzień przestaje być stratą, a staje się +lejkiem — jedynym, jaki ten produkt naturalnie ma. + +--- + +## KROK 4: Co budować, czego nie + +### (a) Pięć rzeczy do zbudowania + +1. **Przejmowanie profilu gościa (claim).** Gość z `added_by` dostaje po meczu link, + zakłada konto, dziedziczy historię i statystyki. Rozbraja główny mechanizm pre-mortem + (przerwana konwersja zaproszony → użytkownik); usuwa odpadnięcie Agi i ludzi Moniki. + Wykorzystuje zbudowane: `event_participants.added_by`, `get_player_stats`, profil + `/gracz/[id]`. + +2. **Powiadomienie członków grupy o nowym meczu (backlog §1.2).** Jedyna rzecz, której + brakuje, żeby grupa faktycznie zastępowała czat — kanał (`notifications`, Resend) + istnieje, brakuje wyzwalacza przy `createEvent` z `group_id`. Rozbraja Q3 pre-mortem + (ekipy nie widzą wartości ponad Messengera); domyka to, po co przyszłaby ekipa Marcina. + +3. **Rozliczenie po meczu + widok „ile płacę" dla uczestnika (backlog §1.4).** Zdjęcie + warunku `!eventStarted` z panelu kosztów i sekcja dla uczestnika licząca przez + `priceForParticipant()`. Usuwa odpadnięcie Roberta; zamienia fałszywą dziś obietnicę + „Rozliczysz ekipę w minutę" w prawdziwą. + +4. **Naprawa danych boisk w Poznaniu — bez „całej Polski"** (backlog 5.0, punkty 1–5: + sporty skażone sąsiedztwem, fałszywe kontakty, zepsute adresy, ukrycie nie-boisk, + bramka publikacji). Usuwa odpadnięcia Krzysztofa i Kuby. Katalog to jedyna wartość + dostępna przy zerowej sieci — musi być wiarygodny, zanim będzie duży. + +5. **Półka publiczna, która nie umie być pusta.** Lista i landing pokazują także mecze + pełne (oznaczone „komplet") i rozegrane z wynikami — dowód życia zamiast pustki — + a przy braku meczów w okolicy jedyną akcją jest odmrożony alert `SHOW_GAME_ALERTS` + („daj znać, jak coś będzie"). Usuwa odpadnięcie Tomka w minucie 1; rozbraja + „pustą półkę" z Q3. + +### (b) Do skasowania z BACKLOG.md + +Pozycje cytowane dosłownie; jedno zdanie przy każdej. + +1. **„1.1 Trzeci poziom widoczności meczu — «widoczne dla grupy»"** — sekcja „Mecze + Twoich ekip" (2026-08-03) już pokazuje członkom prywatne mecze grupy, co konsumuje + 90% tej potrzeby bez migracji i nowej polityki RLS. +2. **„1.3 Gry cykliczne ukryte flagą"** — „Powtórz mecz (skopiuj)" pokrywa realny + przypadek użycia, a ekipy grające co tydzień to segment Marcina, który nie przyjdzie. +3. **„SHOW_CUP | Turniej BOJO Cup"** (§2) — turniej dla społeczności, której nie ma, + to wystawa pustej sali; wraca, gdy będzie kogo turniejować. +4. **„SHOW_SMS_FEATURES | Potwierdzenie SMS + przypomnienia"** (§2) — SMS kosztuje, + nikt o niego nie prosił, a przypomnienia załatwi kiedyś web-push. +5. **„FEATURE_RESERVATIONS | Rezerwacje obiektów, panel menedżera"** (§2) — audyt + wykazał 56 maili na 1484 obiekty, częściowo fałszywych; nie ma drugiej strony tego + marketplace'u i długo nie będzie. +6. **„`components/home/NearbyGames.tsx`"** (§4) — nierenderowany komponent; jeśli + punkt (a)5 go potrzebuje, taniej napisać od nowa niż utrzymywać. +7. **„`components/map/{MapView,LeafletMapImpl,EventsMapView,EventsMapImpl}.tsx`"** + (§4) — cztery pliki martwego kodu z przestarzałą logiką flag. +8. **„Tabela `games`"** (§4) — martwa od migracji `002`; DROP w najbliższej migracji. +9. **„`/gracze` — trasa istnieje, ale to redirect"** (§4) — usunąć trasę; lista graczy + przy 40 kontach to lista wstydu. +10. **„Zod — walidacja danych z bazy"** (§5) — polerowanie warstwy typów przed + pierwszym prawdziwym użytkownikiem nie rozbraja żadnej przyczyny z pre-mortem. +11. **„Sesja w cookie zamiast localStorage"** (§5) — mignięcie landingu to defekt + kosmetyczny; wraca, gdy będzie komu mignąć. +12. **„Katalog boisk — … potem cała Polska"** (5.0, część „cała Polska") — ekspansja + katalogu na kraj bez jednego aktywnego miasta to Q1 2027 z pre-mortem; naprawa + danych Poznania zostaje (to punkt (a)4). +13. **„Wizualizacja drabinki na mobile"** (§6) — turniej idzie do kasacji w całości. +14. **„Powiadomienia: wylosowano drabinkę / kiedy następny mecz"** (§6) — jak wyżej. +15. **„Płatność za drużynę (re-użyć trackPayments)"** (§6) — jak wyżej. +16. **„Lista wielu turniejów"** (§6) — jak wyżej. +17. **„Obecność zewnętrzna / backlinki"** (§7) — to nie jest zadanie w backlogu, tylko + styl życia; jako pozycja do odhaczenia jest fikcją. +18. **„Core Web Vitals"** (§7) — sami piszecie „zmierzyć po wdrożeniu"; pomiar bez + użytkowników niczego nie zmieni. +19. **„Zamykanie zapisów po komplecie"** (§8) — migracja `058` (oferta miejsca dla + rezerwy z oknem czasowym) skonsumowała problem, który ta pozycja miała rozwiązać: + zwolnione miejsce jest już trzymane dla rezerwowego, nikt go nie podbierze. +20. **„Propozycje składów przez graczy"** (§8) — **już zbudowane** (migracja `059`, + `TeamProposals`); pozycja jest martwym wpisem, backlog nie nadążył za kodem. +21. **„Web-push (PWA)"** (§8) — kanał powrotu dla użytkowników, których nie ma; + wraca razem z pierwszą kohortą, którą będzie po co przypominać. +22. **„Onboarding / pierwsza gra"** (§8) — skonsumowane przez punkt (a)5; jako osobna + pozycja to duplikat. +23. **„Rankingi publiczne i odznaki"** (§8) — ranking trzech osób ośmiesza produkt; + wizja sama mówi „później". +24. **„Ocena umiejętności i dopasowywanie gier do poziomu"** (§8) — wymaga gęstości + meczów, od której dzielą Was dwa lata. +25. **„MVP meczu"** (§8) — kosmetyka statystyk przy braku grających. +26. **„Realny przepływ pieniędzy (BLIK/Stripe)"** (§8) — regulacyjnie i integracyjnie + najdroższa pozycja listy, rozwiązująca problem, którego rejestrowanie „kto zapłacił" + jeszcze nie wyczerpało. +27. **„Wynajem sędziego"** (§8) — marketplace w marketplace, zanim zadziałał pierwszy. +28. **„Wyszukiwarka boisk po nazwie/dzielnicy"** (§8) — drobiazg, który nie rozbraja + żadnej przyczyny; filtry na `/mapa` wystarczą do czasu realnego ruchu. +29. **„Statystyki sezonowe dla stałych ekip"** (§8) — nadbudówka nad statystykami, + których nikt jeszcze nie ogląda. +30. **„Agent kontaktowy — automat wysyłający maile do obiektów"** (§8) — automatyzacja + kontaktu z obiektami przy 56 mailach w bazie, w większości niepewnych, to skalowanie + błędu; najpierw dane ((a)4), potem ręczny kontakt, automat na końcu. + +Zostają poza piątką: weryfikacja stanu migracji na produkcji, adresy `bojo.app`, +domknięcie RLS, build w CI, `/o-nas` — tanie, higieniczne, bez ambicji. + +### (c) Ukryte za flagą — do usunięcia z kodu całkiem + +- **Turniej** — `lib/tournaments.ts` (455 linii), 6 tabel `tournament_*`, trasy + `/turniej/*`, flaga `SHOW_CUP`. Największy pojedynczy kawał utrzymywanego kodu + bez ani jednego użycia; git pamięta, gdyby wrócił. +- **Rezerwacje obiektów** — `lib/bookings.ts`, trasy `/obiekt/*` i `/rezerwacje`, + tabele `venue_schedules` / `venue_pricing` / `bookings`, flaga `FEATURE_RESERVATIONS` + z furtką `booking_enabled`. Zbudowane na relację z obiektami, której nie ma; audyt + danych kontaktowych pokazał, że długo nie będzie. +- **SMS** — edge function `send-event-sms`, kolumny `require_sms_confirmation` / + `confirmation_deadline_h` w formularzu, flaga `SHOW_SMS_FEATURES`. Płatny kanał + o zerowym popycie. +- **Gry cykliczne** — `lib/recurring.ts`, trasy `/cykliczne/*`, tabele z migracji `007`, + flaga `SHOW_RECURRING`. „Powtórz mecz" pokrywa przypadek użycia bez utrzymywania + drugiego modelu wydarzeń. +- **Martwe mapy i `NearbyGames`** — pięć plików z §4 backlogu plus DROP tabeli `games`. + +**Zostaje jako flaga: `SHOW_GAME_ALERTS`** — jedyna, której powód ukrycia backlog sam +oznacza jako nieaktualny, i jedyna potrzebna w piątce (a). + +--- + +## Czego nie wiem + +1. **Jaki procent uczestników meczów to dziś goście bez kont?** Jedno zapytanie: + `SELECT count(*) FILTER (WHERE is_guest), count(*) FROM event_participants`. + Jeśli goście to ponad ~30%, teza z kroku 2 jest potwierdzona twardo i claim ((a)1) + jest bezdyskusyjnie pierwszy. Jeśli goście to margines — mechanizm pre-mortem + wymaga rewizji. + +2. **Czy w Poznaniu istnieje żywy popyt na granie z obcymi?** Sprawdzalne w tydzień, + bez kodu: liczba i świeżość postów „ktoś chętny? / szukamy 2" w poznańskich grupach + FB o graniu. Jeśli takich postów są dziesiątki tygodniowo — „pusta półka" jest + problemem podaży w Bojo, nie braku popytu, i punkt (a)5 ma sens. Jeśli ich prawie + nie ma — publiczne mecze trzeba zdegradować z fundamentu do dodatku, a to zmienia + wizję głębiej niż cokolwiek w tym dokumencie. + +3. **Jaki jest realny tygodniowy ruch dziś, przed startem?** `analytics_events` + (migracja `047`) już to zbiera — ilu unikalnych użytkowników tygodniowo robi + cokolwiek poza założycielami. Od tej liczby zależy, czy „start publiczny" to + launch, czy kolejna prywatna iteracja — i czy Q3 z pre-mortem już trwa. diff --git a/docs/strategia.md b/docs/strategia.md new file mode 100644 index 00000000..956f3248 --- /dev/null +++ b/docs/strategia.md @@ -0,0 +1,219 @@ +# Bojo — strategia operacyjna i plan działania + +> Warstwa **operacyjna**: infrastruktura, koszty, podział ról, roadmapa fazowa. +> Kierunek produktu i propozycje wartości → [wizja.md](./wizja.md) (dokument nadrzędny). +> +> Scalony z `STRATEGIA.md` (stan na 2026-06-05), dane zweryfikowane wobec kodu 2026-08-02. + +--- + +## 0. Obecny priorytet - KRÓTKI TERMIN (1–3 msc) +Strategia. Skupiamy się na pozyskiwaniu organizatorów i szlifowaniu przepływu +organizacji gry. Baza graczy zbuduje się sama, bo żeby dołączyć do meczu, trzeba założyć konto - organizator przyprowadza 10–14 osób. +To klasyczne rozwiązanie problemu jajka i kury zwane single-player mode: narzędzie daje wartość jednej stronie rynku, zanim druga w ogóle istnieje. +Kanoniczny przykład to OpenTable, które zaczęło jako darmowy system rezerwacji dla restauracji, użyteczny w dniu zero, bez ani jednego gościa online. +Bojo.pl ma więc mówić językiem organizatora, a nie targowiska. + +## 1. Gdzie jesteśmy + +**Produkt działa i jest wdrożony.** Aplikacja webowa (Next.js 14 + Supabase, hosting +Vercel), spójny design, większość funkcji core gotowa: + +- Mapa boisk w Poznaniu (~1400 obiektów z OSM + wzbogacone AI: telefon, www, godziny, + sposób rezerwacji) +- Organizacja meczów: tworzenie gry, zapisy, lista rezerwowa, goście bez konta +- Zaawansowane: drużyny (drag&drop, losowanie, kapitanowie), wyniki meczów, statystyki, + płatności +- Logowanie Google + e-mail (hasło / magic link / reset) +- Komentarze, profile publiczne graczy z reputacją, powtarzanie meczu, podział kosztów +- Panel admina: kontakt z obiektami (CRM), zarządzanie użytkownikami, analityka + +**Zbudowane, ale niewidoczne dla użytkownika** (za flagami — patrz +[funkcje.md](./funkcje.md#flagi-funkcji)): gry cykliczne, alerty o grach w okolicy, +potwierdzenia i przypomnienia SMS, turniej, rezerwacje obiektów. + +**Infrastruktura:** +- Frontend: Vercel | Baza + Auth: Supabase | E-mail: Resend | SMS: SMSAPI.pl (główny) + + Twilio (zapasowy) +- **57 migracji** bazy, **11 workflowów** GitHub Actions + +**Jedno wąskie gardło infrastrukturalne:** jest tylko **jedno środowisko** (prod). Każda +zmiana idzie od razu na żywo. To pierwsza rzecz do naprawienia przy „realnym starcie". + +--- + +## 2. SMS — bramka i optymalizacja kosztów + +### Stan +**SMSAPI.pl** = główny dostawca, **Twilio** = fallback. Limit 30 SMS/h na organizatora. +Funkcje SMS są dziś ukryte flagą `SHOW_SMS_FEATURES`. + +### Analiza kosztów (orientacyjnie, PL) +| Dostawca | Koszt / SMS | Uwagi | +|----------|-------------|-------| +| **SMSAPI.pl** (standard, z nazwą nadawcy „Bojo") | ~0,06–0,09 zł | pakiety obniżają cenę; nazwa nadawcy wymaga rejestracji (darmowa) | +| **SMSAPI.pl ECO** (bez nazwy nadawcy) | ~0,04 zł | tańsze, ale wolniejsze i bez brandingu | +| **Twilio** (PL) | ~0,16–0,30 zł | **3-5× drożej**, wymaga rejestracji Alphanumeric Sender ID, rozliczenie w USD | + +### Rekomendacja +1. **Zostać przy SMSAPI jako głównym** — dla Polski jest bezkonkurencyjny cenowo. Twilio + rozważyć do **usunięcia** (komplikuje kod i sekrety, a jest 3-5× droższy). Ewentualnie + zostawić wyłączony jako „break glass". +2. **Zarejestrować nazwę nadawcy „Bojo"** w SMSAPI (darmowe) — SMS z „Bojo" zamiast + losowego numeru wygląda profesjonalnie i buduje markę. +3. **SMS to najdroższy kanał — minimalizować jego użycie.** Przesuwać komunikację na + kanały darmowe: + - **Link sharing (WhatsApp/Messenger)** — 0 zł, już działa, to główny kanał zapraszania + - **Powiadomienia in-app** — 0 zł, już działa (tabela `notifications`, `NotificationBell`) + - **E-mail (Resend)** — ~0 zł, idealny do przypomnień + - **Push notifications (PWA)** — 0 zł, do zbudowania (Faza 1) — **przyszły główny + darmowy kanał przypomnień** + - **SMS rezerwować TYLKO na:** potwierdzenia obecności od osób bez konta oraz pilne + „gramy dziś". To realnie tnie koszt SMS o ~80%. + +**Wniosek:** przy 100 aktywnych grach/mies. i SMS tylko na potwierdzenia → kilka-kilkanaście +zł/mies. Nie jest to bariera. + +--- + +## 3. E-mail + +- **Dostawca: Resend.** Szablony zaszyte w Edge Functions (zaproszenia, alerty o grach). +- **Koszt:** darmowy plan = **3000 maili/mies. (100/dzień)**. Płatny: $20/mies. za 50k. + Na start z dużym zapasem — **0 zł**. +- ⚠️ **Do zrobienia — zależne od decyzji o domenie:** nadawcą jest dziś `noreply@bojo.app`, + a domeną kanoniczną aplikacji jest **`bojo.pl`** (patrz §9). Maile wysyłane z domeny + innej niż strona częściej lądują w spamie. Trzeba zweryfikować **`bojo.pl`** w Resend + (rekordy **SPF + DKIM** w DNS) i zmienić nadawcę na `noreply@bojo.pl`. Wymaga dostępu + do DNS i panelu Resend — nie da się tego zrobić z poziomu repo. + +--- + +## 4. Rozdzielenie środowisk dev / prod + +**Priorytet #1 techniczny.** Niski wysiłek, duża wartość — przestajemy testować na żywych +użytkownikach. + +### Plan (koszt: 0 zł na start) +1. **Supabase: drugi projekt `bojo-dev`** (darmowy tier) jako baza dev/staging. Migracje + i nowe funkcje testujemy najpierw tam. +2. **Vercel: Preview Deployments już działają** — każdy branch/PR dostaje własny URL. + Trzeba ustawić **osobne zmienne środowiskowe**: `Production` → Supabase produkcyjny, + `Preview` → `bojo-dev`. +3. **Workflow gitowy:** `feature branch → PR → preview deploy → review → merge → prod`. +4. **Sekrety Edge Functions** ustawić osobno per projekt Supabase. + +--- + +## 5. Dług techniczny + +- [ ] **Zweryfikować w Supabase, które migracje są zastosowane.** W repo jest **57** + migracji (`001`–`057`). Stanu bazy produkcyjnej **nie da się odczytać z repo** — + migracje uruchamia się ręcznie w SQL Editor. Objaw braku: aplikacja rzuca błędem + o nieznanej kolumnie. Patrz [baza-danych.md](./baza-danych.md). +- [ ] **Zod — walidacja danych z bazy** (mappery `toEvent`, `toField` — dziś rzutowanie + bez walidacji runtime) +- [ ] **Dokończyć logowanie błędów** — część `.catch(()=>{})` już zastąpiona +- [ ] **Ujednolicić copy „Zapisz się" / „Dołącz do gry"** +- [ ] **Weryfikacja domeny w Resend** (SPF/DKIM) — patrz §3 +- [ ] **Domknąć reguły dostępu w RLS** — część sprawdzana dziś po stronie przeglądarki + +--- + +## 6. Roadmapa (fazowa) + +Priorytet: **wzrost i retencja** (monetyzacja dopiero przy trakcji). +Statusy zweryfikowane wobec kodu — część pozycji jest zbudowana wcześniej, niż zakładała +pierwotna kolejność faz. + +### Faza 0 — Fundamenty +- Rozdzielenie dev/prod +- Domknięcie długu technicznego (§5) +- Weryfikacja domeny Resend, rejestracja nadawcy „Bojo" w SMSAPI +- **Cel: stabilna baza pod growth.** + +### Faza 1 — Akwizycja / viral +- **PWA + push notifications** — instalowalna apka + darmowy kanał przypomnień +- **Onboarding** — pierwsze wejście prowadzi do „stwórz lub znajdź grę" w < 1 min +- **Wzmocnienie pętli viralowej** — „udostępnij grę" wszędzie, ładny podgląd OG +- **Seed treści** — kilka realnych publicznych gier tygodniowo +- **SEO i widoczność w wyszukiwarkach AI** — strony obiektów i sportów są indeksowalne; + doszła warstwa maszynowa (`llms.txt`, JSON-LD, `robots.txt`) — patrz §9 +- **Cel: pierwszych 100 aktywnych użytkowników.** + +### Faza 2 — Retencja +- **Cykliczne gierki / stałe ekipy — kod jest kompletny, ukryty flagą `SHOW_RECURRING`.** + Zadanie sprowadza się do decyzji o odmrożeniu, nie do budowy. +- Reputacja i profile graczy (zaczęte) — frekwencja, znaczek „rzetelny gracz" +- Powiadomienia push o stałych grach, przypomnienia +- **Cel: użytkownik wraca co tydzień bez naszej zachęty.** + +### Faza 3 — Monetyzacja (gdy jest trakcja) +- **Rezerwacje obiektów z prowizją — warstwa techniczna jest zbudowana** (`lib/bookings.ts`, + tabele `bookings`, `venue_schedules`, `venue_pricing`, trasy `/obiekt/*`, `/rezerwacje`), + ukryta flagą `FEATURE_RESERVATIONS`. Brakuje strony biznesowej: partnerstw z obiektami. +- **Premium dla organizatorów** — darmowy core, płatne dodatki (SMS, statystyki, branding) +- **Cel: pierwszy przychód, walidacja modelu.** + +--- + +## 7. Podział ról + +### Jan (biznes / growth) +- **Outreach do obiektów** — panel CRM (`/admin/outreach`) z danymi wzbogaconymi AI. + Domykanie partnerstw, włączanie `booking_enabled`. +- **Marketing / akwizycja** — grupy FB (piłka/siatka/kosz Poznań), uczelnie (AWF, PP, UAM), + kluby amatorskie, orliki +- **Pierwsze ekipy** — osobiście zebrać pierwsze stałe gierki (seed) +- **Feedback od użytkowników** → backlog +- **Partnerstwa i sprzedaż B2B** (obiekty) w Fazie 3 + +### Franek (tech / produkt) +- Rozwój aplikacji, infrastruktura, decyzje produktowo-techniczne +- Roadmapa techniczna, jakość, bezpieczeństwo + +### Wspólnie +- Decyzje strategiczne i o monetyzacji +- **Cotygodniowy sync** + wspólny backlog +- **KPI tygodniowe:** nowi użytkownicy, aktywne gry/tydzień, retencja + +### ⚠️ Do ustalenia (ważne wcześnie, nie później) +1. **Własność / equity** — jak dzielimy udziały? Spółka czy umowa partnerska? +2. **Zaangażowanie czasowe** — ile godzin/tydzień każdy realnie wkłada? +3. **Definicja sukcesu na 3 i 6 miesięcy** +4. **Budżet** — czy wkładamy własne pieniądze, ile, jak rozliczamy? + +--- + +## 8. Szata graficzna i UX + +### Stan obecny +Design jest spójny: system kolorów (zielony `#15663E`, amber `#F5A623`), fonty +(Inter + Bricolage Grotesque), tokeny w Tailwind. + +### Grafika — gdzie da najwięcej wartości +1. **Finalne logo + warianty** (dziś placeholder „B") — pozioma/kwadratowa/favicon +2. **Zestaw ilustracji „empty state"** +3. **Szablony social media** (post zapowiadający grę, plakat na uczelnię/orlik) +4. **Dopracowany OG image** + +→ Najwięcej daje **logo + zestaw marketingowy**, bo bezpośrednio wspiera akwizycję. + +### UX +Świeże oko zewnętrznego UX-a warto **po pierwszych użytkownikach** — na bazie realnego +feedbacku, nie abstrakcyjnie. Pilniejsze: branding + zebranie pierwszych użytkowników. + +--- + +## 9. Domena i widoczność w wyszukiwarkach + +**Domena kanoniczna: `bojo.pl`.** Ustawiana przez `NEXT_PUBLIC_SITE_URL`; fallback +w kodzie (`layout.tsx`, `robots.ts`, `sitemap.ts`) wskazuje `https://bojo.pl`. + +Warstwa maszynowa dla wyszukiwarek i modeli językowych: +- `frontend/public/llms.txt` — zwięzły opis aplikacji i tras dla modeli +- JSON-LD: `SportsActivityLocation` (boisko), `SportsEvent` (mecz publiczny), + `WebSite` + `Organization` (globalnie), `ItemList` (lista boisk sportu) +- `robots.ts` — panel admina, API i trasy z kodami zaproszeń wyłączone z indeksowania + +Otwarte: nadawca e-mail w Resend wciąż na `bojo.app` (§3). diff --git a/docs/wizja.md b/docs/wizja.md new file mode 100644 index 00000000..5bf02c8e --- /dev/null +++ b/docs/wizja.md @@ -0,0 +1,199 @@ +# Bojo — wizja i propozycje wartości + +> **To jest dokument nadrzędny.** Opisuje kierunek produktu. Gdy kod nie zgadza się +> z tym dokumentem, to **kod nie nadążył** — nie odwrotnie. Rozbieżności trafiają do +> [BACKLOG.md](../BACKLOG.md) jako zadania. +> +> Sekcja 1 to dokument źródłowy **wklejony bez zmian**. Nie parafrazować, nie skracać, +> nie „poprawiać" przy okazji innych zmian. Aktualizacja tylko wtedy, gdy zapadnie +> świadoma decyzja produktowa. + +Plan operacyjny (infrastruktura, koszty, role, fazy) → [strategia.md](./strategia.md). +Stan implementacji funkcji → [funkcje.md](./funkcje.md). + +--- + +## 1. Dokument źródłowy (werbatim) + +> BOJO - Wizja, Value Propositions i Roadmapa +> Wersja robocza v1. 2026.07.18 +> +> **Po co ten dokument** +> +> Żebyśmy oboje mieli te same definicje wizji, wartości i priorytetów. +> Zamiast trzymać je osobno w głowie i ryzykować, że się z czasem rozjadą. +> To nie jest zamknięta decyzja, tylko punkt odniesienia do zmieniania: +> ustalamy → budujemy → testujemy → zbieramy feedback → korygujemy → +> wracamy tutaj i aktualizujemy +> +> **1. Misja, Wizja i Cel Długoterminowy** +> +> Misja: Łączymy ludzi poprzez najprostszy sposób organizowania i +> dołączania do amatorskich gier sportowych. +> +> Wizja: Oddolna platforma społecznościowo-organizacyjna, która staje się +> dla amatorskich graczy domyślnym miejscem planowania, rozgrywania i +> przeżywania aktywności sportowych. +> +> W przeciwieństwie do konkurencji (np. BallSquad), która zaczyna od +> kontraktowania obiektów, BOJO stosuje podejście odwrotne – najpierw to +> skojarzenie z organizacją gry, dopiero potem, z pozycji siły i z realną +> liczbą zaangażowanych userów za sobą, rozmowa z obiektami o rezerwacjach +> i współpracy. +> +> W dłuższej perspektywie BOJO to coś więcej niż narzędzie organizacyjne: +> platforma społecznościowa sportu amatorskiego, gdzie gracze budują +> historię, statystyki i rywalizację, o której można rozmawiać i się nią +> chwalić, podobnie jak Strava robi to dla biegania i kolarstwa. Sport jako +> sfera socjalna, nie tylko rozwiązanie logistycznego problemu "kto gra, +> gdzie i kiedy". BOJO ma być pierwszą aplikacją, którą użytkownik otwiera, +> gdy chce zagrać w sport. +> +> Cel Długoterminowy: Największa społeczność amatorskich graczy +> sportowych w Polsce. Zbudowanie rentownej firmy opartej na tej +> społeczności. Głównym miernikiem sukcesu projektu jest cel finansowy, +> który stanowi ostateczny wskaźnik tego, czy aplikacja dostarcza realną, +> rynkową wartość (użytkownicy i obiekty chcą za nią płacić) - nie tylko +> rośnie liczbą userów. +> +> Cel finansowy nie oznacza rezygnacji z tego, co napędza ten projekt od +> początku - czystej zajawki i chęci rozwiązania własnego problemu. To +> dodatkowy wymiar, który sprawdza, czy inicjatywa jest zdrowa i daje nam +> fun. +> +> **2. WARTOŚCI BOJO (Value Propositions)** +> +> Wartość BOJO budowana jest na dwóch głównych poziomach, które +> rozwiązują realne problemy graczy oraz zaspokajają ich potrzeby +> społeczne: +> +> **A. Poziom Funkcjonalny (Rozwiązanie problemów graczy).** +> +> Najłatwiejszy sposób stworzenia meczu (dopasowany do sportu): Szybkie +> tworzenie gier pojedynczych lub cyklicznych, prywatne, widoczne dla +> grupy lub publiczne +> +> Znajdź brakujących graczy i nie odwołuj gry: wystawianie publicznych gier +> +> Znajdź grę w 2 minuty. Masz czas i ochotę?: dołączanie do gier publicznych +> +> Grupy - (stałe ekipy, zaproszenia, historia meczów i składów) zastąpienie +> facebook/whatsapp, członkowie grupy dostają powiadomienie o utworzeniu +> gry/otwarciu zapisów, +> +> Rozliczysz ekipę w minutę - Kalkulator płatności - zbieranie info ile i +> komu kto ma zapłacić z możliwością oznaczania uregulowanych +> +> Wszystkie obiekty w jednym miejscu: najlepsza baza wiedzy o boiskach i +> obiektach (początkowo jako pinezki na mapie, docelowo pełne profile). +> Bez szukania informacji na stronach +> +> (później) zarezerwuj obiekt bezpośrednio przez apkę +> +> (później) rozliczanie płatności +> +> (później) wynajmij sędziego +> +> (później) zorganizuj turniej firmowy, liga, …………. +> +> **B. Poziom Socjalny (Efekt "Strava / Playarena")** +> +> (później) Rywalizacja i Statystyki: Zapisywanie wyników (gole asysty MVP +> liczba gier), prowadzenie statystyk dla zamkniętych grup znajomych oraz +> rankingów publicznych. Coś, o czym gracze mogą rozmawiać po meczu i czym +> mogą się pochwalić. +> +> (później) Profil Gracza i System Ocen: Budowanie sportowej tożsamości w +> aplikacji, ocena umiejętności oraz system sugerowania odpowiednich +> gier/zawodników na podstawie poziomu zaawansowania i celu gry (skill, +> rywalizacja, sport, przyjemność, kondycja), wiarygodność gracza +> +> (później) poznaj nowych znajomych dzielących pasję/sport +> +> (później) odznaki i rankingi — strzelec miesiąca/roku, X gier w +> miesiącu/roku, 100h na boisku, 10 nowych znajomych +> +> (później) inne kolejne +> +> (później) inne kolejne + +--- + +## 2. Status implementacji + +Legenda: + +| Status | Znaczenie | +|---|---| +| `ZBUDOWANE` | Działa i użytkownik to widzi | +| `ZBUDOWANE, UKRYTE` | Kod kompletny, ale flaga chowa wejście — użytkownik tego nie znajdzie | +| `CZĘŚCIOWO` | Część działa, część brakuje — szczegóły w kolumnie obok | +| `NIE ZNALEZIONO` | Brak w kodzie | + +### A. Poziom funkcjonalny + +| Propozycja wartości | Status | Szczegóły | +|---|---|---| +| Najłatwiejszy sposób stworzenia meczu — gry **pojedyncze** | `ZBUDOWANE` | `/wydarzenia/nowe`, `lib/events.ts` | +| …gry **cykliczne** | `ZBUDOWANE, UKRYTE` | `lib/recurring.ts`, `/cykliczne/*`; flaga `SHOW_RECURRING = false` → [luka 3](#3-luki) | +| …**prywatne** lub **publiczne** | `ZBUDOWANE` | `events.visibility` | +| …**widoczne dla grupy** | `NIE ZNALEZIONO` | `visibility` dopuszcza tylko `private`/`public` → [luka 1](#3-luki) | +| Znajdź brakujących graczy — wystawianie gier publicznych | `ZBUDOWANE` | `getPublicEvents`, `/wydarzenia` | +| Znajdź grę w 2 minuty — dołączanie do gier publicznych | `ZBUDOWANE` | `joinEvent`, sortowanie po odległości, `getNearbyEvents` | +| Grupy — stałe ekipy, zaproszenia, historia | `ZBUDOWANE` | `lib/groups.ts`, `/grupy/*`, `/g/[code]` | +| …**powiadomienie dla członków grupy o nowej grze** | `NIE ZNALEZIONO` | Jedyna ścieżka to `game_alerts` (promień + sport), nie członkostwo → [luka 2](#3-luki) | +| Rozliczysz ekipę w minutę — kalkulator płatności | `CZĘŚCIOWO` | Panel „Podział kosztów" działa, ale tylko dla organizatora i tylko **przed** meczem → [luka 4](#3-luki) | +| Wszystkie obiekty w jednym miejscu | `ZBUDOWANE` | ~1400 boisk, `/mapa`, `/boiska/[sport]`, `/boisko/[id]`; pełne profile zamiast pinezek | +| (później) zarezerwuj obiekt przez apkę | `ZBUDOWANE, UKRYTE` | **Wyprzedza roadmapę.** `lib/bookings.ts`, `/obiekt/*`, `/rezerwacje`; flaga `FEATURE_RESERVATIONS` | +| (później) rozliczanie płatności | `CZĘŚCIOWO` | Rejestrowanie kto zapłacił — tak. Realny przepływ pieniędzy (BLIK/Stripe) — nie | +| (później) wynajmij sędziego | `NIE ZNALEZIONO` | — | +| (później) turniej firmowy, liga | `ZBUDOWANE, UKRYTE` | **Wyprzedza roadmapę.** `lib/tournaments.ts` (455 linii), 6 tabel `tournament_*`, `/turniej/*`; flaga `SHOW_CUP` | + +### B. Poziom socjalny + +| Propozycja wartości | Status | Szczegóły | +|---|---|---| +| (później) Rywalizacja i statystyki — **gole, asysty, liczba gier** | `ZBUDOWANE` | RPC `get_player_stats`, tabele `player_goals`, `match_results`, `player_stats` | +| …**MVP** | `NIE ZNALEZIONO` | Jedyne wystąpienie to tekst nagrody na `/turniej` | +| …statystyki dla **zamkniętych grup** | `ZBUDOWANE` | `getGroupPlayerStats` w `lib/eventFeatures.ts` | +| …**rankingi publiczne** | `NIE ZNALEZIONO` | — | +| (później) Profil gracza | `ZBUDOWANE` | `/gracz/[id]`, `lib/players.ts` | +| …**wiarygodność gracza** | `ZBUDOWANE` | `reliabilityPct()`, znaczek „rzetelny gracz" (≥5 gier, 0 nieobecności), `player_reports` | +| …**ocena umiejętności / poziom zaawansowania** | `NIE ZNALEZIONO` | — | +| …**sugerowanie gier/zawodników wg poziomu i celu gry** | `NIE ZNALEZIONO` | — | +| (później) poznaj nowych znajomych | `NIE ZNALEZIONO` | — | +| (później) odznaki i rankingi | `NIE ZNALEZIONO` | Poza znaczkiem „rzetelny gracz" | + +--- + +## 3. Luki + +Pozycje, w których **kod nie nadążył za dokumentem**. Każda ma wpis w +[BACKLOG.md](../BACKLOG.md). + +1. **Trzeci poziom widoczności — „widoczne dla grupy".** Dokument wymienia trzy poziomy, + kod ma dwa: `events.visibility` to CHECK `('private','public')` (`002_events_and_auth.sql`). + Kolumna `group_id` (`051_group_field.sql`) steruje **listowaniem** meczu w grupie, + nie **dostępem** do niego. Wymaga migracji + zmian w UI tworzenia meczu. + +2. **Powiadomienie dla członków grupy o utworzeniu gry.** Dokument stawia to jako część + propozycji „Grupy — zastąpienie facebook/whatsapp", bo bez powiadomienia grupa nie + zastępuje czatu. Kanał powiadomień **istnieje** (tabela `notifications`, + `lib/notifications.ts`, `NotificationBell`, wysyłka e-mail przez Resend), ale nic nie + wywołuje go przy tworzeniu meczu w grupie. + +3. **Gry cykliczne ukryte flagą.** Dokument wymienia je w pierwszej propozycji wartości, + na równi z grami pojedynczymi. Kod jest kompletny; `SHOW_RECURRING = false` ukrywa + wejścia w `Header.tsx`, `app/page.tsx` i `moje-gry`. Decyzja: odmrozić czy uzasadnić + ukrycie. + +4. **Rozliczenie po meczu.** Propozycja brzmi „Rozliczysz ekipę w minutę", a panel + „Podział kosztów" renderuje się pod warunkiem `isOwner && !eventStarted` + (`EventDetailClient.tsx`). Rozliczenie po meczu — czyli wtedy, kiedy się zwykle + rozlicza — jest niedostępne, a uczestnicy nie widzą, ile mają zapłacić. + +### Odwrotny kierunek: kod wyprzedza dokument + +**Rezerwacje obiektów** i **turniej** są oznaczone w dokumencie jako „(później)", a są +zbudowane i schowane za flagami. To nie jest luka — to decyzja do podjęcia: odmrozić +i przesunąć w roadmapie, czy zostawić do czasu, aż będzie na to popyt. diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..380dd01d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +# Development server +FROM base AS dev +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +EXPOSE 3000 +ENV NODE_ENV=development +ENV NEXT_TELEMETRY_DISABLED=1 + +CMD ["npm", "run", "dev"] diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 00000000..b7fc0dd1 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '*.supabase.co', + }, + ], + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..a2199f4b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8167 @@ +{ + "name": "boiska-poznan-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "boiska-poznan-frontend", + "version": "0.1.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@supabase/supabase-js": "^2.43.4", + "@types/leaflet.markercluster": "^1.5.6", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", + "lucide-react": "^0.378.0", + "next": "14.2.3", + "next-themes": "^0.4.6", + "react": "^18", + "react-dom": "^18", + "react-leaflet": "^4.2.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/leaflet": "^1.9.12", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10", + "eslint": "^8", + "eslint-config-next": "14.2.3", + "jsdom": "^29.1.1", + "postcss": "^8", + "tailwindcss": "^3.4.3", + "typescript": "^5", + "vitest": "^4.1.8" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.3.tgz", + "integrity": "sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz", + "integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz", + "integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz", + "integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz", + "integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz", + "integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz", + "integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.106.2", + "@supabase/functions-js": "2.106.2", + "@supabase/postgrest-js": "2.106.2", + "@supabase/realtime-js": "2.106.2", + "@supabase/storage-js": "2.106.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.0.tgz", + "integrity": "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.3.tgz", + "integrity": "sha512-ZkNztm3Q7hjqvB1rRlOX8P9E/cXRL9ajRcs8jufEtwMfTVYRqnmtnaSu57QqHyBlovMuiB8LEzfLBkh5RYV6Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "14.2.3", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.378.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz", + "integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.3", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", + "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..643d9414 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "boiska-poznan-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@supabase/supabase-js": "^2.43.4", + "@types/leaflet.markercluster": "^1.5.6", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", + "lucide-react": "^0.378.0", + "next": "14.2.3", + "next-themes": "^0.4.6", + "react": "^18", + "react-dom": "^18", + "react-leaflet": "^4.2.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/leaflet": "^1.9.12", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10", + "eslint": "^8", + "eslint-config-next": "14.2.3", + "jsdom": "^29.1.1", + "postcss": "^8", + "tailwindcss": "^3.4.3", + "typescript": "^5", + "vitest": "^4.1.8" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 00000000..b703d729 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss').ProcessOptions} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/frontend/public/llm-context.md b/frontend/public/llm-context.md new file mode 100644 index 00000000..c0422cdc --- /dev/null +++ b/frontend/public/llm-context.md @@ -0,0 +1,483 @@ +# Bojo — kontekst dla modeli językowych + +> Bojo (bojo.pl) to aplikacja webowa do organizowania amatorskich meczów w całej Polsce +> (katalog boisk dziś najgęstszy w Poznaniu): 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-05 · migracja `061` · 30 tabel · 121 testów + +--- + +## 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 ~1400 boisk w Poznaniu 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 Poznaniu? 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 (~1400 obiektów) jest dziś najgęstszy w Poznaniu i rośnie o kolejne miasta. +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, dołączanie do +składu i zakładanie grup wymagają logowania. + +**Pytania, na które odpowiada ta sekcja:** W jakich miastach działa Bojo? Czy Bojo jest +dostępne poza Poznaniem? Ile boisk ma Bojo? Jakie sporty obsługuje Bojo? Czy trzeba mieć +konto, żeby przeglądać boiska? + +--- + +## 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, obecność, 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. + +**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? + +--- + +## 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, a historia wspólnych meczów nigdzie nie zostaje. + +**Rozwiązanie w Bojo.** Grupa to stała ekipa: sport, miasto, okładka, lista członków +i mecze grupy w jednym miejscu. Dołącza się przez link zaproszenia. + +**Mechanika.** Logika w `frontend/src/lib/groups.ts`, tabele `groups` i `group_members` +(migracja `044`). Link zaproszenia prowadzi pod `/g/[kod]`. Twórca grupy zostaje jej +członkiem automatycznie (trigger `add_group_creator_as_member`). + +**Przypisanie meczu do grupy steruje listowaniem, nie dostępem.** Kolumna +`events.group_id` sprawia, że mecz pojawia się na liście meczów grupy, ale jego +widoczność nadal wynika wyłącznie z `events.visibility`. Nie istnieje widoczność +„tylko dla mojej grupy". + +**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 dostają +powiadomienie o nowym meczu? + +--- + +## 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 ~1400 obiektów w Poznaniu 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 Poznaniu? 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. +- **Widoczność „tylko dla grupy".** `events.visibility` to wyłącznie `private`/`public`. +- **Powiadomienie dla członków grupy o nowym meczu grupy.** +- **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-06 — Strony boisk generowane na żądanie zamiast przy buildzie +PROBLEM: każda strona boiska w Bojo była generowana z góry, przy budowaniu +aplikacji — tyle stron, ile obiektów w katalogu. Dopóki katalog obejmował +Poznań, mieściło się to w kilku minutach. Po imporcie z OpenStreetMap urósł +do ~4600 obiektów i budowanie przestało się kończyć: ponad 40 minut bez +skutku, więc żadna zmiana nie docierała na produkcję. +ROZWIĄZANIE BOJO: strona boiska powstaje przy pierwszym wejściu i zostaje +w pamięci podręcznej na dobę. Czas budowania Bojo nie zależy już od wielkości +katalogu, co jest warunkiem dojścia do dziesiątek tysięcy obiektów z całej +Polski. Adresy stron i mapa witryny się nie zmieniły. +MECHANIKA: `generateStaticParams()` w `app/boisko/[id]/page.tsx` zwraca pustą +listę, `revalidate = 86400`; `resolveField()` rozwiązuje slug przez wspólny +indeks slug→id z TTL zamiast pobierać całą tabelę `fields` raz na render. + +### 2026-08-06 — Mapa otwiera się na Polsce, nie na Poznaniu +PROBLEM: mapa Bojo brała już obiekty z całego kraju, ale startowała z widokiem +ustawionym na Poznań przy dużym przybliżeniu. Po imporcie 1638 boisk +z lubelskiego użytkownik widział ten sam pusty Poznań i miał prawo sądzić, +że import nic nie dodał. Osobno: boiska opisane w OpenStreetMap jako +wielofunkcyjne (`sport=multi`) nie były na liście dyscyplin, które mapa +przepuszcza — w samym lubelskiem odpadały tak 162 obiekty. +ROZWIĄZANIE BOJO: mapa otwiera się na całej Polsce. Kto chce swoją okolicę, +klika przycisk pinezki w prawym dolnym rogu mapy — Bojo pyta wtedy przeglądarkę +o lokalizację i przeskakuje na nią. Pytanie o zgodę pada dopiero po kliknięciu, +bo mapa kraju działa i bez niej. Boiska wielofunkcyjne są widoczne na mapie, +ale nie mają własnego filtra dyscypliny: tag `multi` z OpenStreetMap nie mówi, +w co konkretnie da się tam zagrać. +MECHANIKA: `POLSKA` / `POLSKA_ZOOM` w `components/map/mapIcons.ts`, przycisk +lokalizacji i `locateMe()` w `components/map/VenueExplorer.tsx`, +`wielofunkcyjne` dopisane do `EXPLORER_SPORTS` w `lib/api.ts` i do +`SPORT_CONFIG` w `lib/sports.ts`. + +### 2026-08-05 — Mapa pokazuje boiska z całej Polski +PROBLEM: mapa Bojo miała zaszyty prostokąt wokół Poznania i pokazywała tylko to, +co się w nim mieściło. Obiekty zaimportowane z innych województw nie miały szans +się pojawić. Drugi filtr wymagał telefonu, strony albo opisu — świeży import +z OpenStreetMap nie ma żadnej z tych rzeczy, więc odsiewał je nawet w zasięgu. +ROZWIĄZANIE BOJO: mapa pokazuje obiekty z całego kraju. O tym, co jest widoczne, +decyduje wyłącznie `map_visibility = 'public'` — jedno kryterium zamiast dwóch +zachodzących na siebie. Import z OSM ustawia tę kolumnę świadomie, według bramki +jakości opartej na tagach, więc nie trzeba jej już podpierać filtrem zastępczym. +MECHANIKA: `getExplorerFields()` w `lib/api.ts` (usunięte `EXPLORER_BOUNDS` +i filtr „ma kontakt"), punkt zerowy przeplotu Mortona w +`components/map/VenueExplorer.tsx` przesunięty na 49° N / 14° E — przy starym +(52° N, 16,5° E) wszystko na południe od Poznania traciło porządek przestrzenny. + +### 2026-08-05 — Domyślny skład zależny od sportu +PROBLEM: kreator meczu w Bojo proponował 12 miejsc niezależnie od dyscypliny. +Przy siatkówce plażowej, gdzie gra się 2 na 2, organizator musiał osiem razy +kliknąć minus. Na mapie karta obiektu chowała się pod dolną nawigacją, więc +adresu nie dało się doczytać. +ROZWIĄZANIE BOJO: liczba miejsc podpowiada się z dyscypliny — piłka nożna 14 +(7v7), piłka ręczna 14, siatkówka 12 (6v6), futsal 10 (5v5), koszykówka 10 +(5v5), siatkówka plażowa 4 (2v2). Podpowiedź ustępuje, gdy organizator sam +ruszy licznik. Karuzela obiektów na mapie ma dopełnienie na wysokość paska +nawigacji. +MECHANIKA: `SPORT_PLAYERS` w `app/wydarzenia/nowe/page.tsx`, dolne dopełnienie +karuzeli w `components/map/VenueExplorer.tsx`. + +### 2026-08-05 — Uczestnik znów może zapraszać na mecz +PROBLEM: „Zaproś z ekipy" działało wyłącznie organizatorowi. Uczestnikowi Bojo +odpowiadało błędem o naruszeniu zabezpieczeń, mimo że sam zapis był dozwolony. +Do tego etykieta „już zaproszony" w oknie zapraszania kłamała uczestnikom, bo +nie widzieli cudzych zaproszeń — dało się wysłać drugie do tej samej osoby. +ROZWIĄZANIE BOJO: uczestnik meczu może zapraszać tak jak organizator, a lista +zaproszonych jest widoczna dla wszystkich, którzy na tym meczu grają. +MECHANIKA: migracja `061` rozszerza politykę SELECT na `event_player_invites` +o zapraszającego (`invited_by`) i uczestników meczu. Sedno: `invitePlayers()` +robi `INSERT … RETURNING id`, a Postgres stosuje do RETURNING politykę SELECT — +brak prawa odczytu przerywał całą operację błędem wyglądającym jak odrzucony +zapis. Sam warunek INSERT z migracji `060` był poprawny i został bez zmian. + +### 2026-08-05 — Kreator meczu bez rozproszeń, zaproszenia w Moich grach +PROBLEM: kreator meczu rozpraszał organizatora — numerki kroków 1/2/3 nic nie robiły, +przycisk „Dalej" na pierwszym kroku był zasłonięty przez dolny panel nawigacji +(„Gry Mapa Nowy Moje Grupy"), a „Dalej"/„Wróć" zajmowały dwie osobne linie. Ten sam +panel zasłaniał przyciski „Dołącz"/„Obserwuj" na stronie meczu i modal wypisania się. +Zaproszenia na mecz żyły wyłącznie na stronie głównej — bez znaczka nigdzie indziej, +łatwo je przegapić. +ROZWIĄZANIE BOJO: numerki kroków są teraz klikalne i walidują tak samo jak „Dalej" — +skok na krok, którego wymagania nie są spełnione, zatrzymuje się na pierwszym +brakującym polu. „Wróć" i „Dalej" siedzą w jednym przyklejonym pasku, w jednej linii. +Dolny panel chowa się w kreatorze przez cały czas i na stronie meczu, dopóki +użytkownik nie ma potwierdzonego miejsca ani oczekującej prośby — wraca po dołączeniu. +Zdublowany przycisk „+" na `/wydarzenia` (chowany za panelem) usunięty. Modale nad +panelem zamiast pod nim. Zaproszenia dostały zakładkę „Zaproszenia" na `/moje-gry` +(`?tab=zaproszenia`, przeżywa odświeżenie) i plakietkę z licznikiem obok pola +wyszukiwania na `/wydarzenia` — widoczną tylko, gdy jest co najmniej jedno zaproszenie. +MECHANIKA: `lib/eventWizard.ts` (walidacja kroków, wydzielona pod testy), +`lib/bottomNavVisibility.tsx` (`HideBottomNav`, kontekst z licznikiem), +`app/wydarzenia/nowe/page.tsx`, `app/wydarzenia/[id]/EventDetailClient.tsx` +(`joinBarVisible`), `lib/useMyInvites.ts`, `components/events/InviteList.tsx`, +`app/moje-gry/page.tsx`, `app/wydarzenia/EventsListClient.tsx`, +`components/layout/BottomNav.tsx` („Gry" → „Znajdź grę"). + +### 2026-08-04 — Strona meczu: mniej ozdób, więcej odpowiedzi +PROBLEM: strona meczu w Bojo otwierała się zdjęciem satelitarnym na pół ekranu, +które nic nie mówiło. Nie było widać, czy się w tym meczu gra ani czy się je +organizuje — trzeba było rozwinąć skład. Kto kliknął „Obserwuj", tracił przycisk +„Dołącz". Boisko pojawiało się dwa razy, a jego nazwa (zwykle „Boisko — piłka +nożna") mówiła mniej niż adres. Powrót ze strony boiska wyrzucał na mapę zamiast +do meczu, a miejsce wpisane ręcznie w ogóle nie dawało się kliknąć. +ROZWIĄZANIE BOJO: zdjęcie usunięte, na górze pasek z opisanymi akcjami +„Udostępnij" i „Kopiuj". Wśród plakietek widać teraz „Organizujesz", „Grasz" +(z rolą bramkarza) albo „Rezerwa · N." z pozycją w kolejce i „Obserwujesz". +Organizator zmienia termin i widoczność klikając plakietkę; zmiana terminu przy +zapisanych graczach wymaga potwierdzenia. Plakietka boiska pokazuje adres i +prowadzi na stronę obiektu, skąd strzałka wraca do meczu; miejsce spoza katalogu +otwiera okno z adresem i nawigacją. Wypisanie się ma teraz drugą opcję — +„Wypisz mnie, ale obserwuj" — oraz krzyżyk do zamknięcia. Dopisując osobę bez +konta wybiera się rolę (zawodnik z pola albo bramkarz) zamiast samego pola +wyboru „dodaj jako bramkarza". +MECHANIKA: `app/wydarzenia/[id]/EventDetailClient.tsx`, `setEventWhen()` +w `lib/events.ts` (osobna funkcja, żeby nie nadpisywać reszty pola jak +`updateEvent`), parametr `?wroc=` na trasie `/boisko/[id]` (tylko ścieżki +względne). + +### 2026-08-04 — Landing i dashboard: zwrot na organizatora, zasięg ogólnopolski +PROBLEM: Landing obiecywał „zbierz skład na mecz w dwie minuty" — nierealne dla +kilkunastoosobowej ekipy — i mówił wyłącznie o Poznaniu, mimo że tworzenie meczu +z pinezką na mapie działa już w całej Polsce. Górny pasek był zawsze biały i zjadał +pierwszy ekran telefonu, a „Zaloguj się" było dostępne wyłącznie w menu hamburgera +na mobile. Dashboard zalogowanego stawiał „Otwarte mecze" (dziś zwykle puste) nad +„Twoje grupy" (gdzie realnie dzieje się ruch) i nie miał sekcji „Jak to działa" ani FAQ. +ROZWIĄZANIE BOJO: H1 mówi o czynności, którą Bojo faktycznie dowozi w dwie minuty +(„Zorganizuj mecz"), a rotujący nagłówek nad H1 pokazuje trzy wartości organizatora +zamiast liczby boisk w jednym mieście. Górny pasek jest przezroczysty nad hero (z +widocznym od razu „Zaloguj się" na mobile) i staje się białym paskiem po zescrollowaniu +albo dla zalogowanych. Sticky CTA na mobile to teraz okrągły przycisk „+" zamiast paska +na całą szerokość. Pasek cookies pokazuje się dopiero po przewinięciu 300px albo po +6 sekundach, więc nie zjada pierwszego ekranu. Dolna nawigacja zalogowanego ma „Grupy" +zamiast „Profil" (profil jest już w menu hamburgera). Dashboard: „Twoje grupy" nad +„Otwarte mecze", a „Jak to działa" i FAQ pokazują się zawsze na dole, nie tylko przy +zerowej aktywności. +MECHANIKA: `components/home/landing/{content,LandingHero,LandingStats,LandingVenues, +StickyCta,RotatingBadge,PhoneMock}.tsx`, `components/layout/Header.tsx` (prop +`transparentOverHero`), `components/Logo.tsx` (wariant `onDark` dla `LogoPill`), +`lib/cookieConsent.ts` (hook `useCookieBannerVisible`, dzielony przez `CookieBanner.tsx` +i `StickyCta.tsx`), `components/layout/BottomNav.tsx`, +`components/home/{AppHome,dashboard/DashboardSections}.tsx`, +`components/layout/SiteFooter.tsx`. + +### 2026-08-04 — Dashboard zalogowanego przebudowany + koniec mignięcia landingu +PROBLEM: strona główna zalogowanego wciąż otwierała się marketingowym hero +(„Znajdź mecz. Albo stwórz własny.") — copy sprzedające produkt komuś, kto już +go używa. Do tego serwer nie wiedział, kto jest zalogowany, więc zalogowany +widział na moment landing dla gości, zanim JavaScript zdążył odczytać sesję +z `localStorage`. Wreszcie: dashboard robił 7–8 zapytań do Supabase na każde +wejście, z czego trzy sekcje budowały własną, osobną kopię mapy uczestnictwa, +a mecz z imiennym zaproszeniem do grupy pokazywał się dwa razy (w „Zaproszenia" +i w „Mecze Twoich ekip"). +ROZWIĄZANIE BOJO: dashboard zaczyna się kompaktowym powitaniem i kartą +najbliższego meczu (co i kiedy gram), nie hero. Serwer renderuje od razu +szkielet dashboardu dla zalogowanego dzięki ciasteczku-wskazówce (bez tokenu — +serwer wciąż nie ma prawdziwej sesji, tylko podpowiedź, którą skorupę +wyrenderować). Status uczestnictwa `invited` (zarezerwowany w kodzie, ale +wcześniej niczego nie produkujący) zaczął być realnie zwracany, co usunęło +duplikat zaproszenia. Na mobile aktywowana dolna nawigacja (wcześniej gotowa, +ale nieużywana) dla zalogowanych. +MECHANIKA: `lib/sessionHint.ts` (ciasteczko `bojo_sess`, bez tokenu), +synchronizacja w `lib/auth.tsx`, `AppHomeSkeleton.tsx`, `app/page.tsx` (odczyt +`cookies()`). `lib/useDashboardData.ts` — jedno wywołanie `Promise.allSettled` +zamiast pięciu niezależnych efektów. `lib/myEvents.ts` (`splitMyEvents`, +`nextMatch` — sortuje samodzielnie, bo `getMyParticipatedEvents()` zwraca dane +malejąco). `lib/eventDates.ts` (`matchWhenLabel` i przeniesione stąd +`isUpcoming`/`isEventJoinable`/`timeUntil`). `getMyParticipationMap()` +w `lib/events.ts` dociąga `event_player_invites` → status `invited`. +`components/home/dashboard/{GreetingBar,NextMatchCard,DashboardSections}.tsx`, +`components/layout/{BottomNav,BottomNavGate}.tsx`. + +### 2026-08-04 — Krótszy kreator meczu +PROBLEM: kreator meczu w Bojo wymagał zbyt wielu decyzji: pusta data, ręczna godzina +zakończenia, sekcja „Ustawienia zaawansowane" z przełącznikami, których każdy i tak +chciał używać, a przycisk „Dalej" był poza pierwszym ekranem telefonu. +ROZWIĄZANIE BOJO: sport wybiera się z jednej przewijanej linii (plus mały dropdown), +data domyślnie jutro, zamiast godziny zakończenia jest „Czas gry" (domyślnie 90 min, +koniec liczy się sam), liczba miejsc domyślnie 12 dla piłki nożnej z informacją, +że kolejni trafią na rezerwę. Widoczność domyślnie publiczna. Sekcja zaawansowana +zniknęła: obecność jest zawsze włączona, a mecz z ustawioną kwotą sam włącza +śledzenie płatności i pokazuje graczom status wpłat. Organizator zapisujący się +na własny mecz wybiera przy tym rolę: bramkarz lub zawodnik z pola. +MECHANIKA: `app/wydarzenia/nowe/page.tsx`, parametr `organizerIsGoalkeeper` +w `createEvent()` (`lib/events.ts`); wiersz organizatora dostaje status +„potwierdzony" — zapis znaczy „będę", organizator oznacza potem nieobecnych. + diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt new file mode 100644 index 00000000..dbecbfbe --- /dev/null +++ b/frontend/public/llms.txt @@ -0,0 +1,53 @@ +# Bojo + +> Bojo (bojo.pl) to aplikacja webowa do organizowania amatorskich meczów w całej Polsce: +> znajdź boisko, stwórz mecz, zbierz skład. Katalog ~1400 boisk i obiektów sportowych +> (dziś najgęstszy w Poznaniu), mecze publiczne otwarte na dołączenie, stałe ekipy +> (grupy). Sporty: piłka nożna, siatkówka, siatkówka plażowa, koszykówka. Interfejs +> po polsku. Logowanie: Google lub e-mail. + +## Kontekst dla modeli + +- [Pełny kontekst o Bojo](/llm-context.md): gęsty opis produktu, modelu domenowego + i granic aplikacji — problem, rozwiązanie i mechanika każdej funkcji + +## Dla graczy + +- [Strona główna](/): jak zorganizować mecz gdziekolwiek w Polsce, otwarte gry, FAQ +- [Mapa boisk i meczów](/mapa): filtry po sporcie, nawierzchni, dzielnicy +- [Lista meczów](/wydarzenia): publiczne mecze, sortowanie po odległości +- [Nowy mecz](/wydarzenia/nowe): sport, miejsce, termin, liczba graczy, widoczność +- [Moje gry](/moje-gry): mecze organizowane, te na które jesteś zapisany i zaproszenia na mecz +- [Grupy](/grupy): stałe ekipy, dołączanie przez link zaproszenia +- [Profil](/profil): imię, awatar, telefon, usunięcie konta + +## Boiska + +- [Piłka nożna](/boiska/pilka-nozna) +- [Futsal](/boiska/futsal) +- [Koszykówka](/boiska/koszykowka) +- [Piłka ręczna](/boiska/pilka-reczna) +- [Siatkówka](/boiska/siatkowka) +- [Siatkówka plażowa](/boiska/siatkowka-plazowa) + +Strona pojedynczego boiska: `/boisko/` — adres, sporty, nawierzchnia, +zdjęcie, dane kontaktowe (jeśli obiekt zgodził się na publikację) i nadchodzące mecze. + +## Jak działa Bojo + +- Przeglądanie mapy i stron boisk nie wymaga konta. Tworzenie meczu i dołączanie — tak. +- 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. +- Organizator może wymagać akceptacji zapisu oraz pozwolić uczestnikom dopisywać gości + bez konta. +- Status **„Obserwuję"** nie zajmuje miejsca w składzie ani nie liczy się do statystyk. +- Gdy komplet jest pełny, kolejne zapisy trafiają na **listę rezerwową**. Rezerwowy + **nie wskakuje automatycznie**, gdy ktoś się wypisze — organizator powiadamia go sam. +- Organizator może włączyć podział kosztów, wyniki meczu, obecność i podział na drużyny. +- Bojo **rejestruje**, kto zapłacił — **nie przelewa pieniędzy**. Płatność odbywa się + poza aplikacją. + +## Optional + +- [Regulamin](/regulamin) +- [Polityka prywatności](/prywatnosc) diff --git a/frontend/public/mockups/mockup-1-lista-gier.png b/frontend/public/mockups/mockup-1-lista-gier.png new file mode 100644 index 00000000..6a9a1255 Binary files /dev/null and b/frontend/public/mockups/mockup-1-lista-gier.png differ diff --git a/frontend/public/mockups/mockup-2-szczegoly-meczu.png b/frontend/public/mockups/mockup-2-szczegoly-meczu.png new file mode 100644 index 00000000..d6d2a801 Binary files /dev/null and b/frontend/public/mockups/mockup-2-szczegoly-meczu.png differ diff --git a/frontend/public/mockups/mockup-3-mapa-boisk.png b/frontend/public/mockups/mockup-3-mapa-boisk.png new file mode 100644 index 00000000..065601a3 Binary files /dev/null and b/frontend/public/mockups/mockup-3-mapa-boisk.png differ diff --git a/frontend/public/mockups/mockup-4-profil-gracza.png b/frontend/public/mockups/mockup-4-profil-gracza.png new file mode 100644 index 00000000..2c32fb4a Binary files /dev/null and b/frontend/public/mockups/mockup-4-profil-gracza.png differ diff --git a/frontend/public/mockups/screen-1-mecze.png b/frontend/public/mockups/screen-1-mecze.png new file mode 100644 index 00000000..9515b929 Binary files /dev/null and b/frontend/public/mockups/screen-1-mecze.png differ diff --git a/frontend/public/mockups/screen-2-moje.png b/frontend/public/mockups/screen-2-moje.png new file mode 100644 index 00000000..81bf6535 Binary files /dev/null and b/frontend/public/mockups/screen-2-moje.png differ diff --git a/frontend/public/mockups/screen-2-nowe.png b/frontend/public/mockups/screen-2-nowe.png new file mode 100644 index 00000000..d8b408b9 Binary files /dev/null and b/frontend/public/mockups/screen-2-nowe.png differ diff --git a/frontend/public/mockups/screen-3-mapa.png b/frontend/public/mockups/screen-3-mapa.png new file mode 100644 index 00000000..d660ca2a Binary files /dev/null and b/frontend/public/mockups/screen-3-mapa.png differ diff --git a/frontend/public/mockups/screen-4-moje.png b/frontend/public/mockups/screen-4-moje.png new file mode 100644 index 00000000..896ce62b Binary files /dev/null and b/frontend/public/mockups/screen-4-moje.png differ diff --git a/frontend/public/mockups/screen-4-profil.png b/frontend/public/mockups/screen-4-profil.png new file mode 100644 index 00000000..81140ab5 Binary files /dev/null and b/frontend/public/mockups/screen-4-profil.png differ diff --git a/frontend/public/poznan-satellite.jpg b/frontend/public/poznan-satellite.jpg new file mode 100644 index 00000000..09a4eab7 Binary files /dev/null and b/frontend/public/poznan-satellite.jpg differ diff --git a/frontend/public/sports/beach-volleyball.svg b/frontend/public/sports/beach-volleyball.svg new file mode 100644 index 00000000..09210ab7 --- /dev/null +++ b/frontend/public/sports/beach-volleyball.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/scripts/seed-demo-events.mjs b/frontend/scripts/seed-demo-events.mjs new file mode 100644 index 00000000..a91f7e65 --- /dev/null +++ b/frontend/scripts/seed-demo-events.mjs @@ -0,0 +1,180 @@ +// Seed DEMO matches for Bojo — wipes all events, then creates a realistic set +// across dates/sports, each filled with the test players (+ named guests). +// +// Goal: show what the app looks like once it's busy, and have something to demo. +// +// ── Run it ─────────────────────────────────────────────────────────────────── +// 1) First create the test players (once): node scripts/seed-test-users.mjs +// 2) Then: +// cd frontend +// SUPABASE_URL="https://YOURPROJECT.supabase.co" \ +// SUPABASE_SERVICE_ROLE_KEY="eyJ...service_role..." \ +// node scripts/seed-demo-events.mjs +// +// ⚠ This DELETES every existing event (and their participants/comments via +// cascade). Only run it on a test/demo project. + +import { createClient } from '@supabase/supabase-js'; + +const SUPABASE_URL = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; +const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!SUPABASE_URL || !SERVICE_KEY) { + console.error('\n✗ Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (Project Settings → API).\n'); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +// ── helpers ────────────────────────────────────────────────────────────────── +const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const shuffle = (arr) => arr.map((v) => [Math.random(), v]).sort((a, b) => a[0] - b[0]).map(([, v]) => v); +const dateIn = (days) => { + const d = new Date(); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +}; + +// Named guests so full rosters look real (these have no account → show initials). +const GUEST_NAMES = [ + 'Tomasz Lis', 'Paweł Krawczyk', 'Adrian Mazur', 'Bartosz Kaczmarek', 'Damian Wróbel', + 'Sebastian Pawlak', 'Grzegorz Adamczyk', 'Marcin Dudek', 'Łukasz Sikora', 'Rafał Baran', + 'Krzysztof Michalski', 'Dawid Olszewski', 'Patryk Stępień', 'Hubert Górski', 'Oskar Witkowski', + 'Filip Rutkowski', 'Wojciech Zawadzki', 'Konrad Sadowski', 'Igor Jakubowski', 'Kamil Walczak', +]; + +// ── demo matches (Poznań venues as custom locations) ───────────────────────── +const EVENTS = [ + { title: 'Środowa piłka 7v7', sport: 'piłka nożna', max: 14, days: 1, time: '19:00', end: '21:00', cost: 1500, + field: 'Orlik Rataje', lat: 52.3889, lng: 16.9560, + desc: 'Regularna środowa gierka na sztucznej trawie. Gramy do dwóch straconych, rotacja składów. Poziom luźny — liczy się dobra zabawa i pełne składy. Buty turfy lub lanki.' }, + { title: 'Niedzielne 6v6 — hala', sport: 'piłka nożna', max: 12, days: 3, time: '11:00', end: '12:30', cost: 1800, + field: 'Hala Politechnika', lat: 52.4020, lng: 16.9490, + desc: 'Gramy w hali 2×30 min, równe składy losowane na miejscu. Obuwie na halę obowiązkowe (jasna podeszwa). Kameralna, stała ekipa — dobierzemy brakujących.' }, + { title: 'Piłka 7v7 — Wilda', sport: 'piłka nożna', max: 14, days: 2, time: '18:30', end: '20:00', cost: 0, + field: 'Orlik Wilda', lat: 52.3870, lng: 16.9200, + desc: 'Za darmo, Orlik miejski. Spokojne tempo, mile widziani początkujący. Zbieramy pełne 7 na 7 — wpadaj jak brakuje Ci gry.' }, + { title: 'Poranna piłka 7v7', sport: 'piłka nożna', max: 14, days: 5, time: '09:00', end: '10:30', cost: 1200, + field: 'Golęcin — kompleks boisk', lat: 52.4350, lng: 16.8950, + desc: 'Sobotni poranek, świeże nogi. Gramy szybkie mecze do gola, zmiana co 10 min. Po wszystkim kawa w klubie.' }, + { title: 'Wieczorne 6v6', sport: 'piłka nożna', max: 12, days: 7, time: '20:00', end: '21:30', cost: 1600, + field: 'Orlik Grunwald', lat: 52.3990, lng: 16.8800, + desc: 'Mecz pod światłami. Równe drużyny, gramy do 7 bramek lub do końca czasu. Stała grupa, szukamy kilku do kompletu.' }, + { title: 'Piłka 7v7 — Sołacz', sport: 'piłka nożna', max: 14, days: 9, time: '18:00', end: '19:30', cost: 1000, + field: 'Park Sołacki — boisko', lat: 52.4250, lng: 16.9050, + desc: 'Trawa naturalna, klimatyczne miejsce. Luźna gra dla każdego poziomu. Bramkarz mile widziany!' }, + + { title: 'Siatkówka plażowa 4v4', sport: 'siatkówka plażowa', max: 8, days: 2, time: '17:00', end: '19:00', cost: 800, + field: 'Malta — boisko piaszczyste', lat: 52.4030, lng: 16.9760, + desc: 'Piasek nad Maltą, dwa boiska. Gramy do dwóch wygranych setów, rotacja par. Poziom rekreacyjny, woda i dobry humor obowiązkowe.' }, + { title: 'Plażówka po pracy', sport: 'siatkówka plażowa', max: 8, days: 6, time: '18:30', end: '20:30', cost: 0, + field: 'Plaża Rataje', lat: 52.3850, lng: 16.9620, + desc: 'Luźne granie na piachu po pracy. Miksujemy składy, gramy krótkie sety. Za darmo — wpadaj uzupełnić ekipę.' }, + + { title: 'Streetball 3x3', sport: 'koszykówka', max: 6, days: 4, time: '19:00', end: '20:30', cost: 0, + field: 'Łęgi Dębińskie — boisko', lat: 52.3870, lng: 16.9080, + desc: 'Koszykówka uliczna do 21 punktów, winner stays. Jedna obręcz, szybkie zmiany. Za darmo, wpadaj na parkiet.' }, + { title: 'Kosz 5v5 — pełne boisko', sport: 'koszykówka', max: 10, days: 8, time: '20:00', end: '21:30', cost: 1400, + field: 'Hala Politechnika', lat: 52.4020, lng: 16.9490, + desc: 'Pełnowymiarowe granie 5 na 5 w hali. Gramy na czas z sędzią-amatorem. Średni poziom, dobierzemy brakujących do kompletu.' }, +]; + +async function run() { + // 1) Players to fill rosters with. + const { data: testUsers, error: uErr } = await supabase + .from('profiles') + .select('id, display_name') + .like('email', 'test%@example.com'); + if (uErr) { console.error('✗ Could not read test users:', uErr.message); process.exit(1); } + if (!testUsers || testUsers.length === 0) { + console.error('✗ No test users found. Run `node scripts/seed-test-users.mjs` first.'); + process.exit(1); + } + console.log(`Found ${testUsers.length} test players.`); + + // 2) Wipe existing events (cascades participants/comments/results). + const { error: dErr } = await supabase + .from('events') + .delete() + .neq('id', '00000000-0000-0000-0000-000000000000'); + if (dErr) { console.error('✗ Could not delete events:', dErr.message); process.exit(1); } + console.log('Deleted all existing events.'); + + // 3) Create the demo events, each filled. + let made = 0; + for (let i = 0; i < EVENTS.length; i++) { + const e = EVENTS[i]; + const organizer = testUsers[i % testUsers.length]; + + const { data: row, error: evErr } = await supabase + .from('events') + .insert({ + organizer_id: organizer.id, + organizer_name: organizer.display_name || 'Organizator', + sport: e.sport, + field_id: null, + field_name: e.field, + lat: e.lat, + lng: e.lng, + title: e.title, + description: e.desc, + event_date: dateIn(e.days), + event_time: e.time, + end_time: e.end, + max_players: e.max, + external_count: 0, + visibility: 'public', + team_mode: 'brak', + track_results: e.sport === 'piłka nożna', + cost_grosz: e.cost, + invite_only: false, + }) + .select('id') + .single(); + + if (evErr) { console.error(`✗ ${e.title}:`, evErr.message); continue; } + + // Fill: organizer + a few real players (with avatars), then named guests. + const target = Math.max(2, e.max - randInt(0, 2)); // mostly full, 0–2 free + const others = shuffle(testUsers.filter((u) => u.id !== organizer.id)); + const realCount = Math.min(target - 1, others.length, randInt(3, 8)); + + const rows = []; + // organizer first + rows.push({ user_id: organizer.id, name: organizer.display_name || 'Organizator', is_guest: false }); + for (let k = 0; k < realCount; k++) { + rows.push({ user_id: others[k].id, name: others[k].display_name || 'Gracz', is_guest: false }); + } + // guests fill the rest + const guests = shuffle(GUEST_NAMES); + let g = 0; + while (rows.length < target && g < guests.length) { + rows.push({ user_id: null, name: guests[g++], is_guest: true }); + } + + // One goalkeeper for football matches (≈70% of the time). + const gkIndex = e.sport === 'piłka nożna' && Math.random() < 0.7 ? randInt(0, rows.length - 1) : -1; + + const participantRows = rows.map((r, idx) => ({ + event_id: row.id, + user_id: r.user_id, + name: r.name, + is_guest: r.is_guest, + is_reserve: false, + is_goalkeeper: idx === gkIndex, + status: 'potwierdzony', + })); + + const { error: pErr } = await supabase.from('event_participants').insert(participantRows); + if (pErr) { console.error(` ⚠ participants for "${e.title}":`, pErr.message); } + + made++; + console.log(`✓ ${e.title.padEnd(28)} ${rows.length}/${e.max} graczy · ${dateIn(e.days)} ${e.time}`); + } + + console.log(`\nDone — created ${made} demo matches. Open the app to see a busy feed.\n`); +} + +run().catch((err) => { console.error('\nFatal:', err.message || err); process.exit(1); }); diff --git a/frontend/scripts/seed-test-users.mjs b/frontend/scripts/seed-test-users.mjs new file mode 100644 index 00000000..ebc6177c --- /dev/null +++ b/frontend/scripts/seed-test-users.mjs @@ -0,0 +1,124 @@ +// Seed test accounts for Bojo — realistic Polish names + avatar photos. +// +// Creates ~10 confirmed accounts you can log into straight away (no email +// confirmation needed). Safe to re-run: existing accounts are updated, not +// duplicated. +// +// ── How to run ────────────────────────────────────────────────────────────── +// cd frontend +// SUPABASE_URL="https://YOURPROJECT.supabase.co" \ +// SUPABASE_SERVICE_ROLE_KEY="eyJ...service_role..." \ +// node scripts/seed-test-users.mjs +// +// Get the service_role key in Supabase: Project Settings → API → service_role. +// NEVER commit it or expose it in the browser — it bypasses all security. +// +// Optional: TEST_PASSWORD="..." to override the default password. + +import { createClient } from '@supabase/supabase-js'; + +const SUPABASE_URL = + process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; +const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; +const PASSWORD = process.env.TEST_PASSWORD || 'test1234'; + +if (!SUPABASE_URL || !SERVICE_KEY) { + console.error( + '\n✗ Missing env vars.\n' + + ' Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY, e.g.:\n\n' + + ' SUPABASE_URL="https://xxx.supabase.co" \\\n' + + ' SUPABASE_SERVICE_ROLE_KEY="eyJ..." \\\n' + + ' node scripts/seed-test-users.mjs\n', + ); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +// 10 test players — realistic Polish names + portrait photos (randomuser.me). +const PEOPLE = [ + { email: 'test1@example.com', name: 'Jakub Kowalski', avatar: 'https://randomuser.me/api/portraits/men/32.jpg' }, + { email: 'test2@example.com', name: 'Mateusz Nowak', avatar: 'https://randomuser.me/api/portraits/men/45.jpg' }, + { email: 'test3@example.com', name: 'Piotr Wiśniewski', avatar: 'https://randomuser.me/api/portraits/men/12.jpg' }, + { email: 'test4@example.com', name: 'Kacper Wójcik', avatar: 'https://randomuser.me/api/portraits/men/76.jpg' }, + { email: 'test5@example.com', name: 'Michał Kamiński', avatar: 'https://randomuser.me/api/portraits/men/8.jpg' }, + { email: 'test6@example.com', name: 'Zuzanna Lewandowska', avatar: 'https://randomuser.me/api/portraits/women/44.jpg' }, + { email: 'test7@example.com', name: 'Julia Zielińska', avatar: 'https://randomuser.me/api/portraits/women/68.jpg' }, + { email: 'test8@example.com', name: 'Maja Szymańska', avatar: 'https://randomuser.me/api/portraits/women/21.jpg' }, + { email: 'test9@example.com', name: 'Aleksandra Woźniak', avatar: 'https://randomuser.me/api/portraits/women/33.jpg' }, + { email: 'test10@example.com', name: 'Natalia Dąbrowska', avatar: 'https://randomuser.me/api/portraits/women/57.jpg' }, +]; + +/** Find an existing auth user by email (paginated listUsers). */ +async function findUserByEmail(email) { + for (let page = 1; page <= 10; page++) { + const { data, error } = await supabase.auth.admin.listUsers({ page, perPage: 200 }); + if (error) throw error; + const hit = data.users.find((u) => u.email?.toLowerCase() === email.toLowerCase()); + if (hit) return hit; + if (data.users.length < 200) break; + } + return null; +} + +async function upsertProfile(id, person) { + // The signup trigger creates a profile with display_name, but never sets the + // avatar — so write both here (service role bypasses RLS). + const { error } = await supabase.from('profiles').upsert({ + id, + email: person.email, + display_name: person.name, + avatar_url: person.avatar, + }); + if (error) console.warn(` ⚠ profile upsert failed: ${error.message}`); +} + +async function run() { + console.log(`\nSeeding ${PEOPLE.length} test accounts (password: "${PASSWORD}")\n`); + + for (const person of PEOPLE) { + const meta = { display_name: person.name, avatar_url: person.avatar }; + + const { data, error } = await supabase.auth.admin.createUser({ + email: person.email, + password: PASSWORD, + email_confirm: true, + user_metadata: meta, + }); + + let userId = data?.user?.id; + + if (error) { + // Most likely: already registered → update instead of failing. + const existing = await findUserByEmail(person.email); + if (!existing) { + console.error(`✗ ${person.email} — ${error.message}`); + continue; + } + userId = existing.id; + await supabase.auth.admin.updateUserById(userId, { + password: PASSWORD, + email_confirm: true, + user_metadata: meta, + }); + await upsertProfile(userId, person); + console.log(`↻ ${person.email.padEnd(20)} ${person.name} (updated)`); + continue; + } + + await upsertProfile(userId, person); + console.log(`✓ ${person.email.padEnd(20)} ${person.name}`); + } + + console.log( + `\nDone. Log in with any email above and password "${PASSWORD}".\n` + + 'Tip: use an incognito window per account to stay logged in as several at once.\n', + ); +} + +run().catch((e) => { + console.error('\nFatal:', e.message || e); + process.exit(1); +}); diff --git a/frontend/src/__tests__/eventDates.test.ts b/frontend/src/__tests__/eventDates.test.ts new file mode 100644 index 00000000..c0a33b93 --- /dev/null +++ b/frontend/src/__tests__/eventDates.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { isUpcoming, isEventJoinable, timeUntil, matchWhenLabel } from '@/lib/eventDates'; +import type { EventItem } from '@/types'; + +function fakeEvent(overrides: Partial = {}): EventItem { + return { + id: 'e1', + organizerId: 'u1', + organizerName: 'Jan', + sport: 'piłka nożna', + fieldName: 'Orlik', + date: '2026-01-01', + time: '18:00', + maxPlayers: 10, + visibility: 'public', + createdAt: '2026-01-01T00:00:00Z', + status: 'active', + requireSmsConfirmation: false, + trackAttendance: false, + teamMode: 'brak', + trackPayments: false, + showPaymentStatus: false, + trackResults: false, + ...overrides, + } as EventItem; +} + +function ymd(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} +function addDays(n: number): Date { + const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + n); return d; +} + +describe('isUpcoming', () => { + it('dzisiejsza data jest nadchodząca', () => { + expect(isUpcoming(fakeEvent({ date: ymd(addDays(0)) }))).toBe(true); + }); + it('data w przyszłości jest nadchodząca', () => { + expect(isUpcoming(fakeEvent({ date: ymd(addDays(5)) }))).toBe(true); + }); + it('data w przeszłości nie jest nadchodząca', () => { + expect(isUpcoming(fakeEvent({ date: ymd(addDays(-1)) }))).toBe(false); + }); +}); + +describe('isEventJoinable', () => { + it('mecz po godzinie startu nie jest już do dołączenia', () => { + expect(isEventJoinable(fakeEvent({ date: ymd(addDays(-1)), time: '18:00' }))).toBe(false); + }); + it('mecz w przyszłości jest do dołączenia', () => { + expect(isEventJoinable(fakeEvent({ date: ymd(addDays(3)), time: '18:00' }))).toBe(true); + }); +}); + +describe('timeUntil', () => { + it('zwraca null, gdy mecz jest dalej niż 24h', () => { + expect(timeUntil(ymd(addDays(3)), '18:00')).toBeNull(); + }); + it('zwraca null bez godziny', () => { + expect(timeUntil(ymd(addDays(0)))).toBeNull(); + }); +}); + +describe('matchWhenLabel', () => { + it('dziś', () => { + expect(matchWhenLabel(ymd(addDays(0)), '18:00')).toBe('dziś · 18:00'); + }); + it('jutro', () => { + expect(matchWhenLabel(ymd(addDays(1)), '18:00')).toBe('jutro · 18:00'); + }); + it('w ciągu tygodnia pokazuje dzień tygodnia', () => { + const label = matchWhenLabel(ymd(addDays(3)), '18:00'); + expect(label).toMatch(/^w [a-ząćęłńóśźż]+ · 18:00$/); + }); + it('dalej niż tydzień pokazuje datę', () => { + const label = matchWhenLabel(ymd(addDays(30)), '18:00'); + expect(label).toMatch(/^\d{1,2} [a-ząćęłńóśźż]+ · 18:00$/); + }); +}); diff --git a/frontend/src/__tests__/eventWizard.test.ts b/frontend/src/__tests__/eventWizard.test.ts new file mode 100644 index 00000000..3d2cd0d9 --- /dev/null +++ b/frontend/src/__tests__/eventWizard.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { isPast, validateStep1, validateStep2, validateStep } from '@/lib/eventWizard'; + +describe('isPast', () => { + it('treats a far-future date/time as not past', () => { + expect(isPast('2099-01-01', '18:00')).toBe(false); + }); + + it('treats a far-past date/time as past', () => { + expect(isPast('2000-01-01', '18:00')).toBe(true); + }); + + it('returns false on malformed input instead of throwing', () => { + expect(isPast('', '')).toBe(false); + }); +}); + +describe('validateStep1 (Co i gdzie)', () => { + it('errors when neither a venue nor coordinates are set', () => { + const errs = validateStep1({ venue: null, lat: null }); + expect(errs.location).toBeDefined(); + }); + + it('passes with a venue selected', () => { + const errs = validateStep1({ venue: { id: '1', name: 'Orlik' }, lat: null }); + expect(errs.location).toBeUndefined(); + }); + + it('passes with a raw map pin (lat/lng, no venue)', () => { + const errs = validateStep1({ venue: null, lat: 52.4 }); + expect(errs.location).toBeUndefined(); + }); +}); + +describe('validateStep2 (Kiedy i ile)', () => { + it('errors when date is empty', () => { + const errs = validateStep2('', '18:00'); + expect(errs.date).toBe('Podaj datę meczu.'); + }); + + it('errors when date+time is in the past', () => { + const errs = validateStep2('2000-01-01', '18:00'); + expect(errs.date).toBe('Mecz nie może zaczynać się w przeszłości.'); + }); + + it('passes for a future date', () => { + const errs = validateStep2('2099-01-01', '18:00'); + expect(errs).toEqual({}); + }); +}); + +describe('validateStep (dispatcher used by attemptGoToStep)', () => { + const base = { location: { venue: null, lat: null }, date: '2099-01-01', time: '18:00' }; + + it('step 1 checks location only', () => { + expect(validateStep(1, base).location).toBeDefined(); + }); + + it('step 2 checks date only', () => { + expect(validateStep(2, { ...base, date: '' }).date).toBeDefined(); + }); + + it('step 3 has no required fields', () => { + expect(validateStep(3, base)).toEqual({}); + }); +}); diff --git a/frontend/src/__tests__/events.test.ts b/frontend/src/__tests__/events.test.ts new file mode 100644 index 00000000..be4e0548 --- /dev/null +++ b/frontend/src/__tests__/events.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Hoist mock variables so vi.mock factory can reference them +// --------------------------------------------------------------------------- +const { + mockInsert, mockSelect, mockSingle, mockDelete, mockUpdate, + mockEq, mockOrder, mockLimit, mockMaybeSingle, mockRpc, mockChain, +} = vi.hoisted(() => { + const mockInsert = vi.fn(); + const mockSelect = vi.fn(); + const mockSingle = vi.fn(); + const mockDelete = vi.fn().mockResolvedValue({ error: null }); + const mockUpdate = vi.fn().mockResolvedValue({ error: null }); + const mockEq = vi.fn(); + const mockOrder = vi.fn(); + const mockLimit = vi.fn(); + const mockMaybeSingle = vi.fn(); + // Rate-limit RPC — default to "allowed" so the guarded path runs. + const mockRpc = vi.fn().mockResolvedValue({ data: true, error: null }); + + const mockChain: Record> = { + insert: mockInsert, select: mockSelect, single: mockSingle, + delete: mockDelete, update: mockUpdate, eq: mockEq, + order: mockOrder, limit: mockLimit, maybeSingle: mockMaybeSingle, + }; + + // All methods return the chain so they can be chained further + Object.values(mockChain).forEach((fn) => { + if (fn !== mockDelete && fn !== mockUpdate) { + fn.mockReturnValue(mockChain); + } + }); + + return { + mockInsert, mockSelect, mockSingle, mockDelete, mockUpdate, + mockEq, mockOrder, mockLimit, mockMaybeSingle, mockRpc, mockChain, + }; +}); + +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: vi.fn().mockReturnValue(mockChain), + rpc: mockRpc, + }, +})); + +import { createEvent, joinEvent, removeParticipant, getMyParticipationMap } from '@/lib/events'; + +beforeEach(() => { + vi.clearAllMocks(); + // Restore chain returns — insert chains too (events insert → .select().single()) + Object.values(mockChain).forEach((fn) => { + if (fn !== mockDelete && fn !== mockUpdate) { + (fn as ReturnType).mockReturnValue(mockChain); + } + }); + mockDelete.mockResolvedValue({ error: null }); + mockUpdate.mockResolvedValue({ error: null }); + mockRpc.mockResolvedValue({ data: true, error: null }); +}); + +// --------------------------------------------------------------------------- +// createEvent +// --------------------------------------------------------------------------- +describe('createEvent', () => { + it('inserts event and adds organizer as participant, returns id', async () => { + const fakeId = 'event-uuid-1234'; + mockSingle.mockResolvedValue({ data: { id: fakeId }, error: null }); + + const id = await createEvent( + { + sport: 'piłka nożna', + fieldName: 'Orlik Rataje', + date: '2099-07-01', + time: '18:00', + maxPlayers: 10, + visibility: 'private', + }, + 'organizer-uid', + 'Jan Kowalski', + ); + + expect(id).toBe(fakeId); + const { supabase } = await import('@/lib/supabase'); + expect(supabase.from).toHaveBeenCalledWith('events'); + expect(supabase.from).toHaveBeenCalledWith('event_participants'); + }); + + it('throws when Supabase returns an error', async () => { + mockSingle.mockResolvedValue({ data: null, error: { message: 'DB error' } }); + await expect( + createEvent({ sport: 'futsal', fieldName: 'X', date: '2099-01-01', time: '10:00', maxPlayers: 5, visibility: 'private' }, 'u', 'U'), + ).rejects.toThrow('DB error'); + }); +}); + +// --------------------------------------------------------------------------- +// joinEvent +// --------------------------------------------------------------------------- +describe('joinEvent', () => { + it('adds participant as non-reserve when slots available', async () => { + const { supabase } = await import('@/lib/supabase'); + + // joinEvent queries: + // events → .select().eq().single() → { max_players: 10 } + // event_participants → taken count: .select({count}).eq().eq().eq() + // event_participants → held count: .select({count}).eq().not() + // event_participants → .insert() + vi.mocked(supabase.from).mockImplementation((table: string) => { + if (table === 'events') { + return { + ...mockChain, + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: { max_players: 10 }, error: null }), + }), + }), + } as unknown as ReturnType; + } + // The taken-count query chains three .eq() filters (event_id, is_reserve, + // pending_approval). The held-count query chains .eq() then .not() for + // spots currently offered to someone on the reserve — none here. + const thirdEq = vi.fn().mockResolvedValue({ count: 5, error: null }); + const secondEq = vi.fn().mockReturnValue({ eq: thirdEq }); + const firstEq = vi.fn().mockReturnValue({ + eq: secondEq, + not: vi.fn().mockResolvedValue({ count: 0, error: null }), + }); + return { + ...mockChain, + select: vi.fn().mockReturnValue({ eq: firstEq }), + insert: vi.fn().mockResolvedValue({ error: null }), + } as unknown as ReturnType; + }); + + await expect(joinEvent('event-1', 'user-1', 'Test User')).resolves.toBeUndefined(); + }); + + it('throws when the rate limit is exceeded', async () => { + mockRpc.mockResolvedValue({ data: false, error: null }); + await expect(joinEvent('event-1', 'user-1', 'Test User')).rejects.toThrow(/Zbyt wiele/); + }); +}); + +// --------------------------------------------------------------------------- +// removeParticipant +// --------------------------------------------------------------------------- +describe('removeParticipant', () => { + // removeParticipant: reads event_id, deletes the row, then nudges the reserve + // queue so the freed spot gets offered to the next person. + function mockRemoveChain(onDelete?: () => void, onUpdate?: () => void) { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: { event_id: 'event-1' }, error: null }), + }), + }), + delete: vi.fn().mockReturnValue({ + eq: vi.fn().mockImplementation(() => { onDelete?.(); return Promise.resolve({ error: null }); }), + }), + update: vi.fn().mockImplementation(() => { + onUpdate?.(); + return { eq: vi.fn().mockResolvedValue({ error: null }) }; + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + it('deletes the participant row', async () => { + const { supabase } = await import('@/lib/supabase'); + let deleteWasCalled = false; + vi.mocked(supabase.from).mockImplementation(() => mockRemoveChain(() => { deleteWasCalled = true; })); + + await expect(removeParticipant('participant-1')).resolves.toBeUndefined(); + expect(deleteWasCalled).toBe(true); + }); + + it('does NOT promote a reserve directly — it only asks the queue to offer the spot', async () => { + const { supabase } = await import('@/lib/supabase'); + let updateWasCalled = false; + vi.mocked(supabase.from).mockImplementation(() => mockRemoveChain(undefined, () => { updateWasCalled = true; })); + + await removeParticipant('participant-1'); + + // Nobody is written into the squad here: the spot is merely offered, and + // the reserve has to accept it themselves. + expect(updateWasCalled).toBe(false); + expect(mockRpc).toHaveBeenCalledWith('sync_reserve_claim', { p_event_id: 'event-1' }); + }); +}); + +// --------------------------------------------------------------------------- +// getMyParticipationMap — 'invited' status derived from event_player_invites +// --------------------------------------------------------------------------- +describe('getMyParticipationMap', () => { + it('derives status "invited" for an open invite with no participant row yet', async () => { + const { supabase } = await import('@/lib/supabase'); + vi.mocked(supabase.from).mockImplementation((table: string) => { + if (table === 'event_participants') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + } as unknown as ReturnType; + } + // event_player_invites → .select().eq().is() + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + is: vi.fn().mockResolvedValue({ data: [{ event_id: 'e-invited' }], error: null }), + }), + }), + } as unknown as ReturnType; + }); + + const map = await getMyParticipationMap('user-1'); + expect(map['e-invited']).toBe('invited'); + }); + + it('an existing participant row wins over an open invite for the same event', async () => { + const { supabase } = await import('@/lib/supabase'); + vi.mocked(supabase.from).mockImplementation((table: string) => { + if (table === 'event_participants') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [{ event_id: 'e1', rsvp: null, is_reserve: false, pending_approval: false }], + error: null, + }), + }), + } as unknown as ReturnType; + } + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + is: vi.fn().mockResolvedValue({ data: [{ event_id: 'e1' }], error: null }), + }), + }), + } as unknown as ReturnType; + }); + + const map = await getMyParticipationMap('user-1'); + // Already answered (playing) — the invite is stale context, not the relation. + expect(map['e1']).toBe('playing'); + }); + + it('no invites and no participant rows produces an empty map', async () => { + const { supabase } = await import('@/lib/supabase'); + vi.mocked(supabase.from).mockImplementation((table: string) => { + if (table === 'event_participants') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + } as unknown as ReturnType; + } + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + is: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + }), + } as unknown as ReturnType; + }); + + const map = await getMyParticipationMap('user-1'); + expect(map).toEqual({}); + }); +}); diff --git a/frontend/src/__tests__/landingContent.test.ts b/frontend/src/__tests__/landingContent.test.ts new file mode 100644 index 00000000..f82abfe0 --- /dev/null +++ b/frontend/src/__tests__/landingContent.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { + LANDING_CTA, + LANDING_HERO, + LANDING_STEPS, + LANDING_VALUES, + LANDING_FAQ, + LANDING_FINAL_CTA, + LANDING_STATS, +} from '@/components/home/landing/content'; +import { faqJsonLd } from '@/lib/structuredData'; + +// Every phrase here maps to a feature that is flag-gated or absent — see +// docs/llm-context.md, section "Czego Bojo NIE robi". If one of these words +// legitimately needs to appear (e.g. a feature ships and a flag flips to +// true), update this list in the same commit as a deliberate decision. +const FORBIDDEN_PHRASES = [ + 'cykliczn', // SHOW_RECURRING = false + 'stał[ae] gierk', // same feature, marketing name + 'turniej', // SHOW_CUP = false + 'sms', // SHOW_SMS_FEATURES = false + 'przypomnien', // no scheduler exists + 'powiadom', // no event triggers notifications today + 'alert', // SHOW_GAME_ALERTS = false + 'rezerwacj[aeę] boisk', // FEATURE_RESERVATIONS = false + 'blik', // no payment integration + 'zapłać przez', // no payment integration + 'automatyczn[iy].*(awans|wskocz)', // no reserve auto-promotion, by design + 'ranking', // does not exist + 'poziom(u|ie)? zaawansowania', // does not exist + 'warszaw', // not a covered city by name + 'krak[oó]w', // not a covered city by name +] as const; + +function allLandingText(): string { + return [ + ...LANDING_HERO.badges, + ...LANDING_HERO.h1, + LANDING_HERO.lead, + ...LANDING_HERO.trust, + ...LANDING_STEPS.flatMap((s) => [s.title, s.body]), + ...LANDING_VALUES.flatMap((v) => [v.title, v.body]), + ...LANDING_FAQ.flatMap((f) => [f.q, f.a]), + LANDING_FINAL_CTA.h2, + LANDING_FINAL_CTA.lead, + LANDING_CTA.primary.label, + LANDING_CTA.secondary.label, + LANDING_STATS.sportsValue, + LANDING_STATS.sportsLabel, + LANDING_STATS.timeValue, + LANDING_STATS.timeLabel, + LANDING_STATS.priceValue, + LANDING_STATS.priceLabel, + ].join(' \n ').toLowerCase(); +} + +describe('landing copy — brak obietnic bez pokrycia w kodzie', () => { + const text = allLandingText(); + + for (const phrase of FORBIDDEN_PHRASES) { + it(`nie zawiera frazy pasującej do /${phrase}/`, () => { + expect(new RegExp(phrase, 'i').test(text)).toBe(false); + }); + } +}); + +describe('landing CTA — jedno główne, jedno poboczne', () => { + it('prowadzą do różnych, poprawnych tras', () => { + expect(LANDING_CTA.primary.href).toBe('/wydarzenia/nowe'); + expect(LANDING_CTA.secondary.href).toBe('/wydarzenia'); + expect(LANDING_CTA.primary.href).not.toBe(LANDING_CTA.secondary.href); + }); +}); + +describe('landing H1 — obiecuje tylko to, co dowieziemy', () => { + it('mówi o organizowaniu meczu, nie o zbieraniu 14 osób w 2 minuty', () => { + expect(LANDING_HERO.h1[0]).toBe('Zorganizuj mecz'); + }); +}); + +// Geography rule: the SALES copy (hero, steps, values, stats) speaks about +// capability and stays city-agnostic, because "stwórz mecz gdziekolwiek" +// already works today regardless of how dense the venue catalogue is in any +// one place. The venue catalogue's actual density is a separate, honest fact +// — it belongs only in FAQ, disclosed plainly, not folded into the pitch. +describe('zasięg — Poznań tylko jako uczciwe ujawnienie w FAQ, nie w ofercie', () => { + const salesCopy = [ + ...LANDING_HERO.badges, + ...LANDING_HERO.h1, + LANDING_HERO.lead, + ...LANDING_HERO.trust, + ...LANDING_STEPS.flatMap((s) => [s.title, s.body]), + ...LANDING_VALUES.flatMap((v) => [v.title, v.body]), + LANDING_FINAL_CTA.h2, + LANDING_FINAL_CTA.lead, + LANDING_STATS.sportsValue, + LANDING_STATS.sportsLabel, + LANDING_STATS.timeLabel, + LANDING_STATS.priceLabel, + ].join(' \n ').toLowerCase(); + + it('oferta (hero/kroki/wartości/statystyki) nie wymienia Poznania', () => { + expect(salesCopy).not.toMatch(/poznań|poznania|poznaniu/); + }); + + it('FAQ wciąż uczciwie ujawnia, że katalog jest dziś najgęstszy w Poznaniu', () => { + const geoAnswer = LANDING_FAQ.find((f) => /gdzie działa bojo/i.test(f.q)); + expect(geoAnswer).toBeDefined(); + expect(geoAnswer!.a.toLowerCase()).toMatch(/poznani/); + }); +}); + +describe('FAQ ↔ JSON-LD — zero rozjazdu', () => { + const jsonLd = faqJsonLd(LANDING_FAQ); + + it('ma tyle samo pozycji co widoczna treść', () => { + expect(jsonLd.mainEntity).toHaveLength(LANDING_FAQ.length); + }); + + it('każde pytanie/odpowiedź w schemacie odpowiada źródłu', () => { + jsonLd.mainEntity.forEach((entry, i) => { + expect(entry.name).toBe(LANDING_FAQ[i].q); + expect(entry.acceptedAnswer.text).toBe(LANDING_FAQ[i].a); + }); + }); + + it('każda odpowiedź jest wystarczająco treściwa dla schema (≥40 znaków)', () => { + for (const { a } of LANDING_FAQ) { + expect(a.length).toBeGreaterThanOrEqual(40); + } + }); +}); diff --git a/frontend/src/__tests__/myEvents.test.ts b/frontend/src/__tests__/myEvents.test.ts new file mode 100644 index 00000000..50d521dd --- /dev/null +++ b/frontend/src/__tests__/myEvents.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { splitMyEvents, nextMatch, type MyEventRow } from '@/lib/myEvents'; +import type { EventItem } from '@/types'; +import type { MyEventRelation } from '@/lib/events'; + +function addDays(n: number): Date { + const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + n); return d; +} +function ymd(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function fakeEvent(overrides: Partial = {}): EventItem { + return { + id: overrides.id ?? 'e1', + organizerId: 'u1', + organizerName: 'Jan', + sport: 'piłka nożna', + fieldName: 'Orlik', + date: ymd(addDays(1)), + time: '18:00', + maxPlayers: 10, + visibility: 'public', + createdAt: '2026-01-01T00:00:00Z', + status: 'active', + requireSmsConfirmation: false, + trackAttendance: false, + teamMode: 'brak', + trackPayments: false, + showPaymentStatus: false, + trackResults: false, + ...overrides, + } as EventItem; +} + +function row(event: Partial, relation: Partial = {}): MyEventRow { + return { + event: fakeEvent(event), + relation: { isOrganizer: false, status: 'playing', ...relation }, + }; +} + +// Mirrors the filters app/moje-gry/page.tsx used before switching to splitMyEvents +// — a regression guard for the extraction. +function legacyFilters(items: MyEventRow[]) { + const upcoming = items.filter(({ event }) => event.status !== 'cancelled' && isUpcomingLocal(event)); + const history = items.filter(({ event }) => event.status === 'cancelled' || !isUpcomingLocal(event)); + const playing = upcoming.filter(({ relation }) => relation.status !== 'observing'); + const observing = upcoming.filter(({ relation }) => relation.status === 'observing'); + return { upcoming, history, playing, observing }; +} +function isUpcomingLocal(event: EventItem): boolean { + const [y, m, d] = event.date.split('-').map(Number); + const eventDate = new Date(y, m - 1, d); + const today = new Date(); today.setHours(0, 0, 0, 0); + return eventDate.getTime() >= today.getTime(); +} + +describe('splitMyEvents', () => { + it('odwołany mecz trafia do historii, nie do upcoming', () => { + const items = [row({ id: 'a', date: ymd(addDays(1)), status: 'cancelled' })]; + const { upcoming, history } = splitMyEvents(items); + expect(upcoming).toHaveLength(0); + expect(history).toHaveLength(1); + }); + + it('rozdziela obserwujesz od grasz', () => { + const items = [ + row({ id: 'a' }, { status: 'playing' }), + row({ id: 'b' }, { status: 'observing' }), + ]; + const { playing, observing } = splitMyEvents(items); + expect(playing.map((r) => r.event.id)).toEqual(['a']); + expect(observing.map((r) => r.event.id)).toEqual(['b']); + }); + + it('przeszły mecz trafia do historii', () => { + const items = [row({ id: 'a', date: ymd(addDays(-3)) })]; + const { upcoming, history } = splitMyEvents(items); + expect(upcoming).toHaveLength(0); + expect(history).toHaveLength(1); + }); + + it('zgadza się z dotychczasowymi filtrami z /moje-gry (test regresji)', () => { + const items = [ + row({ id: 'a', date: ymd(addDays(1)) }, { status: 'playing' }), + row({ id: 'b', date: ymd(addDays(2)) }, { status: 'observing' }), + row({ id: 'c', date: ymd(addDays(-1)) }, { status: 'playing' }), + row({ id: 'd', date: ymd(addDays(5)), status: 'cancelled' }, { status: 'playing' }), + ]; + expect(splitMyEvents(items)).toEqual(legacyFilters(items)); + }); +}); + +describe('nextMatch', () => { + it('zwraca najbliższy mecz, nie pierwszy z wejścia (dane przychodzą malejąco)', () => { + // getMyParticipatedEvents() sorts descending — simulate that order. + const items = [ + row({ id: 'far', date: ymd(addDays(10)) }), + row({ id: 'near', date: ymd(addDays(1)) }), + ]; + expect(nextMatch(items)?.event.id).toBe('near'); + }); + + it('pomija obserwowane mecze', () => { + const items = [ + row({ id: 'observed', date: ymd(addDays(1)) }, { status: 'observing' }), + row({ id: 'playing', date: ymd(addDays(3)) }, { status: 'playing' }), + ]; + expect(nextMatch(items)?.event.id).toBe('playing'); + }); + + it('pomija odwołane i przeszłe mecze', () => { + const items = [ + row({ id: 'cancelled', date: ymd(addDays(1)), status: 'cancelled' }), + row({ id: 'past', date: ymd(addDays(-2)) }), + ]; + expect(nextMatch(items)).toBeNull(); + }); + + it('zwraca null dla pustego wejścia', () => { + expect(nextMatch([])).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/sessionHint.test.ts b/frontend/src/__tests__/sessionHint.test.ts new file mode 100644 index 00000000..a77309b8 --- /dev/null +++ b/frontend/src/__tests__/sessionHint.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { SESSION_HINT_COOKIE, setHintCookie, clearHintCookie } from '@/lib/sessionHint'; + +describe('setHintCookie', () => { + const cookie = setHintCookie(false); + + it('ustawia wartość na literalne "1" — bez tokenu', () => { + expect(cookie).toContain(`${SESSION_HINT_COOKIE}=1`); + }); + + it('nie zawiera niczego, co wygląda na token JWT albo UUID', () => { + // JWTs are dot-separated base64url segments; UUIDs have dashes in a fixed + // pattern. Neither should appear anywhere in the cookie string. + expect(cookie).not.toMatch(/[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/); + expect(cookie).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); + }); + + it('ma Path=/, SameSite=Lax i dodatni Max-Age', () => { + expect(cookie).toContain('Path=/'); + expect(cookie).toContain('SameSite=Lax'); + const maxAge = Number(cookie.match(/Max-Age=(\d+)/)?.[1]); + expect(maxAge).toBeGreaterThan(0); + }); + + it('dokłada Secure tylko gdy poproszono', () => { + expect(setHintCookie(true)).toContain('Secure'); + expect(setHintCookie(false)).not.toContain('Secure'); + }); +}); + +describe('clearHintCookie', () => { + it('ma Max-Age=0', () => { + expect(clearHintCookie(false)).toContain('Max-Age=0'); + }); + + it('czyści wartość ciasteczka', () => { + expect(clearHintCookie(false)).toContain(`${SESSION_HINT_COOKIE}=;`); + }); + + it('dokłada Secure tylko gdy poproszono', () => { + expect(clearHintCookie(true)).toContain('Secure'); + expect(clearHintCookie(false)).not.toContain('Secure'); + }); +}); diff --git a/frontend/src/__tests__/slugify.test.ts b/frontend/src/__tests__/slugify.test.ts new file mode 100644 index 00000000..9c49422d --- /dev/null +++ b/frontend/src/__tests__/slugify.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { slugify, isUuid } from '@/lib/utils'; + +describe('slugify', () => { + it('converts basic name to slug', () => { + expect(slugify('Orlik Rataje')).toBe('orlik-rataje'); + }); + + it('strips Polish diacritics', () => { + expect(slugify('piłka nożna')).toBe('pilka-nozna'); + expect(slugify('siatkówka plażowa')).toBe('siatkowka-plazowa'); + expect(slugify('Hala Ząbkowska')).toBe('hala-zabkowska'); + }); + + it('handles ł correctly', () => { + expect(slugify('Łódź')).toBe('lodz'); + expect(slugify('piłka ręczna')).toBe('pilka-reczna'); + }); + + it('collapses multiple spaces and special chars into single dash', () => { + expect(slugify('ul. Dąbrowskiego 79A')).toBe('ul-dabrowskiego-79a'); + }); + + it('trims leading and trailing dashes', () => { + expect(slugify(' Boisko ')).toBe('boisko'); + }); + + it('is deterministic (same input → same output)', () => { + const name = 'Boisko Sportowe Reymonta'; + expect(slugify(name)).toBe(slugify(name)); + }); +}); + +describe('isUuid', () => { + it('recognises valid UUIDs', () => { + expect(isUuid('b1a2c3d4-0001-0001-0001-000000000001')).toBe(true); + expect(isUuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + + it('rejects slugs', () => { + expect(isUuid('orlik-rataje')).toBe(false); + expect(isUuid('pilka-nozna')).toBe(false); + }); + + it('rejects partial or malformed UUIDs', () => { + expect(isUuid('b1a2c3d4-0001')).toBe(false); + expect(isUuid('')).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/structuredData.test.ts b/frontend/src/__tests__/structuredData.test.ts new file mode 100644 index 00000000..f6c9d38d --- /dev/null +++ b/frontend/src/__tests__/structuredData.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { breadcrumbsJsonLd, eventJsonLd, siteJsonLd, venueListJsonLd, type EventForJsonLd } from '@/lib/structuredData'; + +const BASE = 'https://bojo.pl'; + +function publicEvent(overrides: Partial = {}): EventForJsonLd { + return { + sport: 'piłka nożna', + date: '2026-09-12', + time: '18:00', + visibility: 'public', + ...overrides, + }; +} + +describe('eventJsonLd — widoczność', () => { + it('nie emituje niczego dla meczu prywatnego', () => { + expect(eventJsonLd('abc', publicEvent({ visibility: 'private' }), BASE)).toBeNull(); + }); + + it('nie emituje niczego dla nieznanej wartości visibility', () => { + expect(eventJsonLd('abc', publicEvent({ visibility: 'group' }), BASE)).toBeNull(); + }); + + it('emituje SportsEvent dla meczu publicznego', () => { + const out = eventJsonLd('abc', publicEvent(), BASE); + expect(out).not.toBeNull(); + expect(out!['@type']).toBe('SportsEvent'); + expect(out!.url).toBe(`${BASE}/wydarzenia/abc`); + }); +}); + +describe('eventJsonLd — pola', () => { + it('składa startDate z daty i godziny', () => { + const out = eventJsonLd('abc', publicEvent(), BASE)!; + expect(out.startDate).toBe('2026-09-12T18:00'); + }); + + it('bez godziny zostawia samą datę', () => { + const out = eventJsonLd('abc', publicEvent({ time: undefined }), BASE)!; + expect(out.startDate).toBe('2026-09-12'); + expect(out.endDate).toBeUndefined(); + }); + + it('dodaje endDate tylko gdy jest end_time', () => { + const out = eventJsonLd('abc', publicEvent({ end_time: '20:00' }), BASE)!; + expect(out.endDate).toBe('2026-09-12T20:00'); + }); + + it('oznacza odwołany mecz jako EventCancelled', () => { + const out = eventJsonLd('abc', publicEvent({ status: 'cancelled' }), BASE)!; + expect(out.eventStatus).toBe('https://schema.org/EventCancelled'); + }); + + it('domyślnie mecz jest EventScheduled', () => { + const out = eventJsonLd('abc', publicEvent(), BASE)!; + expect(out.eventStatus).toBe('https://schema.org/EventScheduled'); + }); + + it('używa nazwy własnej, a w jej braku sportu z wielkiej litery', () => { + expect(eventJsonLd('a', publicEvent({ title: 'Wtorkowa ligówka' }), BASE)!.name) + .toBe('Wtorkowa ligówka'); + expect(eventJsonLd('a', publicEvent(), BASE)!.name).toBe('Piłka nożna'); + }); + + it('przelicza grosze na złote w ofercie', () => { + const out = eventJsonLd('abc', publicEvent({ cost_grosz: 2550 }), BASE)!; + expect(out.offers).toMatchObject({ price: '25.50', priceCurrency: 'PLN' }); + expect(out.isAccessibleForFree).toBe(false); + }); + + it('mecz za darmo jest oznaczony jako bezpłatny', () => { + const out = eventJsonLd('abc', publicEvent({ cost_grosz: 0 }), BASE)!; + expect(out.isAccessibleForFree).toBe(true); + }); + + it('bez kosztu nie emituje oferty', () => { + const out = eventJsonLd('abc', publicEvent(), BASE)!; + expect(out.offers).toBeUndefined(); + expect(out.isAccessibleForFree).toBeUndefined(); + }); + + it('bierze nazwę boiska, a w jej braku lokalizację własną', () => { + const withField = eventJsonLd('a', publicEvent({ field_name: 'Orlik Rataje' }), BASE)!; + expect(withField.location).toMatchObject({ name: 'Orlik Rataje' }); + + const withCustom = eventJsonLd( + 'a', + publicEvent({ custom_location_name: 'Boisko za blokiem', custom_address: 'ul. Kwiatowa 3' }), + BASE, + )!; + expect(withCustom.location).toMatchObject({ + name: 'Boisko za blokiem', + address: { streetAddress: 'ul. Kwiatowa 3', addressLocality: 'Poznań' }, + }); + }); + + it('bez miejsca nie emituje location', () => { + expect(eventJsonLd('a', publicEvent(), BASE)!.location).toBeUndefined(); + }); +}); + +describe('siteJsonLd', () => { + it('wiąże WebSite z Organization przez @id', () => { + const graph = siteJsonLd(BASE)['@graph'] as Record[]; + const org = graph.find((n) => n['@type'] === 'Organization')!; + const site = graph.find((n) => n['@type'] === 'WebSite')!; + expect(site.publisher).toEqual({ '@id': org['@id'] }); + }); +}); + +describe('breadcrumbsJsonLd', () => { + it('numeruje pozycje od 1 i buduje absolutne adresy', () => { + const out = breadcrumbsJsonLd([ + { name: 'Strona główna', path: '/' }, + { name: 'Boiska: Piłka nożna', path: '/boiska/pilka-nozna' }, + { name: 'Orlik Rataje' }, + ], BASE); + expect(out['@type']).toBe('BreadcrumbList'); + expect(out.itemListElement[0]).toMatchObject({ position: 1, item: `${BASE}/` }); + expect(out.itemListElement[1]).toMatchObject({ position: 2, item: `${BASE}/boiska/pilka-nozna` }); + }); + + it('ostatni element (bieżąca strona) nie ma item — zgodnie ze spec Google', () => { + const out = breadcrumbsJsonLd([{ name: 'A', path: '/' }, { name: 'Bieżąca' }], BASE); + const last = out.itemListElement[1] as Record; + expect(last.name).toBe('Bieżąca'); + expect(last.item).toBeUndefined(); + }); + + it('pusta lista nie wywraca budowania', () => { + expect(breadcrumbsJsonLd([], BASE).itemListElement).toEqual([]); + }); +}); + +describe('venueListJsonLd', () => { + it('numeruje pozycje od 1 i buduje pełne adresy', () => { + const out = venueListJsonLd('Boiska', [ + { name: 'Orlik A', slug: 'orlik-a' }, + { name: 'Orlik B', slug: 'orlik-b' }, + ], BASE); + expect(out.numberOfItems).toBe(2); + expect(out.itemListElement[0]).toMatchObject({ position: 1, url: `${BASE}/boisko/orlik-a` }); + expect(out.itemListElement[1]).toMatchObject({ position: 2, url: `${BASE}/boisko/orlik-b` }); + }); + + it('pusta lista nie wywraca budowania', () => { + const out = venueListJsonLd('Boiska', [], BASE); + expect(out.numberOfItems).toBe(0); + expect(out.itemListElement).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/validation.test.ts b/frontend/src/__tests__/validation.test.ts new file mode 100644 index 00000000..d82acee7 --- /dev/null +++ b/frontend/src/__tests__/validation.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { + sanitizeText, + validateName, + sanitizeDescription, + sanitizeAddress, + validatePhone, + normalizePhone, +} from '@/lib/validation'; + +describe('sanitizeText', () => { + it('strips HTML tags', () => { + expect(sanitizeText('Orlik')).toBe('Orlik'); + expect(sanitizeText('Boisko')).toBe('alert(1)Boisko'); + expect(sanitizeText('')).toBe(''); + }); + + it('strips ASCII control characters', () => { + expect(sanitizeText('Orlik\x00\x07Rataje')).toBe('OrlikRataje'); + }); + + it('trims surrounding whitespace', () => { + expect(sanitizeText(' Boisko ')).toBe('Boisko'); + }); + + it('clamps to the given max length', () => { + expect(sanitizeText('abcdefghij', 5)).toBe('abcde'); + }); + + it('keeps Polish diacritics intact', () => { + expect(sanitizeText('Piłka nożna ążśćź')).toBe('Piłka nożna ążśćź'); + }); + + it('returns empty string for tag-only input', () => { + expect(sanitizeText('
')).toBe(''); + }); +}); + +describe('validateName', () => { + it('returns the sanitized name for valid input', () => { + expect(validateName(' Orlik Rataje ')).toBe('Orlik Rataje'); + expect(validateName('Hala')).toBe('Hala'); + }); + + it('throws when the name is empty or whitespace only', () => { + expect(() => validateName('')).toThrow('Nazwa jest wymagana.'); + expect(() => validateName(' ')).toThrow('Nazwa jest wymagana.'); + }); + + it('throws when only HTML tags are supplied', () => { + expect(() => validateName('
')).toThrow(/wymagana/); + }); + + it('uses a custom label in the error message', () => { + expect(() => validateName('', 'Tytuł')).toThrow('Tytuł jest wymagana.'); + }); + + it('truncates to the max length rather than throwing', () => { + const longName = 'a'.repeat(200); + expect(validateName(longName, 'Nazwa', 100)).toHaveLength(100); + }); +}); + +describe('sanitizeDescription', () => { + it('allows up to 1000 characters', () => { + expect(sanitizeDescription('x'.repeat(1500))).toHaveLength(1000); + }); + + it('never throws on empty input', () => { + expect(sanitizeDescription('')).toBe(''); + }); +}); + +describe('sanitizeAddress', () => { + it('allows up to 300 characters', () => { + expect(sanitizeAddress('x'.repeat(400))).toHaveLength(300); + }); + + it('strips tags from an address', () => { + expect(sanitizeAddress('ul. Dąbrowskiego 79')).toBe('ul. Dąbrowskiego 79'); + }); +}); + +describe('validatePhone', () => { + it('accepts 9 bare digits', () => { + expect(validatePhone('501234567')).toBe(true); + }); + + it('accepts +48 prefix', () => { + expect(validatePhone('+48501234567')).toBe(true); + }); + + it('ignores spaces and dashes', () => { + expect(validatePhone('501 234 567')).toBe(true); + expect(validatePhone('501-234-567')).toBe(true); + expect(validatePhone('+48 501-234-567')).toBe(true); + }); + + it('rejects too short / too long numbers', () => { + expect(validatePhone('12345')).toBe(false); + expect(validatePhone('5012345678')).toBe(false); + }); + + it('rejects non-digit content', () => { + expect(validatePhone('abcdefghi')).toBe(false); + expect(validatePhone('')).toBe(false); + }); +}); + +describe('normalizePhone', () => { + it('adds +48 to a bare 9-digit number', () => { + expect(normalizePhone('501234567')).toBe('+48501234567'); + }); + + it('keeps an existing +48 prefix', () => { + expect(normalizePhone('+48501234567')).toBe('+48501234567'); + }); + + it('strips spaces and dashes before normalizing', () => { + expect(normalizePhone('501 234 567')).toBe('+48501234567'); + expect(normalizePhone('+48 501-234-567')).toBe('+48501234567'); + }); +}); diff --git a/frontend/src/app/admin/[fieldId]/page.tsx b/frontend/src/app/admin/[fieldId]/page.tsx new file mode 100644 index 00000000..1bca024e --- /dev/null +++ b/frontend/src/app/admin/[fieldId]/page.tsx @@ -0,0 +1,454 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Lock, CalendarDays } from 'lucide-react'; +import Header from '@/components/layout/Header'; +import Button from '@/components/ui/Button'; +import { useAuth } from '@/lib/auth'; +import { useAdmin } from '@/lib/admin'; +import { getField, updateFieldBookingSettings } from '@/lib/api'; +import { getFieldBookings, updateBookingStatus } from '@/lib/bookings'; +import type { Field, Booking, BookingType } from '@/types'; + +type BookingWithField = Booking & { fieldName: string }; + +type Tab = 'rezerwacje' | 'ustawienia'; + +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 todayString(): string { + return new Date().toISOString().slice(0, 10); +} + +function StatusBadge({ status }: { status: Booking['status'] }) { + const map: Record = { + pending: { label: 'Oczekujące', cls: 'bg-amber-100 text-amber-700' }, + confirmed: { label: 'Potwierdzone', cls: 'bg-green-100 text-green-700' }, + cancelled: { label: 'Anulowane', cls: 'bg-slate-100 text-slate-500' }, + }; + const { label, cls } = map[status]; + return ( + + {label} + + ); +} + +export default function AdminFieldPage() { + const { fieldId } = useParams<{ fieldId: string }>(); + const { user, loading: authLoading } = useAuth(); + const isAdmin = useAdmin(); + + const [pageLoading, setPageLoading] = useState(true); + const [notAllowed, setNotAllowed] = useState(false); + const [field, setField] = useState(null); + const [tab, setTab] = useState('rezerwacje'); + + // --- Rezerwacje tab --- + const [selectedDate, setSelectedDate] = useState(todayString()); + const [allBookings, setAllBookings] = useState([]); + const [bookingsLoading, setBookingsLoading] = useState(false); + const [busy, setBusy] = useState>(new Set()); + + // --- Ustawienia tab --- + const [bookingType, setBookingType] = useState('none'); + const [bookingUrl, setBookingUrl] = useState(''); + const [bookingEnabled, setBookingEnabled] = useState(false); + const [saving, setSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const [saveError, setSaveError] = useState(null); + + const loadBookings = useCallback(async () => { + setBookingsLoading(true); + try { + const data = await getFieldBookings(fieldId); + setAllBookings(data); + } finally { + setBookingsLoading(false); + } + }, [fieldId]); + + useEffect(() => { + if (authLoading) return; + if (!user) { + setNotAllowed(true); + setPageLoading(false); + return; + } + + getField(fieldId) + .then((f) => { + if (f.managerId !== user.id && !isAdmin) { + setNotAllowed(true); + return; + } + setField(f); + setBookingType(f.bookingType); + setBookingUrl(f.bookingUrl ?? ''); + setBookingEnabled(f.bookingEnabled); + }) + .catch(() => setNotAllowed(true)) + .finally(() => setPageLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user, isAdmin]); + + useEffect(() => { + if (!field) return; + loadBookings(); + }, [field, loadBookings]); + + const bookingsForDate = allBookings.filter((b) => b.date === selectedDate); + + const handleStatus = async (bookingId: string, status: 'confirmed' | 'cancelled') => { + setBusy((prev) => new Set(prev).add(bookingId)); + try { + await updateBookingStatus(bookingId, status); + await loadBookings(); + } catch { + // silently ignore + } finally { + setBusy((prev) => { + const next = new Set(prev); + next.delete(bookingId); + return next; + }); + } + }; + + const handleSaveSettings = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setSaveSuccess(false); + setSaveError(null); + try { + await updateFieldBookingSettings( + fieldId, + bookingType, + bookingType === 'external' ? bookingUrl.trim() || undefined : undefined, + bookingEnabled, + ); + setSaveSuccess(true); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Nie udało się zapisać ustawień'); + } finally { + setSaving(false); + } + }; + + // --- Loading skeleton --- + if (authLoading || pageLoading) { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); + } + + // --- Not logged in --- + if (!user) { + return ( +
+
+
+
+ +

Musisz być zalogowany

+

Zaloguj się, aby uzyskać dostęp do panelu.

+ + Wróć do mapy + +
+
+
+ ); + } + + // --- Access denied --- + if (notAllowed) { + return ( +
+
+
+
+ +

Brak dostępu

+

+ Ta strona jest dostępna tylko dla menedżera obiektu lub administratorów. +

+ + Wróć do obiektów + +
+
+
+ ); + } + + return ( +
+
+
+ {/* Header row */} +
+ + + +
+

+ {field?.name ?? 'Panel rezerwacji'} +

+

Panel zarządzania obiektem

+
+
+ + {/* Tabs */} +
+ {(['rezerwacje', 'ustawienia'] as Tab[]).map((t) => ( + + ))} +
+ + {/* === Tab: Rezerwacje === */} + {tab === 'rezerwacje' && ( +
+ {/* Date picker */} +
+ + 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/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 */} +
+ +