13-årsgrense — håndhevet server-side (eneste vei inn i appen, ADR-051) og med umiddelbar rød feilmelding klientside i begge profil-skjemaer. Testet på nøyaktig dagsgrensen.

PWA-installasjonstekst — skiller nå Safari/Chrome på iOS, ikke lenger feilaktig Safari-only.
Beskrivelsestekst — "golfklubber" fjernet, nytt punkt om at turneringsadmin/-presentasjon fungerer godt på PC.
Dette krever både teecup_api og teecup_frontend (ingen migrasjon).
This commit is contained in:
Erol Haagenrud 2026-08-10 07:00:11 +02:00
parent 26313c8017
commit 2562e20ec3
23 changed files with 1417 additions and 292 deletions

View file

@ -451,7 +451,21 @@
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zero_dist.json -w '\\\\nHTTP: %{http_code}\\\\n' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/s19_jar.txt -X POST http://172.18.0.11:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"zerodist@example.com\",\"locale\":\"nb\"}')", "Bash(curl -s -o /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zero_dist.json -w '\\\\nHTTP: %{http_code}\\\\n' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/s19_jar.txt -X POST http://172.18.0.11:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"zerodist@example.com\",\"locale\":\"nb\"}')",
"Bash(awk -F: '$1 >= 2047 && $1 <= 2260')", "Bash(awk -F: '$1 >= 2047 && $1 <= 2260')",
"Bash(curl -s -o /dev/null -w 'HTTP: %{http_code}\\\\n' -H 'Referer: https://teecup.golf/' https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=pk.eyJ1IjoiZXJvbGhhIiwiYSI6ImNtc2s5Z2VsajByYTgyeHMya2JteXhoZTEifQ.NlHI8NpiYTLh9PBzpSXyUw)", "Bash(curl -s -o /dev/null -w 'HTTP: %{http_code}\\\\n' -H 'Referer: https://teecup.golf/' https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=pk.eyJ1IjoiZXJvbGhhIiwiYSI6ImNtc2s5Z2VsajByYTgyeHMya2JteXhoZTEifQ.NlHI8NpiYTLh9PBzpSXyUw)",
"Bash(sort -t/ -k4 -n)" "Bash(sort -t/ -k4 -n)",
"Bash(echo \"building in bg, pid $!\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18124/)",
"Bash(xargs -I{} sh -c 'echo \"--- {} ---\"; head -3 \"{}\"')",
"Bash(grep -n \"</main>\\\\|</div>$\" frontend/components/teecup/velkommen.tsx)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18126/)",
"Bash(xargs -I{} sh -c 'echo \"--- {} ---\"')",
"Bash(grep -iE \"\\\\.env$|\\\\.pem$|secret|credential\")",
"Bash(grep -B3 \": str$\")",
"Bash(python3 -m py_compile app/rate_limit.py app/routers/auth.py)",
"Bash(curl -sI https://teecup.golf/)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.golf/)",
"Bash(echo \"EXIT CODE: $?\")",
"Bash(python3 -m json.tool /tmp/adapted.json)",
"Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -4696,6 +4696,70 @@ endret beslutning får et tillegg, ikke en retusjert original.
`start_method=gps, end_method=map_tap` i databasen, verifisert med `start_method=gps, end_method=map_tap` i databasen, verifisert med
direkte SQL). Se CHANGELOG.md for full detalj. direkte SQL). Se CHANGELOG.md for full detalj.
**Tillegg 2026-08-08 (del 2) — ball-steget viser nå ALLTID kartet, med
løpende (sanntids) posisjon og avstand, og allerede målte slag viser nå
satellittfoto i listen.** Umiddelbart etter tillegget over, bruker: "Jeg
får ikke sett slagene jeg allerede har målt... jeg ønsker å kunne se på et
satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om jeg
velger 'min posisjon nå', så skal jeg se start og slutt på et
satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se lengden
så langt i sanntid mens jeg nærmer meg ballen." Dette endrer Beslutning B
sitt "kart lastes kun ved eksplisitt 'velg punkt på kart'"-prinsipp
BEVISST for ball-steget spesifikt (fortsatt uendret for start-steget):
- **Ball-steget slo sammen "GPS vs. kart"-valget til ÉN alltid-synlig
kart-visning.** Den forrige separate GPS-only-idle-skjermen (bare en
"Jeg er ved ballen nå"-knapp, ingen kart) og det egne `end_map`-steget
er fjernet -- `MapPointPicker` rendres nå direkte når "end"-steget nås,
alltid med `referencePoint=startPoint`. `MapPointPicker` viste
opprinnelig BEGGE bekreftelsesknappene når `referencePoint` var satt:
"Bekreft ballens posisjon" (trykket punkt, `end_method: "map_tap"`) og
"Jeg er ved ballen nå" (siste sporede posisjon, `end_method: "gps"`).
**Korrigert 2026-08-09** (samme dag, etter faktisk bruk): bruker,
"bekreft ballens posisjon er unødvendig" -- trykk-for-å-plassere-ballen
fjernet igjen. Ball-steget bekrefter nå UTELUKKENDE via sporet
GPS-posisjon; kartet der er rent informativt (referansepunkt +
sanntidsposisjon/-avstand), ikke lenger klikkbart for plassering.
Backendens `end_method`-kolonne/CHECK aksepterer fortsatt begge verdier
uendret (historiske `map_tap`-rader fra den korte perioden dette var
aktivt skal fortsatt leses korrekt) -- kun frontend-UI-et endret, ingen
migrasjon. Start-steget er UPÅVIRKET: trykk-for-å-plassere fungerer der
fortsatt akkurat som før.
- **Løpende posisjonssporing (`watchPosition`), kun på ball-steget.**
Dette er et bevisst, avgrenset unntak fra "ingen løpende watchPosition"
-- den opprinnelige begrunnelsen for det forbudet var KART-
lastnings-kostnad (Mapbox fakturerer initialisering), og `watchPosition`
i seg selv utløser ALDRI en ny kartinitialisering eller et nytt
Mapbox-API-kall -- det er ren nettleser-GPS som kun oppdaterer en
eksisterende markørs posisjon på det ALLEREDE lastede kartet. Samme
ETT-kart-instans-prinsipp står dermed fortsatt fast; kun selve
markør-/avstandsdataene oppdateres kontinuerlig. `watchPosition` ryddes
opp (`clearWatch`) når komponenten avmonteres.
- **Sanntids-avstand** vises som et stort tall nederst på kartet,
beregnet med `haversineMeters(referencePoint, livePosition)` (samme
rene funksjon som allerede fantes i `frontend/lib/geo.ts`, tidligere
brukt kun for forhåndsvisningen -- `shot-measurement-sheet.tsx` sin
egen duplikate Haversine-implementasjon fjernet til fordel for denne,
ren opprydding uten atferdsendring).
- **Kostnadskonsekvens, eksplisitt notert:** ball-steget laster nå
Mapbox-kartet for HVERT målt slag, uansett om brukeren til slutt
bekrefter via GPS eller kart-trykk (siden kartet uansett vises for at
brukeren skal se referansepunktet + sanntids-avstand mens de går).
Start-steget er UENDRET (kart lastes fortsatt kun ved eksplisitt "velg
punkt på kart") -- kun halvparten av et målt slags to punkter utløser nå
alltid en kartlastning. Vurdert og akseptert: 50 000 gratis
kartlastninger/mnd (Beslutning A) gir god margin for forventet volum.
- **Allerede målte slag i listen** (`round-detail.tsx`, `ShotList`) viser
nå et lite satellitt-thumbnail (160×160, samme offentlige, URL-
restrikterte token og samme `pin-s-a`/`pin-s-b`-fargekonvensjon som
forhåndsvisningen) bygget direkte fra `ShotRecord`s allerede-returnerte
`start_lat`/`start_lng`/`end_lat`/`end_lng` (utvidet fra kun
`id`/`club`/`distance_meters`/`shared_round_message_id`) -- løser "jeg
burde jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke
bare klubbe/avstand som tekst, men faktisk HVOR slaget ble slått. Ingen
backend-endring nødvendig -- `ShotOut` returnerte allerede alle fire
koordinatene, kun frontend-typen/rendringen manglet dem.
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn` **Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share), og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
@ -4729,6 +4793,257 @@ Beslutning B).
og mål på nytt i stedet), en historisk "vis alle slag på kart etter og mål på nytt i stedet), en historisk "vis alle slag på kart etter
runden"-visualisering. runden"-visualisering.
## ADR-049: `/velkommen` — landingsside for innlogget-men-ikke-fullført-profil
Reist av brukeren 2026-08-09: "Skjemaet med personlig informasjon vises
for tidlig for ikke registrerte spillere etter at de logger seg inn...
Jeg tror det beste er at man kommer til en side med oppfordring til å
installere som app (som vanlig) og med deaktiverte knapper for runde,
turneringen og bli med med kode. Trykker man på noen av disse skal man
få beskjed om at personlig informasjon må fylles ut først, med lenke til
skjemaet. Under dette bør TeeCup presenteres." Bekreftet: ny, avgrenset
side (ikke en full erstatning av `/dashboard`, se drøftingen i chatten)
— dashbordet for FULLFØRT profil er uendret.
**Problemet:** en innlogget bruker med ufullstendig profil ble tidligere
sendt RETT til `/account` sitt påtvungne skjema ("Fullfør profilen din"),
uten noen kontekst om hva de nettopp logget seg inn på. Brukeren
observerte dette som en reell feil i praksis (antas: forvirring/frafall),
ikke bare en teoretisk innvending.
**Løsning: ny side `frontend/app/velkommen/page.tsx` +
`frontend/components/teecup/velkommen.tsx`,** satt inn i redirect-kjeden
mellom innlogging og `/account`:
- `frontend/app/page.tsx` og `frontend/app/logg-inn/page.tsx` sine
server-side redirects, OG `dashboard.tsx` sin klient-side
`profile_complete`-vakt (`loadMe()`), peker nå til `/velkommen` i
stedet for `/account` når profilen er ufullstendig. `/velkommen` selv
redirecter videre til `/dashboard` (komplett profil) eller `/logg-inn`
(ikke innlogget) — kan altså ikke nås "feil" via direkte URL.
- Siden viser, i rekkefølge: TeeCup-ordmerke + "Logg ut", en
personlig hilsen (`me.first_name ?? me.display_name`, samme
navneformat-regel som dashbordets hilsen, CLAUDE.md), den
eksisterende `InstallPrompt`-komponenten UENDRET (samme PWA-
oppfordring som dashbordet allerede bruker), en primær "Fullfør
profilen din"-CTA, TRE synlige men visuelt låste hurtighandlinger
("Ny runde"/"Ny turnering"/"Bli med med kode" — hengelås-ikon,
`aria-disabled`, IKKE HTML `disabled` siden de fortsatt skal være
klikkbare for å forklare hvorfor), og en presentasjon av TeeCup
(innhold hentet fra `teecup-beskrivelse.md`, skrevet om til kort
UI-tekst — ikke limt inn rått som markdown).
- Trykk på en låst handling viser en delt påminnelse
(`role="alert"`, skjermleser-varslet automatisk) med lenke videre til
`/account`, i stedet for å navigere — samme "forklar, ikke bare
blokkér"-prinsipp brukeren ba om.
- Bygget med samme "clubhouse"-palett-tokens (`--tee-strong`,
`--clubhouse-*`) og samme `TeeCupWordmark`/`InstallPrompt`-komponenter
som `/logg-inn` og det allerede reskinnede dashbordet (2026-08-08,
se CHANGELOG.md) — ikke funnet opp på nytt, gjenbruker den etablerte
retningen for akkurat denne delen av appen.
**Bevisst avgrenset omfang:** ingen ny server-side håndheving av
`profile_complete` er lagt til på andre sider (`/my-rounds`,
`/my-friends` osv. sjekker i dag kun innlogging, ikke profil-status —
en pre-eksisterende, ikke relatert inkonsistens, ikke rørt her). Kun de
tre inngangspunktene som faktisk styrte hvor en ufullstendig-profil-
bruker havnet (root, `/logg-inn`, dashbordets egen klient-vakt) er
endret.
## ADR-050: Sikkerhetsgjennomgang 2026-08-09 — rate limiting, sikkerhetshoder, inputvalidering
Brukeren ba om en full sikkerhetsgjennomgang ("sjekk alle felter som kan
fylles ut, og sjekk alle URL-er... lar siden/appen seg hacke?"). En
dedikert agent gjennomgikk autentisering, HELE autorisasjonslaget på
tvers av 16 routere, input-validering, filopplasting, CORS/nettverk og
hemmeligheter i git-historikken. Konklusjon: autorisasjons-/IDOR-laget er
uvanlig grundig og konsekvent (ingen bekreftet IDOR i noe skrive-
endepunkt) — de reelle funnene var manglende rate limiting (HØY),
manglende sikkerhetshoder (LAV-MIDDELS), inkonsekvent input-validering
(LAV), og én liten informasjonslekkasje i ett BBB-endepunkt
(informativt). Bruker: "tett sikkerhetshullene først."
**Beslutning A — Rate limiting, i minnet, ikke Redis (ennå).** Ny
`app/rate_limit.py`: en enkel fast-vindu-teller (`RateLimiter`) brukt
enten som FastAPI-dependency (IP-basert, via `client_ip()` som stoler på
`X-Forwarded-For` — trygt KUN fordi appen ikke er nåbar unntatt gjennom
Caddy, samme tillitsmodell som `should_use_secure_cookies()`) eller kalt
direkte med en egendefinert nøkkel. Lagt til på fem `app/routers/auth.py`
-endepunkter: `request-link`/`login-password`/`2fa/email/request`
(IP-basert, 5-10 forsøk/10 min — ressursen er ikke entydig knyttet til én
konto), og `2fa/verify`/`2fa/setup/confirm` (nøkkel = pending-/innlogget
bruker-ID, 8 forsøk/5 min — her ER ressursen én bestemt kontos kode, en
angriper med en gyldig pending-sesjon kunne ellers omgått IP-basert
begrensning ved å bytte IP). Trygt i minnet KUN fordi `teecup_api` kjører
som ÉN uvicorn-prosess (ingen `--workers`-flagg) — samme kjente
begrensning som andre in-memory-cacher i appen (se punkt 2 i listen
under). Ved fremtidig skalering til flere workers/containere MÅ dette
flyttes til Redis.
**Beslutning B — Sikkerhetshoder i Caddy, ikke Next.js-middleware.**
Lagt til i `/opt/teeoff/deploy/Caddyfile` sin `teecup.golf`-blokk (delt
fil med `teeoff.no`, KUN teecup.golf-blokken endret): `X-Frame-Options:
DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`,
`Strict-Transport-Security`, og en `Content-Security-Policy`. CSP-en er
bevisst IKKE en streng nonce-basert policy — appen bruker inline
`style`-attributter mange steder (React `style={{...}}`, hele
"clubhouse"-paletten) og Next.js sin hydrering kan trenge inline script,
`'unsafe-inline'` er beholdt for script-src/style-src for å unngå å
knekke appen blindt. Verdien ligger i det som ER strammet inn:
`object-src`/`frame-ancestors`/`base-uri` blokkerer hele angrepsklasser,
og `connect-src`/`img-src` er begrenset til KUN Mapbox (slagmåling,
ADR-048) — en fremtidig XSS-bug kan ikke enkelt eksfiltrere data til en
vilkårlig tredjeparts-vert. Bekreftet i ekte nettleser mot produksjon:
ingen CSP-brudd i konsollen, et ekte Mapbox-kall (200 OK) fungerer
uendret.
**Reell driftsfallgruve funnet under utrulling, verdt å dokumentere:**
`docker exec teeoff_caddy caddy reload` (OG en direkte admin-API-`/load`
-PUT) rapporterte begge suksess uten feil, men endringen slo likevel
ALDRI igjennom — `md5sum` av filen inne i containeren avvek fra filen på
verten. Rotårsak: Docker sin bind-mount av EN ENKELT FIL (`./deploy/
Caddyfile:/etc/caddy/Caddyfile:ro`) er bundet til INODE-en filen hadde
ved containerens oppstart. Et skriveverktøy som lagrer atomisk (skriv til
midlertidig fil + `rename()`, vanlig og trygt mønster generelt) bytter ut
inode-en på verten — den kjørende containerens mount fortsetter da å
referere den GAMLE, nå frikoblede inode-en, usynlig for `reload`/admin-
API (som begge leser filen på nytt, men containerens FILSYSTEM-syn av
stien er allerede feil). Løsning: `docker restart teeoff_caddy` (ikke
bare `reload`) — dette er en STÅENDE fallgruve for enhver fremtidig
Caddyfile-endring, ikke unikt for denne runden. Kort, ufarlig avbrudd for
BEGGE sidene (teeoff.no og teecup.golf) ved restart, siden de deler
samme Caddy-container.
**Beslutning C — Inputvalidering, konsistens fremfor nye regler.** Ingen
nye valideringsprinsipper — kun manglende `max_length`/`ge`/`le` lagt til
der de manglet, etter EKSAKT samme mønster som allerede fantes andre
steder i samme fil (f.eks. `PlayerCreate.handicap_index` fikk samme
`ge=-10, le=54` som `auth.py` sin `ProfileUpdate.handicap_index` allerede
hadde). Én reell feil unngått underveis: `PersonalCourseTeeRatingIn.par`
ble først satt til `ge=3, le=6` (feilaktig antatt å være per-hull-par, som
`PersonalCourseHoleIn.par`) — DB-skjemaet (`020_personal_rounds.sql`,
ingen CHECK-constraint på den kolonnen) avslørte at det faktisk er
BANENS TOTALE par brukt i WHS-beregningen, rettet til `ge=54, le=90` før
utrulling. Endret: `rounds.py` (`PersonalCourseTeeRatingIn`/
`PersonalCourseTeeIn.name`/`PersonalCourseCreate.name`), `players.py`
(`PlayerCreate`/`PlayerUpdate`, begge — `handicap_index`/`mobile`/
`nickname`/`country`/`club`/`club_member_number`), `round_messages.py`
(`CommentIn.body`, delt/importert av `messaging.py` sine to bruksseder —
IKKE duplisert), samt tre `Form(default=None)`-felt (`round_messages.py`
+ to i `messaging.py`) som fikk `max_length=2000` (tekst) eller
`max_length=20` (invitasjonskode, som er 6 tegn generert, se
`tournaments.py._generate_join_code`).
**Beslutning D — BBB-endepunkt: autorisasjon flyttet FØR formatsjekk.**
`update_bbb_hole` i `rounds.py` kalte `_get_accessible_round_or_404`
ETTER å ha lest og validert `play_format`/`holes_planned` — en bruker
UTEN tilgang til en fremmed runde kunne dermed skille "runden finnes
ikke" (404) fra "runden finnes, feil format" (400) via feilmeldingen,
uten å oppnå noen skrivetilgang. Byttet rekkefølge: autorisasjon leses
FØRST, deretter format/hull-omfang. `round_row` kan ikke lenger være
`None` etter en vellykket autorisasjonssjekk, så den separate
null-sjekken er fjernet (var uansett aldri nåbar etter omorganiseringen).
**Scratch-verifisert** (tjuesjuende scratch-miljø denne økten): rate
limiting bekreftet å faktisk utløse 429 ved riktig terskel (10./11. forsøk
på login-password, 6. forsøk på request-link), gyldig/ugyldig
input-validering bekreftet begge veier (HCP over 54 avvist, gyldig HCP
akseptert, for lang mobil avvist), BBB-endepunktet bekreftet å returnere
403 NOT_AUTHORIZED (ikke lenger 400 format-lekkasje) for en bruker uten
tilgang, OG fortsatt 200 OK for den faktiske eieren (regresjonssjekk).
`teecup_db`s ACL bekreftet uendret før/etter, scratch-ressurser ryddet
opp fullstendig. CSP verifisert i ekte nettleser mot produksjon (se
Beslutning B).
**Bevisst utenfor omfang denne runden** (nevnt av agenten, ikke fulgt
opp): full linje-for-linje-gjennomgang av `individual_tournaments.py`/
`tournaments.py`/`order_of_merit.py`/`courses.py` (kun stikkprøvd),
Web Push-abonnement-kapring-teori (praktisk risiko vurdert svært lav,
push-endepunkt-URL-er er ugjettbare), noen faktisk penetrasjonstest (kun
statisk kodegjennomgang).
## ADR-051: 13-årsgrense for kontoregistrering + presentasjonstekst-korrigeringer
Brukeren, 2026-08-10, etter å ha lest PDF-vedlegget "Barns personvern i
golfapp" (en Gemini-samtale om GDPR/personvernkrav for mindreårige i en
GPS+UGC-app): "Vi må gjøre noe med de som er barn. For det første:
Tillatt aldri de som er under 13 år å registrere seg i appen." Samtidig,
to relaterte presentasjonskorrigeringer: et annet PDF-vedlegg ("Installere
PWA fra Chrome på iPhone") viste at installasjonsteksten feilaktig
hevdet at PWA-installasjon KREVER Safari på iOS -- siden iOS 16.4 gjelder
ikke det lenger. Og: "Jeg tror ikke vi skal nevne golfklubber i
beskrivelsen av TeeCup," pluss et ønske om å fremheve at
turneringsadministrasjon/-presentasjon fungerer like godt på PC.
**Beslutning A — 13-årsgrense håndheves i `PATCH /auth/profile`, ikke i
et eget registreringssteg.** Siden `profile_complete` (fødselsdato er ett
av de obligatoriske feltene) allerede er den ENESTE veien forbi
`/velkommen`-sperren (ADR-049) og inn i resten av appen, er dette det
naturlige, allerede-eksisterende knutepunktet -- en under-13-åring kan
rett og slett aldri fullføre profilen sin, og kommer dermed aldri forbi
`/velkommen`s låste hurtighandlinger. Ingen ny tabell/kolonne, ingen nytt
flagg. Håndhevet SERVER-SIDE i `app/routers/auth.py` sin `update_profile`
(alder beregnet fra `body.birth_date`, avvist med `400 UNDER_MINIMUM_AGE`
hvis under 13 -- eksakt dagsgrense, ikke bare årstall), PLUSS
klientside i BEGGE skjemaer som setter fødselsdato
(`ProfileOnboarding` og `ProfileSection` i `account-settings.tsx`, delt
`computeAge()`-hjelpefunksjon) for umiddelbar tilbakemelding før
serveren i det hele tatt kontaktes.
**Bevisst IKKE utvidet til `player`-tabellen** (organisasjonens
spillerpool, `players.py`/`registration.py`). Dette er en annen
datamodell: en `player`-rad representerer en ROSTER-oppføring en
ARRANGØR/klubb legger inn om en person, IKKE nødvendigvis en person med
egen app-innlogging (`player.user_id` er nullable, akkurat denne
frikoblingen er selve poenget). Brukerens instruks var "de som er under
13 år å registrere seg" -- altså SELV opprette en app-konto, ikke at en
voksen arrangør registrerer et barn som deltaker i en turnering (det
siste er faktisk PDF-vedleggets EGEN anbefalte, tryggere løsning for
yngre spillere -- "sub-accounts" ført av en voksen, se PDF-ens punkt 2
under "Anbefalte løsninger"). Å blokkere `player`-tabellen ville brutt
akkurat den mekanismen.
**Beslutning B — PWA-installasjonstekst skiller nå Safari/Chrome på
iOS.** `install-prompt.tsx` sin `isIOS()`-sjekk fanger ALLE iOS-
nettlesere (deler WebKit-motor), men Del-ikonets PLASSERING er ulik
(Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet) --
generisk "i Safari"-tekst var dermed direkte feil for en Chrome-bruker,
ikke bare unøyaktig. Ny `iosBrowser()`-funksjon skiller på `CriOS`
(Chrome) i user agent-strengen (vanlig "Chrome"-sniffing ville feilaktig
også truffet Safari, som deler samme motor). Ukjente/andre iOS-
nettlesere får en nøytral "i nettleseren din"-tekst i stedet for å gjette.
**Beslutning C — presentasjonstekst korrigert i to filer.**
`teecup-beskrivelse.md` OG `velkommen.tsx` sin "Hva er TeeCup?"-seksjon
(sistnevnte var bevisst skrevet om fra førstnevnte, ikke limt inn rått,
se ADR-049) -- begge fikk "golfklubber" fjernet fra målgruppe-
beskrivelsen (både "hva det er i dag" og "hva det skal bli"-seksjonene
i beskrivelses-dokumentet), og et nytt punkt lagt til om at
turneringsadministrasjon/-presentasjon fungerer minst like godt på PC
som på telefon. "Klubbhus-stemning" (personlighet/tone-beskrivelsen,
navnet på selve designretningen) er UENDRET -- det er ikke en
målgruppe-påstand, kun en stemningsbeskrivelse.
**Scratch-verifisert** (tjueåttende scratch-miljø denne økten): eksakt
dagsgrense bekreftet med tre testtilfeller mot ekte API -- 10-åring
avvist (400 UNDER_MINIMUM_AGE), nøyaktig 13 år i dag akseptert (200,
`profile_complete: true`), én dag under 13 år avvist (400) -- samme
grensesnitt-endepunkt, kun fødselsdatoen endret mellom kallene. Bekreftet
i ekte nettleser: klientside-feilmeldingen vises umiddelbart med rød
kant ved en 2018-fødselsdato, "Fortsett"-knappen forblir deaktivert.
Presentasjonsteksten på `/velkommen` bekreftet uten "golfklubber" og med
det nye PC-punktet. `tsc --noEmit`/`py_compile` rene, ingen konsollfeil.
`teecup_db`s ACL bekreftet uendret, scratch-ressurser ryddet opp
fullstendig.
**Bevisst utenfor omfang denne runden** (drøftet med bruker, avtalt som
egen fremtidig runde): rapportering/blokkering/moderering av
brukergenerert innhold (bilder/kommentarer) og "privat som standard for
mindreårige"-personvern for GPS-/rundedeling -- begge reelle,
substansielle krav fra PDF-vedlegget for at appen skal godkjennes i App
Store/Google Play med UGC+GPS, men et eget, avgrenset prosjekt (admin-
panel, databasefiltrering, 24-timers responstid), ikke noe som hører
hjemme i samme runde som en enkel aldersgrense.
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på

View file

@ -9571,6 +9571,317 @@ Neste steg:
beskriver. Scratch-miljøet (DB, rolle, MinIO, API- og beskriver. Scratch-miljøet (DB, rolle, MinIO, API- og
frontend-container/-images) ryddet opp fullstendig etterpå. frontend-container/-images) ryddet opp fullstendig etterpå.
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse **Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") — migrasjon
før migrasjon/deploy mot ekte `teecup_db`/`teecup_api`/ 061 kjørt mot ekte `teecup_db`, `teecup_api` og `teecup_frontend`
`teecup_frontend`, samme rutine som punkt 48. bygget/restartet rent, `teecup_db`s ACL bekreftet uendret før/etter,
verifisert live på `teecup.golf` uten konsollfeil.
54. **Ball-steget viser nå alltid kartet med løpende (sanntids) posisjon
og avstand, og allerede målte slag viser satellittfoto i listen —
2026-08-08, umiddelbar brukeroppfølging etter punkt 53.** Bruker,
etter å ha sett skjermbilder av den nye funksjonen: "Jeg får ikke sett
slagene jeg allerede har målt... jeg ønsker å kunne se på et
satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om
jeg velger 'min posisjon nå', så skal jeg se start og slutt på et
satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se
lengden så langt i sanntid mens jeg nærmer meg ballen." Se
"Tillegg 2026-08-08 (del 2)" i ARCHITECTURE_DECISIONS.md (ADR-048) for
full arkitektur-begrunnelse, inkl. det bevisste, avgrensede unntaket
fra "ingen løpende watchPosition"-prinsippet.
**Endringer, kun frontend (ingen migrasjon, `ShotOut` hadde allerede
alle koordinatene):**
- `map-point-picker.tsx`: `onConfirm` tar nå `(lngLat, method)` i
stedet for bare `(lngLat)`. Når `referencePoint` er satt (ball-
steget) startes `navigator.geolocation.watchPosition()` ved mount,
med en egen blå "du er her"-markør som oppdateres løpende og et
stort sanntids-avstand-tall ("AVSTAND SÅ LANGT") nederst på kartet,
beregnet med `haversineMeters(referencePoint, livePosition)`.
Footeren viser nå TO knapper når `referencePoint` er satt: "Bekreft
ballens posisjon" (trykket punkt, kun aktiv etter tap) og "Jeg er
ved ballen nå" (siste sporede posisjon, aktiv så snart første
GPS-fix er mottatt). `watchPosition` ryddes opp (`clearWatch`) ved
avmontering.
- `shot-measurement-sheet.tsx`: `end_map`-steget fjernet igjen (varte
kun én runde) — "end"-steget rendrer nå ALLTID `MapPointPicker`
direkte med `referencePoint={startPoint}`, ingen egen GPS/kart-valg-
skjerm lenger (den valget ligger nå inne i selve kartkomponentens
footer, se over). `measureEndPoint()`/`endStatus` fjernet (ikke
lenger i bruk). Egen duplikat Haversine-implementasjon
(`previewDistance`) erstattet med `haversineMeters` fra
`frontend/lib/geo.ts` (som alt fantes, men var ubrukt inntil nå) —
ren opprydding, ingen atferdsendring.
- `round-detail.tsx`: `ShotRecord`-typen utvidet med
`start_lat`/`start_lng`/`end_lat`/`end_lng` (allerede returnert av
API-et, kun frontend-typen manglet dem). `ShotList` viser nå et
160×160 satellitt-thumbnail per slag (samme offentlige token og
`pin-s-a`/`pin-s-b`-fargekonvensjon som forhåndsvisningen), bygget
client-side direkte fra de lagrede koordinatene — løser "jeg burde
jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke
bare klubbe/avstand som tekst, men HVOR slaget faktisk ble slått.
`tsc --noEmit` rent (ingen backend-endring denne runden).
**Scratch-verifisert** (tjuefjerde scratch-miljø denne økten, ingen
ny migrasjon å teste denne runden — samme 61 migrasjoner + `teecup_db`
ACL-forsiktighet som før): full nettleser-gjennomgang via Chrome
DevTools MCP med BÅDE `getCurrentPosition`- og `watchPosition`
monkey-patchet (sistnevnte simulerer en spiller som beveger seg fra
start mot ballen over ~5 sekunder via et `setInterval`). Bekreftet:
(1) sanntids-avstanden i kartet regner nøyaktig samme tall som en
uavhengig Python Haversine-kontroll (315,04 m); (2) "Jeg er ved ballen
nå" bruker siste sporede posisjon og lagret korrekt med
`end_method=gps` i databasen; (3) tap på kartet plasserer en grønn
markør og aktiverer "Bekreft ballens posisjon"; (4) en reell
grensesnitt-verifisering av EKSISTERENDE feilhåndtering (fra punkt 50)
skjedde underveis helt av seg selv: et tap som (ved et scratch-
test-uhell) landet ~0 m fra referansepunktet ble korrekt avvist av
backendens `distance_meters > 0`-sjekk (422), og arket viste riktig
feilmelding i stedet for å late som suksess — bekrefter at den
beskyttelsen fortsatt virker uendret gjennom hele denne
ombyggingen; (5) slag-listens nye satellitt-thumbnails lastet korrekt
for begge tidligere lagrede slag. Ingen uventede konsollfeil (kun
kjente scratch-miljø-artefakter: WebGL-fallback-advarsel, en
irrelevant WebSocket-tidsavbrudd, og den FORVENTEDE 422-en fra punkt
4). Scratch-miljøet (DB, rolle, MinIO, API- og frontend-container/
-images) ryddet opp fullstendig etterpå, `teecup_db`s ACL bekreftet
uendret.
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") —
`teecup_frontend` bygget/restartet rent (ingen migrasjon eller
backend-endring denne runden, `teecup_api` uendret), verifisert live
`teecup.golf` uten konsollfeil.
55. **Ny landingsside `/velkommen` for innlogget-men-ikke-fullført-profil
— 2026-08-09, eksplisitt brukerønske (ADR-049, se
ARCHITECTURE_DECISIONS.md for full begrunnelse og drøfting).** Bruker:
"Skjemaet med personlig informasjon vises for tidlig for ikke
registrerte spillere etter at de logger seg inn... Jeg tror det beste
er at man kommer til en side med oppfordring til å installere som app
(som vanlig) og med deaktiverte knapper for runde, turneringen og bli
med med kode. Trykker man på noen av disse skal man få beskjed om at
personlig informasjon må fylles ut først, med lenke til skjemaet."
Midtveis presisert: "Alle knappene bør egentlig være der, deaktivert.
bortsett fra til profilen" — dvs. HELE den vanlige bunn-navigasjonen
skal vises, ikke bare de tre hurtighandlingene.
**Rotårsak til det opprinnelige problemet:** tre steder rutet en
innlogget-men-ikke-fullført-profil-bruker RETT til `/account` sitt
påtvungne skjema uten noen kontekst: `app/page.tsx` (root-redirect),
`app/logg-inn/page.tsx` (post-innlogging-redirect), og
`dashboard.tsx` sin egen klient-side `profile_complete`-vakt
(`loadMe()`, fyres hvis en bruker skulle lande direkte på
`/dashboard`, f.eks. via `verify-form.tsx` som ALLTID sender dit
etter innlogging uansett profil-status).
**Bygget:**
- Ny `frontend/app/velkommen/page.tsx` (server-komponent, samme
autentiserings-/redirect-mønster som `/logg-inn/page.tsx` --
`redirect()`-kall bevisst holdt UTENFOR try/catch, samme fallgruve
som ble funnet og fikset i `/logg-inn/page.tsx` 2026-08-08 unngås
her fra start) + `frontend/components/teecup/velkommen.tsx`
(klient-komponent). Alle tre stedene over pekt om til `/velkommen`
i stedet for `/account`. `/velkommen` selv redirecter videre til
`/dashboard` (komplett profil) eller `/logg-inn` (ikke innlogget)
-- kan ikke nås "feil" via direkte URL.
- Siden gjenbruker eksisterende, allerede etablerte komponenter
uendret: `InstallPrompt` (samme PWA-oppfordring som dashbordet),
`TeeCupWordmark`, og samme "clubhouse"-palett-tokens som
`/logg-inn`/dashbordet (reskinnet 2026-08-07/08). Personlig
hilsen bruker `first_name ?? display_name`, samme navneformat-regel
som dashbordets hilsen (CLAUDE.md).
- Tre synlige, LÅSTE hurtighandlinger ("Ny runde"/"Ny turnering"/"Bli
med med kode" -- hengelås-ikon, `aria-disabled`, IKKE HTML
`disabled` siden de fortsatt skal være klikkbare for å forklare
hvorfor de er låst).
- `frontend/components/teecup/bottom-nav.tsx` utvidet med valgfrie
`disabledHrefs`/`onDisabledClick`-props (bakoverkompatibelt --
andre 6 sider som allerede bruker `BottomNav` uendret, ingen prop
sendt). Når en fane er i `disabledHrefs`, rendres den som en
`<button aria-disabled>` i stedet for `<Link>` -- `/velkommen`
viser dermed HELE den vanlige bunn-navigasjonen (Hjem/Runder/
Turneringer/Profil/Mer), med alle unntatt "Profil" låst, per
brukerens presisering midtveis.
- Trykk på EN HVILKEN SOM HELST låst kontroll (hurtighandling ELLER
bunnfane) viser samme delte påminnelse (`role="alert"`, fast
posisjonert rett over bunn-navigasjonen slik at den er synlig
uansett hvilken av de to gruppene som trigget den) med lenke videre
til `/account`.
- Presentasjons-seksjonen nederst er hentet fra `teecup-beskrivelse.md`,
skrevet om til kort UI-tekst (fire punkter: turneringsformater, live
scoreføring, WHS-handicap, sosialt) -- ikke limt inn rått.
`tsc --noEmit` rent. Ingen migrasjon eller backend-endring.
**Scratch-verifisert** (tjuefemte scratch-miljø denne økten): full
nettleser-gjennomgang av HELE kjeden med en HELT NY bruker (aldri sett
før, ikke forhåndsopprettet i databasen som tidligere scratch-økter)
-- registrerte e-post, verifiserte magic-link, landet automatisk på
`/velkommen` (bekrefter at BÅDE `verify-form.tsx` sin `/dashboard`-
redirect OG dashbordets egen vakt samvirker riktig -- vakten er den
som faktisk avgjør endestasjonen). Bekreftet: hilsen viser riktig navn,
`InstallPrompt` vises, hurtighandlinger viser hengelås og er
ikke-navigerende, bunn-navigasjonen viser alle fem faner med fire
låst og "Profil" aktiv, trykk på en låst hurtighandling viser
påminnelsen med korrekt lenke. Fulgte "Fyll ut nå" til `/account`,
fylte ut skjemaet ende-til-ende, endte korrekt på `/dashboard` med
riktig personlig hilsen ("God dag, Ny"). Ingen konsollfeil gjennom
hele kjeden. Scratch-miljøet (DB, rolle, MinIO, API- og
frontend-container/-images) ryddet opp fullstendig etterpå,
`teecup_db`s ACL bekreftet uendret.
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("ja") —
`teecup_frontend` bygget/restartet rent (`teecup_api` uendret, ingen
migrasjon), verifisert live på `teecup.golf` uten konsollfeil.
56. **Fjernet "Bekreft ballens posisjon" (trykk-på-kartet for ballens
posisjon) igjen — 2026-08-09, samme dag som punkt 54/55, etter faktisk
bruk på ekte bane.** Bruker sendte et ekte skjermbilde fra telefonen
(utslags- og live-markør nesten overlappende, "AVSTAND SÅ LANGT 1 m")
og spurte om forskjellen mellom de to bekreftelsesknappene. Etter
forklaring: "bekreft ballens posisjon er unødvendig." Avklart via
spørsmål (siden dette reverserer noe brukeren selv ba om bare timer
tidligere, jf. CLAUDE.md "spør heller enn å gjette" ved usikkerhet om
omfang): fjern trykk-alternativet HELT, behold kartet (referansepunkt
+ sanntidsposisjon/-avstand), ballens posisjon bekreftes nå
UTELUKKENDE med "Jeg er ved ballen nå" (sporet GPS).
**Endringer, kun `map-point-picker.tsx`:**
- `map.on("click", ...)`-registreringen (trykk-for-å-plassere-markør)
er nå betinget på `!referencePoint` -- kjører fortsatt uendret på
start-steget, aldri lenger på ball-steget.
- "Trykk der ballen ligger"-banneret fjernet for ball-steget (ingen
trykk-handling å instruere om lenger); start-stegets "Trykk på
kartet for å plassere punktet" uendret.
- Footeren på ball-steget viser nå kun ÉN knapp ("Jeg er ved ballen
nå", primærstil) i stedet for to stablede knapper.
- Ingen backend-/migrasjonsendring: `end_method`-kolonnen og
`Literal["gps", "map_tap"]`-typen beholdes uendret (historiske
`map_tap`-rader fra den korte perioden funksjonen var live skal
fortsatt leses/vises korrekt -- kun hvordan NYE slag kan opprettes
er endret).
`tsc --noEmit` rent.
**Scratch-verifisert** (tjuesjette scratch-miljø denne økten, samme
forsiktighetsrutine, `teecup_db`s ACL bekreftet uendret): full
nettleser-gjennomgang med `getCurrentPosition`/`watchPosition`
monkey-patchet. Bekreftet at ball-steget nå viser kartet direkte med
KUN "Jeg er ved ballen nå" i footeren (ingen "Bekreft ballens
posisjon"), at et trykk midt på kartet ikke lenger gjør noe (ingen ny
markør, ingen banner om å trykke), og at "Jeg er ved ballen nå"
fortsatt lagrer korrekt -- `start_method=gps, end_method=gps`
bekreftet direkte i databasen etter en full måle-runde (start via GPS,
kølle, lagring). Ingen konsollfeil. Scratch-miljøet ryddet opp
fullstendig.
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("ja") —
`teecup_frontend` bygget/restartet rent (`teecup_api` uendret),
verifisert live på `teecup.golf` uten konsollfeil.
57. **Sikkerhetsgjennomgang + fiks av alle funn — 2026-08-09/10, bruker:
"sjekk alle felter... sjekk alle URL-er. Er alt sikret godt nok? Lar
siden/appen seg hacke?", deretter "tett sikkerhetshullene først."**
Full gjennomgang (dedikert agent) av autentisering, autorisasjon på
tvers av alle 16 routere, input-validering, filopplasting, CORS/
nettverk, hemmeligheter i git. Se ADR-050 i ARCHITECTURE_DECISIONS.md
for full begrunnelse. Konklusjon: autorisasjonslaget er uvanlig
solid (ingen bekreftet IDOR), men fire reelle hull ble funnet og
fikset:
1. **Rate limiting** (HØY) lagt til på fem auth-endepunkter (ny
`app/rate_limit.py`) — ingen fantes fra før, passord/2FA-koder
kunne i praksis brute-forces.
2. **Sikkerhetshoder** (LAV-MIDDELS) lagt til i Caddy for
teecup.golf: CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, HSTS.
3. **Input-validering** (LAV) — manglende `max_length`/`ge`/`le` lagt
til konsekvent i `rounds.py`/`players.py`/`round_messages.py`/
`messaging.py`, etter mønster som allerede fantes andre steder i
samme filer.
4. **BBB-endepunkt informasjonslekkasje** (informativt) — autorisasjon
flyttet til FØR formatsjekk i `update_bbb_hole`.
**Reell driftsfallgruve funnet og løst underveis:** `caddy reload`
(og en direkte admin-API-PUT) rapporterte suksess, men Caddyfile-
endringen slo aldri igjennom — `teeoff_caddy`s bind-mount av
Caddyfile-EN ENKELT FIL var bundet til en nå frikoblet inode etter en
atomisk skriving (skriv+omdøp) på verten. Løst med `docker restart
teeoff_caddy` (ikke bare reload) — se ADR-050 for full forklaring,
dette er en STÅENDE fallgruve for enhver fremtidig Caddyfile-endring.
`python3 -m py_compile` rent på alle endrede filer.
**Scratch-verifisert** (tjuesjuende scratch-miljø denne økten, samme
`teecup_db`-ACL-forsiktighet): rate limiting bekreftet å faktisk
utløse 429 ved riktig terskel på BÅDE `login-password` (10 tillatt,
11. avvist) og `request-link` (5 tillatt, 6. avvist); input-
validering bekreftet begge veier (HCP=99 avvist, HCP=18.4 akseptert,
50-tegns mobilnummer avvist); BBB-endepunktet bekreftet å returnere
403 NOT_AUTHORIZED (ikke lenger en 400 format-lekkasje) for en bruker
uten tilgang til en fremmed runde, OG fortsatt 200 OK for den
faktiske eieren på en ekte BBB-runde (regresjonssjekk). CSP-en
verifisert direkte mot PRODUKSJON i ekte nettleser (ikke scratch,
siden Caddy-konfigen er delt infrastruktur som ikke er en del av
scratch-oppsettet): ingen CSP-brudd i konsollen, et ekte Mapbox-kall
med gyldig token ga 200 OK, login-siden rendret visuelt korrekt (inline
styles uendret). `teecup_db`s ACL bekreftet uendret før/etter,
scratch-ressurser ryddet opp fullstendig.
**Rullet ut** — Caddy-delen ble rullet ut/verifisert direkte mot
produksjon underveis (se over). `teecup_api`-delen (rate limiting,
validering, BBB-fiks) bygget/restartet etter eksplisitt
brukerbekreftelse ("ja takk"), verifisert LIVE: `POST /auth/
request-link` mot ekte `teecup.golf` ga 200 på de fem første
forsøkene og 429 på det sjette (nøyaktig terskelen satt i koden),
ingen konsollfeil i ekte nettleser etterpå.
58. **13-årsgrense for kontoregistrering + to presentasjonstekst-
korrigeringer — 2026-08-10, bruker etter å ha lest to PDF-vedlegg.**
Se ADR-051 i ARCHITECTURE_DECISIONS.md for full begrunnelse og
drøfting av anbefalingene i "Barns personvern i golfapp"-PDF-en.
**Endringer:**
- `app/routers/auth.py`: `update_profile` avviser nå `PATCH /auth/
profile` med `400 UNDER_MINIMUM_AGE` hvis `birth_date` innebærer
under 13 år (eksakt dagsberegning). Dette er den ENESTE veien inn i
appen (profil må være komplett for å komme forbi `/velkommen`,
ADR-049), så dette ene stedet håndhever grensen for hele appen.
Bevisst IKKE utvidet til `player`-tabellen (organisasjonens
roster/CRM, `players.py`/`registration.py`) -- en `player`-rad er
en arrangørs registrering AV en person, ikke personen som
registrerer SEG SELV, og er selve PDF-ens egen anbefalte, tryggere
løsning for yngre spillere.
- `account-settings.tsx`: ny delt `computeAge()`-hjelpefunksjon,
brukt i BÅDE `ProfileOnboarding` (førstegangs-utfylling) og
`ProfileSection` (senere redigering under Konto) for umiddelbar
rød feilmelding + deaktivert lagre-knapp -- før serveren i det
hele tatt kontaktes.
- `install-prompt.tsx`: ny `iosBrowser()` skiller Safari fra Chrome
på iOS (`CriOS` i user agent) -- installasjonsteksten hevdet
tidligere feilaktig at man MÅTTE til Safari, og pekte til "nederst
i Safari" uansett nettleser. Siden iOS 16.4 kan Chrome installere
PWA-er direkte, med Del-ikonet et ANNET sted (oppe til høyre i
adressefeltet, ikke nederst). Ukjente iOS-nettlesere får nå en
nøytral "i nettleseren din"-tekst i stedet for en gjetning.
- `teecup-beskrivelse.md` + `velkommen.tsx`: "golfklubber" fjernet
fra målgruppe-beskrivelsen (to steder i beskrivelses-dokumentet,
ett i `/velkommen`s "Hva er TeeCup?"), nytt punkt lagt til om at
turneringsadministrasjon/-presentasjon fungerer minst like godt på
PC som på telefon. "Klubbhus-stemning" (design-retningens navn,
ren tone-beskrivelse) er bevisst uendret.
`tsc --noEmit` og `python3 -m py_compile` begge rene.
**Scratch-verifisert** (tjueåttende scratch-miljø denne økten, samme
`teecup_db`-ACL-forsiktighet): eksakt dagsgrense bekreftet med tre
API-kall (10-åring avvist, nøyaktig 13 år i dag akseptert med
`profile_complete: true`, én dag under 13 avvist). Ekte nettleser:
klientside rød feilmelding + deaktivert knapp bekreftet ved
2018-fødselsdato, `/velkommen`s presentasjonstekst bekreftet uten
"golfklubber" og med det nye PC-punktet. Ingen konsollfeil. Scratch-
miljøet (DB, rolle, MinIO, API- og frontend-container/-images) ryddet
opp fullstendig, `teecup_db`s ACL bekreftet uendret.
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
`teecup_api`/`teecup_frontend` bygges/restartes mot ekte miljø.

View file

@ -4423,9 +4423,17 @@ isolert forhåndsvisnings-container (ingen konsollfeil).
rent, `/logg-inn` → 200, `/` (dagens innlogging, uendret) → 200 fortsatt. rent, `/logg-inn` → 200, `/` (dagens innlogging, uendret) → 200 fortsatt.
Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet. Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet.
**Ingen beslutning tatt ennå om å faktisk ta i bruk denne retningen** — **✅ BESLUTTET OG RULLET UT 2026-08-08:** bruker valgte denne retningen.
kun en parallell sammenligning. Ingen backend-kobling gjort — hvis denne `teecup-auth.tsx` koblet til de ekte `/auth/request-link`/`/auth/verify-
retningen velges, må `teecup-auth.tsx` sine mockede API-kall kobles til link`/`/auth/login-password`/`/public/tournaments/by-code`-endepunktene
de ekte `/auth/request-link`/`/auth/verify-link`/`/auth/login-password`/ (samme kontrakt som gamle `login-form.tsx` brukte). `/logg-inn` er nå den
`/public/tournaments/by-code`-endepunktene (samme kontrakt som ekte innloggingssiden — `app/page.tsx` (root) er en tynn videresending
`login-form.tsx` allerede bruker) før den kan erstatte dagens `/`. dit (beholdt for gamle bokmerker), gamle `LoginForm`/Forest Green-skjemaet
fjernet. Se CHANGELOG.md 2026-08-08 for byggelogg.
**Bevisst avgrenset omfang (bekreftet med bruker 2026-08-08):** kun
innlogging/auth-flyten fikk den nye "clubhouse"-paletten. Resten av appen
(dashbord, scorekort, runde-visning osv.) beholder dagens etablerte
palett (se DESIGN_SYSTEM.md) inntil videre — retemaes gradvis, skjerm for
skjerm, når de likevel røres i senere runder. Ingen egen stor
retemaings-runde er igangsatt eller bedt om.

66
app/rate_limit.py Normal file
View file

@ -0,0 +1,66 @@
"""Enkel rate limiting i minnet.
Lagt til etter sikkerhetsgjennomgang 2026-08-09 (se ARCHITECTURE_DECISIONS.md
-tillegg): ingen av auth-endepunktene hadde noen form for forsøksbegrensning,
noe som gjorde passord- og 2FA-kode-gjetting praktisk mulig for en angriper
med nok samtidighet.
Trygt å holde i minnet KUN fordi `teecup_api` kjører som ÉN uvicorn-prosess
(ingen `--workers`-flagg, se Dockerfile) -- tilstanden deles derfor ikke
mellom flere prosesser/containere, samme kjente begrensning som andre
in-memory-cacher i appen (se "Cache over flere prosesser" i
ARCHITECTURE_DECISIONS.md sin liste over åpne punkter). Ved fremtidig
skalering til flere workers/containere dette flyttes til Redis eller
tilsvarende delt lager -- ikke glem å oppdatere denne kommentaren da.
"""
import time
from fastapi import Request
from .errors import app_error
def client_ip(request: Request) -> str:
"""Klient-IP via X-Forwarded-For -- trygt KUN fordi appen ikke er nåbar
unntatt gjennom Caddy, som terminerer TLS og proxyer videre over vanlig
http internt (samme tillitsmodell som should_use_secure_cookies() i
app/auth.py). Første adresse i kjeden er den faktiske klienten."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
class RateLimiter:
"""Fast-vindu-teller per nøkkel.
Bruk som FastAPI-dependency (`Depends(en_limiter)`) for IP-basert
begrensning, eller kall `.check(nøkkel)` direkte inne i en
endepunkt-funksjon for en mer presis nøkkel (f.eks. en pending-bruker-ID
-- riktig nøkkel når selve RESSURSEN som brute-forces er knyttet til en
bestemt konto, ikke avsender-IP-en)."""
def __init__(
self,
max_requests: int,
window_seconds: float,
message: str = "For mange forsøk. Vent litt og prøv igjen.",
):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.message = message
self._buckets: dict[str, list[float]] = {}
def check(self, key: str) -> None:
now = time.monotonic()
window_start = now - self.window_seconds
hits = [t for t in self._buckets.get(key, []) if t >= window_start]
if len(hits) >= self.max_requests:
self._buckets[key] = hits
raise app_error(429, "RATE_LIMITED", self.message)
hits.append(now)
self._buckets[key] = hits
def __call__(self, request: Request) -> None:
self.check(client_ip(request))

View file

@ -73,6 +73,7 @@ from ..email import (
send_two_factor_code_email, send_two_factor_code_email,
) )
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from ..rate_limit import RateLimiter
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -81,6 +82,25 @@ _GENERIC_RESPONSE = {
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.", "detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
} }
# Rate limiting (sikkerhetsgjennomgang 2026-08-09, se app/rate_limit.py for
# begrunnelse/forbehold). IP-baserte grenser brukes der ressursen som
# beskyttes ikke er entydig knyttet til én konto (request-link/2fa-email-
# request kan gjelde en HVILKEN SOM HELST e-post, login-password likeledes).
# 2fa/verify og 2fa/setup/confirm brute-forcer derimot EN BESTEMT konto sin
# kode -- der er nøkkelen den faktiske (pending-/innloggede) bruker-ID-en i
# stedet for IP, se .check(...)-kallene i selve endepunktene, siden en
# angriper med en gyldig pending-sesjon ellers kunne omgått IP-basert
# begrensning ved å bytte IP mellom forsøk.
_login_password_limiter = RateLimiter(max_requests=10, window_seconds=600)
_request_link_limiter = RateLimiter(max_requests=5, window_seconds=600)
_two_factor_email_request_limiter = RateLimiter(max_requests=5, window_seconds=600)
_two_factor_verify_limiter = RateLimiter(
max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
)
_two_factor_setup_confirm_limiter = RateLimiter(
max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
)
def _hash_secret(raw: str) -> str: def _hash_secret(raw: str) -> str:
"""Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og """Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
@ -158,7 +178,7 @@ class MagicLinkRequest(BaseModel):
@router.post("/request-link") @router.post("/request-link")
async def request_magic_link(body: MagicLinkRequest) -> dict: async def request_magic_link(body: MagicLinkRequest, _rl: None = Depends(_request_link_limiter)) -> dict:
email = body.email.lower() email = body.email.lower()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS) cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
@ -369,7 +389,12 @@ class PasswordLoginRequest(BaseModel):
@router.post("/login-password", response_model=LoginResult) @router.post("/login-password", response_model=LoginResult)
async def login_with_password(body: PasswordLoginRequest, response: Response, request: Request) -> LoginResult: async def login_with_password(
body: PasswordLoginRequest,
response: Response,
request: Request,
_rl: None = Depends(_login_password_limiter),
) -> LoginResult:
email = body.email.lower() email = body.email.lower()
async with plain_connection() as conn: async with plain_connection() as conn:
user_row = await conn.fetchrow( user_row = await conn.fetchrow(
@ -454,7 +479,10 @@ async def _send_two_factor_email_code(conn, user_id: str, email: str, locale: st
@router.post("/2fa/email/request") @router.post("/2fa/email/request")
async def request_two_factor_email_code(pending: PendingUser = Depends(get_pending_user)) -> dict: async def request_two_factor_email_code(
pending: PendingUser = Depends(get_pending_user),
_rl: None = Depends(_two_factor_email_request_limiter),
) -> dict:
async with plain_connection() as conn: async with plain_connection() as conn:
user_row = await conn.fetchrow( user_row = await conn.fetchrow(
"SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1", "SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1",
@ -477,6 +505,10 @@ async def verify_two_factor(
request: Request, request: Request,
pending: PendingUser = Depends(get_pending_user), pending: PendingUser = Depends(get_pending_user),
) -> LoginResult: ) -> LoginResult:
# Nøkkel på pending.user_id, ikke IP -- se begrunnelse ved limiter-
# definisjonene lenger opp i filen.
_two_factor_verify_limiter.check(pending.user_id)
async with plain_connection() as conn: async with plain_connection() as conn:
method = await conn.fetchval( method = await conn.fetchval(
"SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id "SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id
@ -588,6 +620,10 @@ async def confirm_two_factor_setup(
request: Request, request: Request,
user: CurrentUser = Depends(get_current_or_enrolling_user), user: CurrentUser = Depends(get_current_or_enrolling_user),
) -> SessionUser: ) -> SessionUser:
# Nøkkel på user.user_id, ikke IP -- se begrunnelse ved limiter-
# definisjonene lenger opp i filen.
_two_factor_setup_confirm_limiter.check(user.user_id)
if body.method == "totp": if body.method == "totp":
if not body.secret or not verify_totp_code(body.secret, body.code.strip()): if not body.secret or not verify_totp_code(body.secret, body.code.strip()):
raise app_error(401, "INVALID_2FA_CODE", "Feil kode.") raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
@ -904,6 +940,22 @@ async def update_profile(
if not updates: if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
# Aldersgrense (2026-08-10, eksplisitt brukerkrav etter juridisk/
# personvern-vurdering, se ARCHITECTURE_DECISIONS.md): TeeCup skal
# ALDRI la noen under 13 år registrere seg (GDPR/personopplysningsloven
# -- under 13 krever verifiserbart foreldresamtykke, som appen ikke har
# støtte for). Håndhevet HER, server-side, ikke bare i skjemaet --
# klientside-validering alene er ingen reell grense.
if body.birth_date is not None:
today = date.today()
age = today.year - body.birth_date.year - (
(today.month, today.day) < (body.birth_date.month, body.birth_date.day)
)
if age < 13:
raise app_error(
400, "UNDER_MINIMUM_AGE", "Du må være minst 13 år for å bruke TeeCup."
)
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values()) values = list(updates.values())
values.append(user.user_id) values.append(user.user_id)

View file

@ -242,7 +242,7 @@ async def list_team_messages(
async def send_team_message( async def send_team_message(
team_id: str, team_id: str,
organization_id: str, organization_id: str,
body: str | None = Form(default=None), body: str | None = Form(default=None, max_length=2000),
image: UploadFile | None = File(default=None), image: UploadFile | None = File(default=None),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> MessageOut: ) -> MessageOut:
@ -572,9 +572,9 @@ async def get_feed(
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201) @public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
async def post_to_feed( async def post_to_feed(
tournament_id: str, tournament_id: str,
body: str | None = Form(default=None), body: str | None = Form(default=None, max_length=2000),
image: UploadFile | None = File(default=None), image: UploadFile | None = File(default=None),
code: str | None = Form(default=None), code: str | None = Form(default=None, max_length=20),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> MessageOut: ) -> MessageOut:
if not body and image is None: if not body and image is None:

View file

@ -24,15 +24,16 @@ router = APIRouter()
class PlayerCreate(BaseModel): class PlayerCreate(BaseModel):
display_name: str = Field(min_length=1, max_length=200) display_name: str = Field(min_length=1, max_length=200)
handicap_index: float | None = None # Samme grense som ProfileUpdate.handicap_index i auth.py (WHS-maksimum).
handicap_index: float | None = Field(default=None, ge=-10, le=54)
gender: str | None = Field(default=None, pattern="^[mfx]$") gender: str | None = Field(default=None, pattern="^[mfx]$")
mobile: str | None = None mobile: str | None = Field(default=None, max_length=30)
email: EmailStr | None = None email: EmailStr | None = None
birth_date: date | None = None birth_date: date | None = None
nickname: str | None = None nickname: str | None = Field(default=None, max_length=100)
country: str | None = None country: str | None = Field(default=None, max_length=100)
club: str | None = None club: str | None = Field(default=None, max_length=200)
club_member_number: str | None = None club_member_number: str | None = Field(default=None, max_length=50)
class Player(BaseModel): class Player(BaseModel):
@ -108,15 +109,15 @@ class PlayerUpdate(BaseModel):
""" """
display_name: str | None = Field(default=None, min_length=1, max_length=200) display_name: str | None = Field(default=None, min_length=1, max_length=200)
handicap_index: float | None = None handicap_index: float | None = Field(default=None, ge=-10, le=54)
gender: str | None = Field(default=None, pattern="^[mfx]$") gender: str | None = Field(default=None, pattern="^[mfx]$")
mobile: str | None = None mobile: str | None = Field(default=None, max_length=30)
email: EmailStr | None = None email: EmailStr | None = None
birth_date: date | None = None birth_date: date | None = None
nickname: str | None = None nickname: str | None = Field(default=None, max_length=100)
country: str | None = None country: str | None = Field(default=None, max_length=100)
club: str | None = None club: str | None = Field(default=None, max_length=200)
club_member_number: str | None = None club_member_number: str | None = Field(default=None, max_length=50)
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player) @router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)

View file

@ -25,7 +25,7 @@ live` -- klienten reagerer med å hente meldingslisten på nytt, samme
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from pydantic import BaseModel from pydantic import BaseModel, Field
from .. import storage from .. import storage
from ..auth import CurrentUser, get_current_user, get_current_user_optional from ..auth import CurrentUser, get_current_user, get_current_user_optional
@ -172,7 +172,7 @@ async def list_round_messages(
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201) @router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
async def post_round_message( async def post_round_message(
round_id: str, round_id: str,
body: str | None = Form(default=None), body: str | None = Form(default=None, max_length=2000),
image: UploadFile | None = File(default=None), image: UploadFile | None = File(default=None),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> RoundMessageOut: ) -> RoundMessageOut:
@ -314,7 +314,7 @@ _ROUND_MESSAGE_COMMENT_COLUMNS = """
class CommentIn(BaseModel): class CommentIn(BaseModel):
body: str body: str = Field(min_length=1, max_length=2000)
parent_comment_id: str | None = None parent_comment_id: str | None = None

View file

@ -114,18 +114,20 @@ class PersonalCourseHoleIn(BaseModel):
class PersonalCourseTeeRatingIn(BaseModel): class PersonalCourseTeeRatingIn(BaseModel):
gender: Literal["m", "f"] gender: Literal["m", "f"]
course_rating: float course_rating: float = Field(ge=50, le=90)
slope_rating: int = Field(ge=55, le=155) slope_rating: int = Field(ge=55, le=155)
par: int # Banens TOTALE par (18 hull), brukt i WHS-beregningen -- ikke per-hull-
# par (det er PersonalCourseHoleIn.par, ge=3/le=6, en helt annen ting).
par: int = Field(ge=54, le=90)
class PersonalCourseTeeIn(BaseModel): class PersonalCourseTeeIn(BaseModel):
name: str name: str = Field(min_length=1, max_length=100)
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2) ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
class PersonalCourseCreate(BaseModel): class PersonalCourseCreate(BaseModel):
name: str name: str = Field(min_length=1, max_length=100)
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18) holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
tees: list[PersonalCourseTeeIn] = Field(min_length=1) tees: list[PersonalCourseTeeIn] = Field(min_length=1)
# Satt når banen opprettes med en annen offentlig custom-bane som # Satt når banen opprettes med en annen offentlig custom-bane som
@ -3354,16 +3356,21 @@ async def update_bbb_hole(
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> BBBHoleOut: ) -> BBBHoleOut:
async with plain_connection() as conn: async with plain_connection() as conn:
# Autorisasjon FØR noe annet leses/valideres -- reell, om enn liten,
# informasjonslekkasje funnet under sikkerhetsgjennomgang 2026-08-09:
# med format-sjekken først kunne en bruker uten tilgang skille
# "runden finnes ikke" fra "runden finnes, feil format" for en
# fremmed runde, uten å faktisk oppnå skrivetilgang. Se
# ARCHITECTURE_DECISIONS.md-tillegg for detalj.
await _get_accessible_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow( round_row = await conn.fetchrow(
"SELECT play_format, holes_planned FROM round WHERE id = $1", round_id "SELECT play_format, holes_planned FROM round WHERE id = $1", round_id
) )
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if round_row["play_format"] != "bbb": if round_row["play_format"] != "bbb":
raise app_error(400, "VALIDATION_FAILED", "Denne runden bruker ikke Bingo Bango Bongo.") raise app_error(400, "VALIDATION_FAILED", "Denne runden bruker ikke Bingo Bango Bongo.")
if not (1 <= hole_number <= round_row["holes_planned"]): if not (1 <= hole_number <= round_row["holes_planned"]):
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.") raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
await _get_accessible_round_or_404(conn, round_id, user.user_id)
for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id): for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
if pid is None: if pid is None:

View file

@ -47,8 +47,12 @@ export default async function LoggInnPage() {
// (en catch der ville slukt den stille og latt innloggingsskjemaet vises // (en catch der ville slukt den stille og latt innloggingsskjemaet vises
// i stedet for å omdirigere, reell bug funnet under scratch-verifisering // i stedet for å omdirigere, reell bug funnet under scratch-verifisering
// 2026-08-08, se CHANGELOG.md). // 2026-08-08, se CHANGELOG.md).
// Ikke rett til /account -- se ARCHITECTURE_DECISIONS.md-tillegg
// 2026-08-09: /velkommen viser kontekst (PWA-installasjon, låste
// hurtighandlinger, presentasjon) FØR skjemaet, i stedet for at
// skjemaet dukker opp helt uten forklaring rett etter innlogging.
if (authenticated) { if (authenticated) {
redirect(profileComplete ? "/dashboard" : "/account") redirect(profileComplete ? "/dashboard" : "/velkommen")
} }
return ( return (

View file

@ -39,13 +39,16 @@ export default async function Page() {
} }
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som // Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til // ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
// /account (som selv viser en fokusert fullførings-visning) i stedet // /velkommen (ikke rett til /account lenger, se ARCHITECTURE_DECISIONS.md
// for dashbordet -- se AccountSettings sin profile_complete-gren. // -tillegg 2026-08-09 -- brukeren rapporterte at det direkte hoppet til
// skjemaet, uten kontekst, var feil). /velkommen viser PWA-installasjon,
// låste-men-forklarte hurtighandlinger og selve TeeCup-presentasjonen,
// med en tydelig lenke videre til /account.
// //
// /logg-inn overtok rollen som ekte innloggingsside 2026-08-08 (se // /logg-inn overtok rollen som ekte innloggingsside 2026-08-08 (se
// CHANGELOG.md) -- denne siden (root) er nå bare en tynn videresending, // CHANGELOG.md) -- denne siden (root) er nå bare en tynn videresending,
// beholdt for gamle bokmerker/lenker til teecup.golf/. Selve // beholdt for gamle bokmerker/lenker til teecup.golf/. Selve
// innloggingsskjemaet (LoginForm, Forest Green) er fjernet -- erstattet // innloggingsskjemaet (LoginForm, Forest Green) er fjernet -- erstattet
// av TeeCupAuth (clubhouse-palett) på /logg-inn. // av TeeCupAuth (clubhouse-palett) på /logg-inn.
redirect(authenticated ? (profileComplete ? "/dashboard" : "/account") : "/logg-inn") redirect(authenticated ? (profileComplete ? "/dashboard" : "/velkommen") : "/logg-inn")
} }

View file

@ -0,0 +1,59 @@
import type { Metadata } from "next"
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { Velkommen } from "@/components/teecup/velkommen"
export const metadata: Metadata = {
title: "Velkommen TeeCup",
description: "Fullfør profilen din for å komme i gang med TeeCup.",
}
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
// next.config.mjs sin rewrites() (samme mønster som /logg-inn/page.tsx og
// den tidligere app/page.tsx). Peker direkte på API-et.
const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
// Må matche SESSION_COOKIE_NAME i app/auth.py -- ingen delt konstant på
// tvers av Python/TypeScript, samme mønster som andre API-kontrakt-felt
// som dupliseres bevisst i frontend-koden.
const SESSION_COOKIE_NAME = "teecup_session"
export default async function VelkommenPage() {
const cookieStore = await cookies()
const session = cookieStore.get(SESSION_COOKIE_NAME)
let authenticated = false
let profileComplete = true
let firstName: string | null = null
let displayName = ""
if (session) {
try {
const res = await fetch(`${API_ORIGIN}/auth/me`, {
headers: { Cookie: `${SESSION_COOKIE_NAME}=${session.value}` },
cache: "no-store",
})
authenticated = res.ok
if (res.ok) {
const data = await res.json()
profileComplete = Boolean(data.profile_complete)
firstName = data.first_name ?? null
displayName = data.display_name ?? ""
}
} catch {
// API utilgjengelig -- send til innlogging i stedet for å henge.
authenticated = false
}
}
// redirect() kaster en egen NEXT_REDIRECT-kontrollflyt-exception som MÅ
// boble videre til Next.js -- må derfor stå UTENFOR try/catch-en over
// (reell bug funnet 2026-08-08 i /logg-inn/page.tsx av nøyaktig denne
// fallgruven, se CHANGELOG.md -- unngås her fra start).
if (!authenticated) {
redirect("/logg-inn")
}
// Denne siden finnes KUN for innlogget-men-ikke-fullført-profil -- en
// bruker med komplett profil skal aldri se den (send rett til dashbordet).
if (profileComplete) {
redirect("/dashboard")
}
return <Velkommen firstName={firstName} displayName={displayName} />
}

View file

@ -57,6 +57,21 @@ const MAX_BAG_CLUBS = 14
// backend-endring trengs da, kun denne listen utvides. // backend-endring trengs da, kun denne listen utvides.
const COUNTRIES = ["Norge"] as const const COUNTRIES = ["Norge"] as const
// Aldersgrense (2026-08-10, se ARCHITECTURE_DECISIONS.md): brukt av BÅDE
// ProfileOnboarding og ProfileSection for umiddelbar tilbakemelding -- den
// ekte, håndhevede grensen ligger server-side i PATCH /auth/profile.
// `dateStr` er "YYYY-MM-DD" (rå verdi fra en <input type="date">).
function computeAge(dateStr: string): number {
const birth = new Date(dateStr)
const today = new Date()
let age = today.getFullYear() - birth.getFullYear()
const hasHadBirthdayThisYear =
today.getMonth() > birth.getMonth() ||
(today.getMonth() === birth.getMonth() && today.getDate() >= birth.getDate())
if (!hasHadBirthdayThisYear) age -= 1
return age
}
function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) { function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) {
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -367,10 +382,17 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Aldersgrense (2026-08-10): TeeCup skal aldri la noen under 13 år
// registrere seg (GDPR/personopplysningsloven -- se ARCHITECTURE_
// DECISIONS.md). Denne klientside-sjekken er kun for rask tilbakemelding
// -- den ekte, håndhevede grensen ligger server-side i PATCH /auth/profile.
const underMinimumAge = birthDate !== "" && computeAge(birthDate) < 13
const valid = const valid =
firstName.trim() !== "" && firstName.trim() !== "" &&
lastName.trim() !== "" && lastName.trim() !== "" &&
birthDate !== "" && birthDate !== "" &&
!underMinimumAge &&
gender !== "" && gender !== "" &&
hcp.trim() !== "" && hcp.trim() !== "" &&
homeClub.trim() !== "" && homeClub.trim() !== "" &&
@ -484,8 +506,14 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
type="date" type="date"
value={birthDate} value={birthDate}
onChange={(e) => setBirthDate(e.target.value)} onChange={(e) => setBirthDate(e.target.value)}
aria-invalid={underMinimumAge}
className="h-11 rounded-xl" className="h-11 rounded-xl"
/> />
{underMinimumAge ? (
<p role="alert" className="text-sm text-destructive text-pretty">
Du være minst 13 år for å bruke TeeCup.
</p>
) : null}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="ob-gender" className="text-sm font-semibold"> <Label htmlFor="ob-gender" className="text-sm font-semibold">
@ -566,8 +594,12 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Aldersgrense (2026-08-10) -- se tilsvarende kommentar i ProfileOnboarding.
const underMinimumAge = birthDate !== "" && computeAge(birthDate) < 13
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (underMinimumAge) return
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false) setSuccess(false)
@ -722,8 +754,14 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
type="date" type="date"
value={birthDate} value={birthDate}
onChange={(e) => setBirthDate(e.target.value)} onChange={(e) => setBirthDate(e.target.value)}
aria-invalid={underMinimumAge}
className="h-11 rounded-xl" className="h-11 rounded-xl"
/> />
{underMinimumAge ? (
<p role="alert" className="text-sm text-destructive text-pretty">
Du være minst 13 år for å bruke TeeCup.
</p>
) : null}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="gender" className="text-sm font-semibold"> <Label htmlFor="gender" className="text-sm font-semibold">
@ -836,7 +874,7 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
{error && <p className="text-sm font-medium text-destructive">{error}</p>} {error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>} {success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
<Button type="submit" disabled={saving} className="h-11 w-fit rounded-xl font-semibold"> <Button type="submit" disabled={saving || underMinimumAge} className="h-11 w-fit rounded-xl font-semibold">
{saving ? "Lagrer …" : "Lagre profil"} {saving ? "Lagrer …" : "Lagre profil"}
</Button> </Button>
</form> </form>

View file

@ -311,8 +311,10 @@ export function Dashboard() {
return return
} }
const data: Me = await res.json() const data: Me = await res.json()
// Ikke rett til /account -- se ARCHITECTURE_DECISIONS.md-tillegg
// 2026-08-09: /velkommen viser kontekst før skjemaet.
if (!data.profile_complete) { if (!data.profile_complete) {
router.replace("/account") router.replace("/velkommen")
return return
} }
setMe(data) setMe(data)

View file

@ -23,6 +23,23 @@ function isIOS() {
return /iphone|ipad|ipod/i.test(window.navigator.userAgent) return /iphone|ipad|ipod/i.test(window.navigator.userAgent)
} }
// Siden iOS 16.4 kan en PWA installeres fra Chrome på iPhone også, ikke
// bare Safari (brukerønske 2026-08-10, tidligere tekst her hevdet feilaktig
// at man MÅTTE til Safari -- se PDF-vedlegg "Installere PWA fra Chrome på
// iPhone"). Del-ikonets PLASSERING er derimot ulik mellom nettleserne
// (Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet),
// så instruksjonsteksten må skille dem for å faktisk stemme -- ikke bare
// fjerne Safari-nevnelsen og la den bli vag. Chrome-på-iOS har "CriOS" i
// user agent-strengen (deler ellers WebKit-motoren med Safari, så vanlig
// "Chrome"-sniffing ville feilaktig truffet Safari også).
function iosBrowser(): "safari" | "chrome" | "other" {
if (typeof window === "undefined") return "other"
const ua = window.navigator.userAgent
if (/crios/i.test(ua)) return "chrome"
if (/safari/i.test(ua) && !/crios|fxios|edgios/i.test(ua)) return "safari"
return "other"
}
function isDismissedForNow() { function isDismissedForNow() {
if (typeof window === "undefined") return true if (typeof window === "undefined") return true
const raw = window.localStorage.getItem(DISMISS_KEY) const raw = window.localStorage.getItem(DISMISS_KEY)
@ -41,17 +58,20 @@ function dismissForNow() {
// reell installasjonsvei: // reell installasjonsvei:
// - Android/Chrome-familien: ekte "Installer"-knapp via det fangede // - Android/Chrome-familien: ekte "Installer"-knapp via det fangede
// beforeinstallprompt-eventet (lib/pwa-install.ts). // beforeinstallprompt-eventet (lib/pwa-install.ts).
// - iOS Safari: Apple har ALDRI implementert beforeinstallprompt -- kun et // - iOS (Safari ELLER Chrome, siden iOS 16.4 -- se iosBrowser() over):
// Apple har ALDRI implementert beforeinstallprompt der -- kun et
// instruksjonsbanner (Del-ikon -> "Legg til på Hjemskjerm") er mulig. // instruksjonsbanner (Del-ikon -> "Legg til på Hjemskjerm") er mulig.
export function InstallPrompt() { export function InstallPrompt() {
const [dismissed, setDismissed] = useState(true) const [dismissed, setDismissed] = useState(true)
const [platform, setPlatform] = useState<"android" | "ios" | "none">("none") const [platform, setPlatform] = useState<"android" | "ios" | "none">("none")
const [iosBrowserKind, setIosBrowserKind] = useState<"safari" | "chrome" | "other">("other")
const [deferredEvent, setDeferredEvent] = useState<BeforeInstallPromptEvent | null>(null) const [deferredEvent, setDeferredEvent] = useState<BeforeInstallPromptEvent | null>(null)
useEffect(() => { useEffect(() => {
if (isStandalone() || isDismissedForNow()) return if (isStandalone() || isDismissedForNow()) return
if (isIOS()) { if (isIOS()) {
setPlatform("ios") setPlatform("ios")
setIosBrowserKind(iosBrowser())
setDismissed(false) setDismissed(false)
return return
} }
@ -117,8 +137,13 @@ export function InstallPrompt() {
</h2> </h2>
{platform === "ios" ? ( {platform === "ios" ? (
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}> <p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del){" "}
Safari, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "} {iosBrowserKind === "chrome"
? "oppe til høyre i adressefeltet"
: iosBrowserKind === "safari"
? "nederst på skjermen"
: "i nettleseren din"}
, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
«Legg til Hjem-skjerm». «Legg til Hjem-skjerm».
</p> </p>
) : ( ) : (

View file

@ -2049,6 +2049,10 @@ type ShotRecord = {
club: string club: string
distance_meters: number distance_meters: number
shared_round_message_id: string | null shared_round_message_id: string | null
start_lat: number
start_lng: number
end_lat: number
end_lng: number
} }
function ShotMeasurementEntry({ function ShotMeasurementEntry({
@ -2292,6 +2296,17 @@ function ShotMeasurementEntry({
) )
} }
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
function shotThumbnailUrl(shot: ShotRecord): string | null {
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
}
function ShotList({ function ShotList({
shots, shots,
onDelete, onDelete,
@ -2302,41 +2317,52 @@ function ShotList({
onShare?: (shot: ShotRecord) => void onShare?: (shot: ShotRecord) => void
}) { }) {
return ( return (
<ul className="flex w-full min-w-[14rem] flex-col gap-1 rounded-xl border border-border bg-card p-1.5"> <ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => ( {shots.map((shot) => {
<li key={shot.id} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"> const thumbUrl = shotThumbnailUrl(shot)
<span className="flex-1 truncate font-medium text-foreground"> return (
{shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span> <li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
</span> {thumbUrl ? (
{shot.shared_round_message_id ? ( // eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<span className="text-[11px] font-semibold text-primary">Delt</span> <img
) : onShare ? ( src={thumbUrl || "/placeholder.svg"}
<button alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
type="button" className="size-14 shrink-0 rounded-lg border border-border object-cover"
onClick={(e) => { />
e.stopPropagation() ) : null}
onShare(shot) <span className="flex-1 truncate font-medium text-foreground">
}} {shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span>
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline" </span>
> {shot.shared_round_message_id ? (
Del <span className="text-[11px] font-semibold text-primary">Delt</span>
</button> ) : onShare ? (
) : null} <button
{onDelete && ( type="button"
<button onClick={(e) => {
type="button" e.stopPropagation()
onClick={(e) => { onShare(shot)
e.stopPropagation() }}
onDelete(shot.id) className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
}} >
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`} Del
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" </button>
> ) : null}
<Trash2 className="size-4" aria-hidden="true" /> {onDelete && (
</button> <button
)} type="button"
</li> onClick={(e) => {
))} e.stopPropagation()
onDelete(shot.id)
}}
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-4" aria-hidden="true" />
</button>
)}
</li>
)
})}
</ul> </ul>
) )
} }

View file

@ -3,32 +3,47 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import mapboxgl from "mapbox-gl" import mapboxgl from "mapbox-gl"
import "mapbox-gl/dist/mapbox-gl.css" import "mapbox-gl/dist/mapbox-gl.css"
import { MapPin } from "lucide-react" import { Crosshair, MapPin } from "lucide-react"
import { haversineMeters } from "@/lib/geo"
type LngLat = { lng: number; lat: number } type LngLat = { lng: number; lat: number }
/** /**
* MapPointPicker * MapPointPicker
* *
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via * To ulike modi, styrt av `referencePoint`:
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per * - Start-steget (referencePoint IKKE satt): rendret kun når brukeren
* sheet-open (once per step, since start and end use separate mounted * velger "Velg punkt på kart". Trykk-for-å-plassere en markør, ett
* instances) and stays mounted until that step is left, so the Mapbox map is * engangs-GPS-oppslag kun for sentrering.
* created exactly once (empty-deps useEffect) taps, pans and marker drags * - Ball-steget (referencePoint satt, ADR-048/049-tillegg): rendres
* never re-initialise it. This keeps Mapbox map loads (which are billed) to a * ALLTID. Rent informativt kart (referansepunkt + løpende sporet
* single load per step-open. * posisjon + sanntidsavstand) -- IKKE trykkbart for å plassere ballen.
* Ballens posisjon bekreftes alltid med sporet GPS-posisjon
* ("Jeg er ved ballen nå"), aldri ved kart-trykk (brukerønske
* 2026-08-09: "bekreft ballens posisjon er unødvendig" -- fjernet
* trykk-alternativet som fantes en kort periode før dette).
*
* Alltid via next/dynamic(..., { ssr: false }) fra foreldre-komponenten.
* Mountes én gang per steg og forblir mountet til det steget forlates,
* selve Mapbox-KARTET initialiseres nøyaktig én gang (empty-deps
* useEffect) pans/zoom re-initialiserer det aldri. Dette holder
* Mapbox-kartlastninger (som faktureres) til én per steg-åpning.
*/ */
export function MapPointPicker({ export function MapPointPicker({
onConfirm, onConfirm,
referencePoint, referencePoint,
referenceLabel = "Utslag", referenceLabel = "Utslag",
}: { }: {
onConfirm: (lngLat: LngLat) => void /** method er "map_tap" ved trykk-bekreftelse (kun start-steget), "gps"
* ved "Jeg er ved ballen nå"-bekreftelse (kun ball-steget). */
onConfirm: (lngLat: LngLat, method: "gps" | "map_tap") => void
/** /**
* Satt KUN ballposisjon-steget (ADR-048-tillegg 2026-08-08): et fast, * Satt KUN ballposisjon-steget: et fast, ikke-flyttbart
* ikke-flyttbart referansepunkt (utslagspunktet) vises kartet, og * referansepunkt (utslagspunktet) vises kartet, og kartet forsøker å
* kartet forsøker å vise BÅDE dette og brukerens nåværende posisjon i * vise BÅDE dette og brukerens nåværende posisjon i samme bilde --
* samme bilde -- brukerønske: "slagpunktet må være en del av det jeg ser". * brukerønske: "slagpunktet må være en del av det jeg ser". Trigger
* samtidig løpende posisjonssporing (se lenger ned) -- ALDRI satt
* start-steget, som fortsatt kun gjør ett engangs-oppslag.
*/ */
referencePoint?: LngLat referencePoint?: LngLat
referenceLabel?: string referenceLabel?: string
@ -37,10 +52,17 @@ export function MapPointPicker({
const mapRef = useRef<mapboxgl.Map | null>(null) const mapRef = useRef<mapboxgl.Map | null>(null)
const markerRef = useRef<mapboxgl.Marker | null>(null) const markerRef = useRef<mapboxgl.Marker | null>(null)
const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null) const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null)
const liveMarkerRef = useRef<mapboxgl.Marker | null>(null)
const watchIdRef = useRef<number | null>(null)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [point, setPoint] = useState<LngLat | null>(null) const [point, setPoint] = useState<LngLat | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Løpende posisjon + avstand-fra-referansepunkt-så-langt (ADR-048-tillegg
// 2026-08-08, brukerønske: "jeg ønsker å se lengden så langt i sanntid
// mens jeg nærmer meg ballen"). Kun i bruk når referencePoint er satt.
const [livePoint, setLivePoint] = useState<LngLat | null>(null)
const [liveDistance, setLiveDistance] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
@ -48,7 +70,7 @@ export function MapPointPicker({
if (!container) return if (!container) return
if (!token) { if (!token) {
setError("Kartet er ikke tilgjengelig akkurat nå. Bruk «Min posisjon nå» i stedet.") setError("Kartet er ikke tilgjengelig akkurat nå.")
return return
} }
@ -58,9 +80,9 @@ export function MapPointPicker({
// Oslo-koordinat -- en kommentar her hevdet "real app centers on // Oslo-koordinat -- en kommentar her hevdet "real app centers on
// last-known position", men den logikken fantes aldri, kun påstanden. // last-known position", men den logikken fantes aldri, kun påstanden.
// Henter nå brukerens faktiske GPS-posisjon FØR kartet initialiseres // Henter nå brukerens faktiske GPS-posisjon FØR kartet initialiseres
// (kun ETT kall, samme som "Min posisjon nå"-veien -- ingen løpende // (ett engangs-oppslag for selve sentreringen), med Oslo som fallback
// watchPosition), med Oslo som fallback KUN hvis posisjon ikke kan // KUN hvis posisjon ikke kan hentes (avslått tillatelse, tidsavbrudd,
// hentes (avslått tillatelse, tidsavbrudd, ingen støtte). // ingen støtte).
// fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt -- // fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt --
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet // kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
// for bare å sentrere på ett av dem, slik at referansepunktet garantert // for bare å sentrere på ett av dem, slik at referansepunktet garantert
@ -97,22 +119,30 @@ export function MapPointPicker({
} }
// Tap to place/move a single draggable marker — never re-inits the map. // Tap to place/move a single draggable marker — never re-inits the map.
map.on("click", (e) => { // KUN på start-steget (ingen referencePoint): brukerønske 2026-08-09,
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat } // "bekreft ballens posisjon er unødvendig" -- ballens posisjon
if (!markerRef.current) { // bekreftes nå ALLTID med sporet GPS-posisjon ("Jeg er ved ballen
const marker = new mapboxgl.Marker({ draggable: true, color: referencePoint ? "#2f7a3f" : "#d2551a" }) // nå"), aldri ved å trykke på kartet. Kartet på ball-steget er dermed
.setLngLat(e.lngLat) // rent informativt (referansepunkt + sanntidsposisjon/-avstand), ikke
.addTo(map) // interaktivt for plassering.
marker.on("dragend", () => { if (!referencePoint) {
const p = marker.getLngLat() map.on("click", (e) => {
setPoint({ lng: p.lng, lat: p.lat }) const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
}) if (!markerRef.current) {
markerRef.current = marker const marker = new mapboxgl.Marker({ draggable: true, color: "#d2551a" })
} else { .setLngLat(e.lngLat)
markerRef.current.setLngLat(e.lngLat) .addTo(map)
} marker.on("dragend", () => {
setPoint(lngLat) const p = marker.getLngLat()
}) setPoint({ lng: p.lng, lat: p.lat })
})
markerRef.current = marker
} else {
markerRef.current.setLngLat(e.lngLat)
}
setPoint(lngLat)
})
}
} }
const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139] const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139]
@ -136,8 +166,46 @@ export function MapPointPicker({
initMap(fallbackCenter) initMap(fallbackCenter)
} }
// Løpende posisjonssporing -- KUN på ballposisjon-steget (referencePoint
// satt). Egen, bevisst unntak fra ADR-048s opprinnelige "ingen løpende
// watchPosition"-prinsipp (som gjaldt kostnadskontroll for KART-
// lastninger): watchPosition i seg selv koster ingenting hos Mapbox --
// det er ren nettleser-GPS, ikke et kartlastnings-kall -- og brukeren
// ba eksplisitt om sanntids-avstand mens de går mot ballen. Kartet
// forblir uansett kun ÉN instans (samme map-objekt gjenbrukes, aldri
// re-initialisert), kun MARKØRPOSISJONEN oppdateres løpende.
if (referencePoint && typeof navigator !== "undefined" && navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
if (cancelled) return
const p = { lng: pos.coords.longitude, lat: pos.coords.latitude }
setLivePoint(p)
setLiveDistance(haversineMeters(referencePoint, p))
if (mapRef.current) {
if (!liveMarkerRef.current) {
const el = document.createElement("div")
el.className = "size-4 rounded-full border-2 border-white bg-[#2b7fff] shadow-md"
liveMarkerRef.current = new mapboxgl.Marker({ element: el }).setLngLat([p.lng, p.lat]).addTo(mapRef.current)
} else {
liveMarkerRef.current.setLngLat([p.lng, p.lat])
}
}
},
() => {
// Feil/avslått tillatelse underveis -- degraderer grasiøst, bruker
// kan fortsatt trykke selv ut ballens posisjon på kartet.
},
{ enableHighAccuracy: true, maximumAge: 1000 },
)
}
return () => { return () => {
cancelled = true cancelled = true
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current)
}
liveMarkerRef.current?.remove()
liveMarkerRef.current = null
referenceMarkerRef.current?.remove() referenceMarkerRef.current?.remove()
referenceMarkerRef.current = null referenceMarkerRef.current = null
markerRef.current?.remove() markerRef.current?.remove()
@ -190,25 +258,56 @@ export function MapPointPicker({
</div> </div>
) : null} ) : null}
{loaded && !point && !error ? ( {/* Trykk-instruksjon KUN på start-steget -- ball-steget har ingen
trykk-handling lenger (se click-handler-kommentaren over). */}
{loaded && !point && !error && !referencePoint ? (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4"> <div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
<p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md"> <p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
<MapPin className="size-4 shrink-0" aria-hidden="true" /> <MapPin className="size-4 shrink-0" aria-hidden="true" />
{referencePoint ? "Trykk der ballen ligger" : "Trykk på kartet for å plassere punktet"} Trykk kartet for å plassere punktet
</p> </p>
</div> </div>
) : null} ) : null}
{/* Sanntids-avstand så langt -- kun ball-steget. Brukerønske
2026-08-08: "jeg ønsker å se lengden langt i sanntid mens
jeg nærmer meg ballen". */}
{referencePoint && liveDistance !== null ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="flex flex-col items-center gap-0.5 rounded-2xl bg-card/95 px-5 py-2.5 shadow-md">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Avstand langt
</span>
<span className="text-3xl font-extrabold tabular-nums leading-none text-foreground">
{Math.round(liveDistance)}
<span className="ml-1 text-lg font-bold text-muted-foreground">m</span>
</span>
</div>
</div>
) : null}
</div> </div>
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]"> <div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button {referencePoint ? (
type="button" <button
disabled={!point} type="button"
onClick={() => point && onConfirm(point)} disabled={!livePoint}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" onClick={() => livePoint && onConfirm(livePoint, "gps")}
> className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{referencePoint ? "Bekreft ballens posisjon" : "Bekreft punkt"} >
</button> <Crosshair className="size-5 shrink-0" aria-hidden="true" />
Jeg er ved ballen
</button>
) : (
<button
type="button"
disabled={!point}
onClick={() => point && onConfirm(point, "map_tap")}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
Bekreft punkt
</button>
)}
</div> </div>
</div> </div>
) )

View file

@ -6,6 +6,7 @@ import { Crosshair, LocateFixed, MapPin, X, ChevronLeft, Check } from "lucide-re
import { ClubPicker } from "@/components/teecup/club-picker" import { ClubPicker } from "@/components/teecup/club-picker"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { haversineMeters } from "@/lib/geo"
/** /**
* Lazy, client-only reference to the map step. Because dynamic() defers the * Lazy, client-only reference to the map step. Because dynamic() defers the
@ -26,7 +27,7 @@ const MapPointPicker = dynamic(
) )
type LngLat = { lng: number; lat: number } type LngLat = { lng: number; lat: number }
type Step = "start" | "map" | "end" | "end_map" | "club" | "result" type Step = "start" | "map" | "end" | "club" | "result"
type GeoStatus = "idle" | "loading" | "error" type GeoStatus = "idle" | "loading" | "error"
export type ShotMeasurementSheetProps = { export type ShotMeasurementSheetProps = {
@ -63,15 +64,7 @@ export type ShotMeasurementSheetProps = {
// The real app passes distanceMeters computed server-/caller-side. // The real app passes distanceMeters computed server-/caller-side.
function previewDistance(a: LngLat | null, b: LngLat | null): number { function previewDistance(a: LngLat | null, b: LngLat | null): number {
if (!a || !b) return 0 if (!a || !b) return 0
const R = 6371000 return Math.round(haversineMeters(a, b))
const toRad = (d: number) => (d * Math.PI) / 180
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const h =
Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2
return Math.round(2 * R * Math.asin(Math.sqrt(h)))
} }
export function ShotMeasurementSheet({ export function ShotMeasurementSheet({
@ -91,7 +84,6 @@ export function ShotMeasurementSheet({
const [endPoint, setEndPoint] = useState<LngLat | null>(null) const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = useState<GeoStatus>("idle") const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
const [endStatus, setEndStatus] = useState<GeoStatus>("idle")
const [club, setClub] = useState<string>("") const [club, setClub] = useState<string>("")
const [share, setShare] = useState(false) const [share, setShare] = useState(false)
@ -124,25 +116,6 @@ export function ShotMeasurementSheet({
) )
} }
// Ball position via GPS. Deliberately NOT auto-fired on step mount (real
// bug found 2026-08-08: firing immediately gave the user zero time to
// actually walk from the start point to the ball, so start/end ended up
// at ~the same spot and ~the same instant -- distance was always 0m).
// Requires an explicit "Jeg er ved ballen nå" tap once the user has
// actually walked there.
function measureEndPoint() {
setEndMethod("gps")
setEndStatus("loading")
acquirePosition(
(p) => {
setEndPoint(p)
setEndStatus("idle")
setStep("club")
},
() => setEndStatus("error"),
)
}
const distance = const distance =
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint) typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
@ -167,11 +140,7 @@ export function ShotMeasurementSheet({
function goBack() { function goBack() {
if (step === "map" || step === "end") { if (step === "map" || step === "end") {
setStartStatus("idle") setStartStatus("idle")
setEndStatus("idle")
setStep("start") setStep("start")
} else if (step === "end_map") {
setEndStatus("idle")
setStep("end")
} else if (step === "club") { } else if (step === "club") {
// Re-measure the ball position rather than reusing a stale fix. // Re-measure the ball position rather than reusing a stale fix.
setEndPoint(null) setEndPoint(null)
@ -198,7 +167,7 @@ export function ShotMeasurementSheet({
}) })
} }
const onMapStep = step === "map" || step === "end_map" const onMapStep = step === "map" || step === "end"
return ( return (
<div <div
@ -308,100 +277,27 @@ export function ShotMeasurementSheet({
</div> </div>
) : null} ) : null}
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
"map_tap" start-steget siden ingen referencePoint er satt. */}
{step === "map" ? <MapPointPicker onConfirm={(p) => { {step === "map" ? <MapPointPicker onConfirm={(p) => {
setStartPoint(p) setStartPoint(p)
setStep("end") setStep("end")
}} /> : null} }} /> : null}
{/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08,
brukerønske: "jeg skal se start og slutt et satelittfoto...
mens jeg går opp mot ballen", "jeg ønsker å se lengden langt
i sanntid"). Ingen egen valg-skjerm lenger -- MapPointPicker
selv viser BÅDE "Bekreft ballens posisjon" (trykket punkt) og
"Jeg er ved ballen nå" (nåværende sporede posisjon) i footeren
når referencePoint er satt, se den komponenten. */}
{step === "end" ? ( {step === "end" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-6 text-center">
{endStatus === "idle" ? (
<>
<p className="text-base text-muted-foreground text-pretty">
til der ballen ligger. Mål med GPS når du er fremme, eller
se kartet mens du går og pek selv ut hvor ballen ligger.
</p>
<button
type="button"
onClick={measureEndPoint}
className="flex min-h-16 w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Crosshair className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Jeg er ved ballen </span>
<span className="text-sm text-muted-foreground">Mål med GPS</span>
</span>
</button>
<button
type="button"
onClick={() => {
setEndMethod("map_tap")
setStep("end_map")
}}
className="flex min-h-16 w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
<MapPin className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Vis kart mens jeg går</span>
<span className="text-sm text-muted-foreground">
Se utslagspunktet kartet, og pek ut ballen
</span>
</span>
</button>
</>
) : null}
{endStatus === "loading" ? (
<>
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Finner posisjonen din </p>
<p className="text-sm text-muted-foreground text-pretty">
Stå ved ballen mens vi måler sluttpunktet.
</p>
</>
) : null}
{endStatus === "error" ? (
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left">
<p role="alert" className="text-base text-destructive text-pretty">
Kunne ikke måle ballposisjonen. Prøv igjen når du står ved ballen.
</p>
<button
type="button"
onClick={measureEndPoint}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5" aria-hidden="true" />
Prøv igjen
</button>
<button
type="button"
onClick={() => {
setEndStatus("idle")
setEndMethod("map_tap")
setStep("end_map")
}}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Velg punkt kart i stedet
</button>
</div>
) : null}
</div>
) : null}
{step === "end_map" ? (
<MapPointPicker <MapPointPicker
referencePoint={startPoint ?? undefined} referencePoint={startPoint ?? undefined}
referenceLabel="Utslag" referenceLabel="Utslag"
onConfirm={(p) => { onConfirm={(p, method) => {
setEndPoint(p) setEndPoint(p)
setEndMethod(method)
setStep("club") setStep("club")
}} }}
/> />

View file

@ -49,7 +49,22 @@ function isActive(pathname: string, href: string) {
return pathname === href || pathname.startsWith(`${href}/`) return pathname === href || pathname.startsWith(`${href}/`)
} }
export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) { export function BottomNav({
tabs = TABS,
disabledHrefs,
onDisabledClick,
}: {
tabs?: Tab[]
/**
* Fanenes hrefs som skal vises låst (ADR-049, /velkommen innlogget-
* men-ikke-fullført-profil skal se HELE den vanlige navigasjonen, ikke
* en avkortet versjon, men kun kunne til profilen før resten låses
* opp). Rendres som en `<button>` i stedet for `<Link>`, aldri
* `aria-current`, og navigerer aldri.
*/
disabledHrefs?: string[]
onDisabledClick?: (tab: Tab) => void
}) {
const pathname = usePathname() ?? "" const pathname = usePathname() ?? ""
return ( return (
@ -66,50 +81,60 @@ export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) {
<ul className="mx-auto flex max-w-xl items-stretch justify-around gap-1 px-2 pb-[env(safe-area-inset-bottom)]"> <ul className="mx-auto flex max-w-xl items-stretch justify-around gap-1 px-2 pb-[env(safe-area-inset-bottom)]">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon const Icon = tab.icon
const disabled = disabledHrefs?.includes(tab.href) ?? false
const active = const active =
isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false) !disabled &&
(isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false))
const itemClassName = cn(
"group relative flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[11px] font-semibold leading-none transition-colors",
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong",
disabled
? "text-neutral-400"
: active
? "text-tee-strong"
: "text-neutral-500 hover:text-neutral-900",
)
const iconWrapClassName = cn(
"relative flex h-8 w-12 items-center justify-center rounded-full transition-colors",
active ? "bg-tee/20" : "bg-transparent group-hover:bg-black/[0.04]",
)
const inner = (
<>
<span className={iconWrapClassName}>
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.6 : 2} aria-hidden="true" />
{typeof tab.badge === "number" && tab.badge > 0 && (
<span
className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[10px] font-bold text-white ring-2 ring-white"
aria-hidden="true"
>
{tab.badge > 9 ? "9+" : tab.badge}
</span>
)}
</span>
<span>
{tab.label}
{typeof tab.badge === "number" && tab.badge > 0 && (
<span className="sr-only">{`, ${tab.badge} nye`}</span>
)}
</span>
</>
)
return ( return (
<li key={tab.href} className="flex-1"> <li key={tab.href} className="flex-1">
<Link {disabled ? (
href={tab.href} <button
aria-current={active ? "page" : undefined} type="button"
className={cn( aria-disabled="true"
"group relative flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[11px] font-semibold leading-none transition-colors", onClick={() => onDisabledClick?.(tab)}
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong", className={itemClassName}
active
? "text-tee-strong"
: "text-neutral-500 hover:text-neutral-900",
)}
>
<span
className={cn(
"relative flex h-8 w-12 items-center justify-center rounded-full transition-colors",
active
? "bg-tee/20"
: "bg-transparent group-hover:bg-black/[0.04]",
)}
> >
<Icon {inner}
className="h-[22px] w-[22px]" </button>
strokeWidth={active ? 2.6 : 2} ) : (
aria-hidden="true" <Link href={tab.href} aria-current={active ? "page" : undefined} className={itemClassName}>
/> {inner}
{typeof tab.badge === "number" && tab.badge > 0 && ( </Link>
<span )}
className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[10px] font-bold text-white ring-2 ring-white"
aria-hidden="true"
>
{tab.badge > 9 ? "9+" : tab.badge}
</span>
)}
</span>
<span>
{tab.label}
{typeof tab.badge === "number" && tab.badge > 0 && (
<span className="sr-only">{`, ${tab.badge} nye`}</span>
)}
</span>
</Link>
</li> </li>
) )
})} })}

View file

@ -0,0 +1,170 @@
"use client"
// Ny landingsside for innlogget-men-ikke-fullført-profil (ADR-048-tillegg
// 2026-08-09 -- se ARCHITECTURE_DECISIONS.md). Erstatter det tidligere
// direkte hoppet til /account: brukeren møtte skjemaet FØR de fikk noen
// kontekst om hva TeeCup er, uten mulighet til å orientere seg -- brukeren
// rapporterte eksplisitt at dette var feil. Denne siden viser i stedet en
// vanlig PWA-installasjonsoppfordring, LÅSTE (men synlige, forklarte)
// hurtighandlinger, og selve TeeCup-presentasjonen (innhold hentet fra
// teecup-beskrivelse.md, skrevet om til kort UI-tekst) -- før brukeren
// dyttes inn i skjemaet.
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowRight, Calculator, Flag, KeyRound, Lock, Monitor, Trophy, Users } from "lucide-react"
import { InstallPrompt } from "@/components/install-prompt"
import { TeeCupWordmark } from "@/components/teecup/wordmark"
import { BottomNav } from "@/components/teecup/bottom-nav"
// Alle fanene unntatt "Profil" vises låst (brukerønske 2026-08-09: "Alle
// knappene bør egentlig være der, deaktivert, bortsett fra til profilen")
// -- selve navigasjonens FORM skal se ut som den vanlige appen, ikke en
// avkortet versjon, men kun profilen skal faktisk være nåbar herfra.
const LOCKED_NAV_HREFS = ["/dashboard", "/my-rounds", "/dashboard#kommende-turneringer", "/more"]
export function Velkommen({ firstName, displayName }: { firstName: string | null; displayName: string }) {
const router = useRouter()
const [showLockedHint, setShowLockedHint] = useState(false)
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/logg-inn")
}
}
return (
<div className="flex min-h-dvh flex-col bg-clubhouse-bg text-clubhouse-ink">
<header className="sticky top-0 z-10 border-b border-clubhouse-border bg-clubhouse-card/95 backdrop-blur pt-[env(safe-area-inset-top)] supports-[backdrop-filter]:bg-clubhouse-card/80">
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
<TeeCupWordmark className="h-7" />
<button
type="button"
onClick={handleLogout}
className="inline-flex min-h-[44px] items-center rounded-lg px-3 text-sm font-semibold text-clubhouse-muted transition-colors hover:bg-clubhouse-field hover:text-clubhouse-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
Logg ut
</button>
</div>
</header>
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-5 pb-24 pt-6">
<div>
<h1 className="text-2xl font-extrabold text-balance">{`Velkommen, ${firstName ?? displayName}!`}</h1>
<p className="mt-1 text-[0.9375rem] text-clubhouse-muted text-pretty">
Du er logget inn. Ett steg gjenstår før du kan bli med i runder og turneringer.
</p>
</div>
<InstallPrompt />
{/* Fullfør profilen -- primær CTA */}
<div className="flex flex-col gap-3 rounded-2xl border border-tee-strong/30 bg-tee/10 p-5">
<div>
<h2 className="text-base font-bold">Fullfør profilen din</h2>
<p className="mt-1 text-sm text-clubhouse-muted text-pretty">
Navn, fødselsdato, kjønn og HCP trengs for at runder og handicap skal beregnes riktig.
</p>
</div>
<Link
href="/account"
className="inline-flex min-h-[48px] w-fit items-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-bold text-white transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
Fyll ut
<ArrowRight aria-hidden="true" className="size-4" />
</Link>
</div>
{/* Låste hurtighandlinger -- synlige, forklarte, ikke skjulte. Trykk
viser en delt påminnelse (fast plassert over bunnnavigasjonen,
se lenger ned -- fungerer uansett om det er en hurtighandling
eller en bunnfane som trykkes) i stedet for å navigere. */}
<section aria-label="Hurtighandlinger (låst til profilen er fullført)" className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2 sm:gap-3">
<LockedAction icon={Flag} label="Ny runde" onClick={() => setShowLockedHint(true)} />
<LockedAction icon={Trophy} label="Ny turnering" onClick={() => setShowLockedHint(true)} />
<LockedAction icon={KeyRound} label="Bli med med kode" onClick={() => setShowLockedHint(true)} />
</div>
</section>
{/* Om TeeCup -- innhold fra teecup-beskrivelse.md, skrevet om til kort
UI-tekst (ikke limt inn rått). */}
<section className="flex flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 sm:p-6">
<h2 className="text-lg font-extrabold">Hva er TeeCup?</h2>
<p className="text-[0.9375rem] leading-relaxed text-clubhouse-muted text-pretty">
En mobil-først app for golfturneringer og live-scoring bygget for bedrifter og
vennegjenger som spiller sammen. Klubbhus-stemning, ikke countryklubb-snobberi.
</p>
<ul className="flex flex-col gap-2.5 text-[0.9375rem] text-clubhouse-ink">
<li className="flex items-start gap-2.5">
<Trophy aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-cup-strong" />
Ni turneringsformater fra vanlig slagspill til Scramble, Chapman og Money Ball.
</li>
<li className="flex items-start gap-2.5">
<Flag aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-tee-strong" />
Ekte, live scoreføring ute banen leaderboardet oppdaterer seg i sanntid.
</li>
<li className="flex items-start gap-2.5">
<Calculator aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-tee-strong" />
Fullt WHS-handicapsystem, ikke en forenklet tilnærming.
</li>
<li className="flex items-start gap-2.5">
<Users aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-cup-strong" />
Venner, delte runder og følg-live for tilskuere.
</li>
<li className="flex items-start gap-2.5">
<Monitor aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-tee-strong" />
Turneringsadministrasjon og -presentasjon fungerer minst like godt PC som telefon.
</li>
</ul>
</section>
</main>
{showLockedHint ? (
<div
role="alert"
className="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4"
style={{ bottom: "calc(56px + env(safe-area-inset-bottom) + 12px)" }}
>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-clubhouse-border bg-clubhouse-card p-3 text-sm shadow-lg shadow-black/10">
<span className="text-clubhouse-ink">Du fylle ut profilen din før du kan bruke dette.</span>
<Link
href="/account"
onClick={() => setShowLockedHint(false)}
className="shrink-0 font-semibold text-tee-strong underline-offset-2 hover:underline"
>
til skjemaet
</Link>
</div>
</div>
) : null}
<BottomNav disabledHrefs={LOCKED_NAV_HREFS} onDisabledClick={() => setShowLockedHint(true)} />
</div>
)
}
function LockedAction({
icon: Icon,
label,
onClick,
}: {
icon: typeof Flag
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
aria-disabled="true"
className="relative flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl border border-clubhouse-border bg-clubhouse-field px-2 py-3 text-center font-semibold text-clubhouse-muted transition-colors hover:bg-clubhouse-field/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<Lock aria-hidden="true" className="absolute right-2 top-2 size-3.5 text-clubhouse-muted" />
<Icon aria-hidden="true" className="size-5 sm:size-6" />
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
</button>
)
}

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@
## Kort sagt ## Kort sagt
TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget
for golfklubber, bedrifter og vennegjenger som spiller sammen. Navnet for bedrifter og vennegjenger som spiller sammen. Navnet
smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup" smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup"
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en (både hullet selv og pokalen konkurransen spilles om) — hele buen i en
konkurransedyktig runde, i ett ord. konkurransedyktig runde, i ett ord.
@ -14,7 +14,7 @@ TeeCup begynte som en avlegger av teeoff.no (banedata/booking), bygget
rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull. rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull.
Det har siden vokst til en selvstendig, kommersiell produkt med egen Det har siden vokst til en selvstendig, kommersiell produkt med egen
identitet (eget domene, teecup.golf, egen innlogging uavhengig av identitet (eget domene, teecup.golf, egen innlogging uavhengig av
teeoff) — solgt til klubber og bedrifter som kanskje aldri har hørt om teeoff) — solgt til bedrifter og andre som kanskje aldri har hørt om
moderselskapet. moderselskapet.
## Hva det er i dag ## Hva det er i dag
@ -36,6 +36,10 @@ moderselskapet.
personlig spillerprofil og historikk. personlig spillerprofil og historikk.
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen, - **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
med push-varsler. med push-varsler.
- **Turneringsadministrasjon og -presentasjon på PC** — selve
scoreføringen er mobil-først, men å sette opp og presentere en
turnering fungerer minst like godt fra en datamaskin som fra
telefonen.
## Personlighet ## Personlighet
@ -53,10 +57,10 @@ TeeCup fortsetter å utvide hvor mange måter man kan spille turnering på
— flere formater legges til etter hvert som brukere ber om dem, inkl. — flere formater legges til etter hvert som brukere ber om dem, inkl.
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda (denne er forresten bygget og live nå). Det skal etter hvert dekke enda
mer av det en golfklubb faktisk trenger for å drive turneringer — ikke mer av det en turneringsarrangør faktisk trenger — ikke bare
bare scoreføring, men hele reisen fra oppsett til sesongrangering. scoreføring, men hele reisen fra oppsett til sesongrangering. Målet er
Målet er å være det naturlige valget for klubber og bedrifter som vil å være det naturlige valget for bedrifter og vennegjenger som vil ha
ha noe raskere og triveligere enn tunge, gamle turneringssystemer. noe raskere og triveligere enn tunge, gamle turneringssystemer.
## Praktisk for logoen ## Praktisk for logoen