Compare commits

...

6 commits

Author SHA1 Message Date
2d1d0ba1d0 Dokumenter dashbord/BottomNav/veiviser/login/deling/slagmåling i CHANGELOG.md
Punkt 39-45: dashbord+BottomNav-reskin, ny-runde-veiviser + offisiell-
banesøk-produksjonsbug, /logg-inn som ekte innloggingsside, rundedeling-
kategoriregel-fiks (ADR-036 E), og slag-for-slag-avstandsmåling
inngangspunkt 1. Se de fem foregående commitene for koden.
2026-08-08 12:38:31 +02:00
9538dfec7f Slag-for-slag GPS-avstandsmåling: entry point i scoring-veiviseren (ADR-048)
Første inngangspunkt av to planlagte: en "Mål et slag"-knapp i
ScoringWizard sitt detalj-steg, rett etter kølle-plukkeren for utslaget.
Komponentene (components/shot/shot-measurement-sheet.tsx,
map-point-picker.tsx) kommer fra en egen, teknisk V0-prompt (design-
tokens fra DESIGN_SYSTEM.md spesifisert som hard begrensning, siden
dette er et ark INNI en eksisterende Forest Green-skjerm, ikke en ny
frittstående side) -- meget tro mot spesifikasjonen: korrekt
next/dynamic({ssr:false})-lasting av kartsteget, ingen mapbox-gl-import
på GPS-only-stien, kartet mountes kun én gang per arkåpning.

components/ui/textarea.tsx (ny shadcn-primitiv) lagt til. Eksisterende,
lokale ClubPicker i round-detail.tsx trukket ut til
components/teecup/club-picker.tsx (ren utrekking, ingen atferdsendring)
slik at måle-arket kan gjenbruke den fremfor V0s egen plassholder-kopi.

Wiret mot ekte backend: POST .../holes/{n}/shots ved innsending, valgfri
etterfølgende POST .../shots/{id}/share ved deling, shot-telling hentet
og vist i selve knappen ("Mål et slag (N målt)").

Gjenstår: inngangspunkt to (alltid-synlig merkelapp på hull-kortet for
retroaktiv måling + lagformat-støtte), ekte Mapbox-tokens, og migrasjon
060_round_shot.sql mot ekte teecup_db (ingen av disse rørt ennå).
2026-08-08 12:37:30 +02:00
8b1c73cf4f Fiks rundedeling: minst én kategori er nok, ikke alle (ADR-036 E)
Bruker rapporterte at en runde delt med kategorien "Make" ikke ble
synlig for vedkommendes ektefelle. Diagnostisert direkte mot ekte
teecup_db (skrivebeskyttede spørringer): eieren hadde kategorisert
ektefellen under tre kategorier, mens runden kun delte to av dem.
_can_view_round krevde at ALLE en venns kategorier måtte være i
rundens synlige sett -- en presisering fra 2026-07-29 som kun ble
dokumentert i kode-kommentarer, aldri i denne ADR-en, og som strammet
inn utover ADR-036 Beslutning B sin opprinnelige "minst én
kategori"-regel fra 2026-07-25.

Reversert til den opprinnelige regelen i alle fire duplikate SQL-steder
(_can_view_round, _friends_who_can_see_round, list_friends_on_course i
rounds.py; feed-listingen i round_messages.py) -- fra "NOT EXISTS en
kategori utenfor synlig sett" til "EXISTS en kategori innenfor synlig
sett". Scratch-verifisert i begge retninger: venn med overlappende
kategori slipper nå inn, venn med ingen overlappende kategori fortsatt
korrekt avvist.

Se CHANGELOG.md punkt 44 for full verifiseringsdetalj og bekreftelse
mot de ekte, virkelige dataene etter utrulling.
2026-08-08 12:37:09 +02:00
b0c65d983b Gjør /logg-inn til den ekte innloggingssiden
/logg-inn (clubhouse-palett, TeeCupAuth) var en foreldreløs V0-
utforskning -- ingenting i appen lenket eller redirectet dit, og
TeeCupAuth var 100% mock (sleep()-baserte stubber, hardkodet
demo-passord, falske invitasjonskoder, en falsk "2FA-kode sendes"-tekst
som aldri sendte noe). Flagget eksplisitt til bruker før arbeid startet,
siden dette er sikkerhetskritisk kode og omfanget var langt større enn
en ren redirect-ombytting.

Full port: ekte POST /auth/request-link, POST /auth/login-password
(identisk LoginResult-statushåndtering som den gamle LoginForm), GET
/public/tournaments/by-code/{code}. TwoFactorVerifyForm/
TwoFactorSetupForm gjenbrukt UENDRET (lavest mulig risiko for
sikkerhetskritisk 2FA-kode). app/page.tsx redusert til en tynn
videresending (autentisert -> /dashboard//account, uautentisert ->
/logg-inn), beholdt for gamle bokmerker. Gammel LoginForm slettet.
Alle ~11 uautentisert-redirect-steder (committet i forrige commit)
pekte allerede til /logg-inn.

Én reell bug funnet og rettet under scratch-verifisering:
app/logg-inn/page.tsx kalte redirect() INNI en try/catch som fanget
Next.js sin egen NEXT_REDIRECT-kontrollflyt-exception stille -- en
allerede innlogget bruker fikk se innloggingsskjemaet på nytt i stedet
for å bli sendt videre. Rettet ved å flytte redirect()-kallet utenfor
try/catch, samme mønster som den opprinnelige /-siden alltid brukte.

Se CHANGELOG.md punkt 42 for full verifiseringsdetalj.
2026-08-08 12:34:50 +02:00
11d10f7661 Erstatt /my-rounds/new med ny-runde-veiviser fra egen V0-prompt
Gammel components/new-round.tsx (3364 linjer) slettet, erstattet av
components/ny-runde/ + lib/ny-runde/ -- V0-eksporten (full teknisk spec,
null designføring) ported mot de faktiske backend-kontraktene i
app/routers/rounds.py: Gender-oversettelse, forenklet Tee-form (aldri
CR/Slope til klienten), CourseMeta-discriminated-union for teeoff- vs.
egen-bane, ekte POST /rounds -> sides -> participants-sekvens, ekte
kontosøk/gjeste-oppslag, ekte banesøk inkl. geolokasjon.

Fire bugs funnet og rettet under scratch-verifisering: manglende
forwardRef på TextInput (i selve V0-eksporten), manglende
tee-synk til eierens spillerkort etter banevalg (egen portingsfeil),
manglende Ingen/Alle-hurtigknapp i steg 5 (samme mangel rettet i appen
2026-08-06), og en produksjonsbug oppdaget EKTE brukere etter utrulling:
fetchFacilityCourses() leste responsen fra offisielt banesøk som en rå
array i stedet for {slug,name,courses}-objektet backend faktisk
returnerer -- ga en uendelig "Laster baner..."-hang for enhver runde
startet på en offisiell (teeoff-)bane. Rettet + lagt til reell
feilhåndtering i OfficialCourses (manglet helt, feilen ble tidligere
en stille, ufanget promise-rejection).

Se CHANGELOG.md punkt 41 og 43 for full verifiseringsdetalj.
2026-08-08 12:34:25 +02:00
8ef2f61f4d Reskinn dashbord + erstatt bunn-navigasjon med clubhouse-palett
Dashbordets egne JSX re-stylet til --clubhouse-*/--tee*/--cup*-tokens
(V0, egen parallell utforskning ved siden av Forest Green) -- delte
komponenter som RoundCard/TournamentCard urørt. Den gamle, lokale
BottomTabBar erstattet med components/teecup/bottom-nav.tsx (egen
V0-prompt, usePathname()-drevet aktiv-fane), koblet inn på alle syv
sider som viser den. To reelle bugs funnet og rettet under
scratch-verifisering: aktiv-fane-sammenligning som feilaktig strippet
#hash (ga to samtidig aktive faner), og manglende fane-gruppering for
my-friends/my-feed/my-notifications (løst med en matchPaths-mekanisme).

Se CHANGELOG.md punkt 39-40 for full verifiseringsdetalj.
2026-08-08 12:34:03 +02:00
43 changed files with 5560 additions and 4361 deletions

View file

@ -3207,6 +3207,37 @@ søk-UI i `round-detail.tsx`, "Gjest" omdøpt til "Medspiller" gjennomgående,
samme viewer-relative "Deg"-fiks portert til `round-stats.tsx`/
`round-scorecard.tsx`, som hadde samme latente bug).
### Beslutning E — Kategori-match ved rundevisibilitet: MINST ÉN, ikke ALLE
Beslutning B punkt 5 (over) spesifiserte fra start "minst én
`friend_categorization`-rad ... hvor kategorien finnes i
`round_visible_category`". En senere presisering 2026-07-29 (kun
dokumentert i kode-kommentarer, aldri i denne ADR-en — selve
dokumentasjonshullet som gjorde denne avviket vanskelig å spore) strammet
dette til at ALLE en venns kategorier måtte være i rundens synlige sett,
ikke bare én.
**Reversert til original Beslutning B-regel 2026-08-08**, etter at
brukeren rapporterte at en runde delt med kategorien "Make" ikke ble
synlig for vedkommendes ektefelle. Årsak: ektefellen var i tillegg
kategorisert "Storfamilie" (Beslutning A tillater flere kategorier per
venn), og siden "Storfamilie" ikke var huket av på DENNE runden, blokkerte
den strenge ALLE-regelen hele synligheten -- til tross for at den
relevante kategorien ("Make") faktisk var valgt. I praksis gjorde dette
det nærmest umulig å dele pålitelig med noen som var tagget i mer enn én
kategori, uten å huke av samtlige av dem hver gang.
**Ny/gjeninnført regel:** en venn ser runden hvis MINST ÉN av kategoriene
eieren har satt dem i, er i rundens synlige sett -- flere kategorier på
samme venn er bare flere sjanser til å matche, aldri en ekstra
begrensning. En venn med INGEN kategorier vises fortsatt aldri (uendret).
Rettet i fire duplikate SQL-steder (`app/routers/rounds.py`:
`_can_view_round`, `_friends_who_can_see_round`,
`list_friends_on_course`; `app/routers/round_messages.py`: feed-
listingen) -- samme sted-for-sted-duplisering som opprinnelig omtalt i
Beslutning B, nå alle konsistent på MINST ÉN-varianten.
---
## ADR-037: Individuelle turneringer, flerrunde-turneringer — grunnstruktur

View file

@ -8683,9 +8683,10 @@ Neste steg:
eksplisitt bekreftet at ekte `teecup_db` sin `datacl` var uendret
etterpå).
**Ikke rullet ut ennå** -- venter på samlet bekreftelse sammen med
resten av det som gjenstår i denne økten (slagmåling-frontend,
dashbord-V0-beslutning).
**Rullet ut 2026-08-08** sammen med punkt 40 og 41 (`docker compose
build teecup_frontend` + `up -d` -- ingen migrasjon, kun ny
frontend-image). Bekreftet ingen konsollfeil på
`https://teecup.golf/` etter omstart.
39. **Dashbordet reskinnet til "clubhouse"-paletten — 2026-08-07, bruker
bekreftet eksplisitt "full overtagelse, også bakgrunnen".** Egen,
@ -8745,7 +8746,424 @@ Neste steg:
utvidet korrekt og det eksisterende skjemaet (uendret) fungerte som før.
Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig.
**Ikke rullet ut ennå** -- venter på BottomTabBar-V0-eksporten (så
hele dashbordet, inkludert navigasjonen, kan rulles ut samlet i én
omgang i stedet for i to synlig usammenhengende steg) og på samlet
bekreftelse sammen med resten av det som gjenstår denne økten.
**Rullet ut 2026-08-08** sammen med BottomNav (punkt 40) og
ny-runde-veiviseren (punkt 41), som planlagt -- se punkt 41 for
utrullingsdetaljer.
40. **BottomTabBar erstattet med `BottomNav` fra egen V0-prompt —
2026-08-08.** Bruker ba eksplisitt om at bunn-navigasjonens utseende
skulle avgjøres av V0 selv, ikke av meg (se punkt 39) -- prompt skrevet
med eksplisitt kontekst om at komponenten deles mellom clubhouse- og
Forest Green-sider og må fungere rimelig godt mot begge, uten å få
oppgitt en fasit-retning. V0 valgte en bevisst palett-nøytral løsning:
en "flytende hvit flate" (halvtransparent hvit, backdrop-blur, egen
skygge) som ikke arver noen sideb palett -- kun den grønne
`tee`/`tee-strong`-aksenten (felles for begge paletter) brukes for
aktiv-tilstand.
Ny fil `components/teecup/bottom-nav.tsx`, eksporterer `BottomNav`
(ikke lenger `BottomTabBar`). Strukturell endring fra forgjengeren:
aktiv fane leses nå fra `usePathname()` internt (ikke en `active`-prop
utenfra) -- hrefs rettet fra V0s plassholdere (`/hjem`, `/runder` osv.)
til de faktiske rutene under integrering. Fjernet den gamle
`BottomTabBar`-definisjonen (og dens nå-ubrukte `C`-fargekonstant og
fire lucide-ikon-importer) fra `dashboard.tsx`; alle seks sider
(`dashboard.tsx`, `own-rounds.tsx`, `friends.tsx`, `account-
settings.tsx`, `feed.tsx`, `notifications.tsx`, `more-menu.tsx`)
importerer nå `BottomNav` fra den nye filen i stedet, uten `active`-
prop.
**To reelle bugs funnet og rettet UNDER scratch-verifisering (ikke i
selve V0-eksporten -- begge i MIN EGEN integreringskode):**
1. Forsøkte først å "forbedre" aktiv-fane-sammenligningen ved å
strippe bort et evt. `#hash` før sammenligning (siden "Turneringer"
sin href er `/dashboard#kommende-turneringer`). Dette var FEIL --
browser-verifisert til å få BÅDE "Hjem" og "Turneringer" til å vise
som aktive samtidig på `/dashboard`, siden begge da strippet til
samme sti. Reversert til V0s opprinnelige, rå strengsammenligning
(som aldri hadde dette problemet -- "Turneringer" matcher rett og
slett aldri via ren pathname, akkurat som forgjengeren uansett aldri
fremhevet den).
2. `/my-friends`, `/my-feed` og `/my-notifications` viste INGEN fane
som aktiv i det hele tatt (ren pathname-matching kjenner dem ikke
igjen som noen fanes mål) -- forgjengeren løste dette implisitt ved
at hver side selv sendte inn riktig `active`-prop. Lagt til en ny,
liten `matchPaths`-mekanisme på `Tab`-typen; "Mer" sin oppføring
fikk `matchPaths: ["/my-friends", "/my-feed", "/my-notifications"]`
-- speiler nøyaktig hvilke tre sider `more-menu.tsx` selv allerede
lister som sine undersider, ingen ny gruppering oppfunnet.
`tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (tiende
scratch-miljø denne økten, samme migrasjon-002-forsiktighet som
punkt 37-39, `teecup_db` bekreftet uendret): logget inn, besøkt alle
syv sider som bruker `BottomNav` (dashbord, my-rounds, my-friends,
account, my-feed, my-notifications, more) -- riktig (og ETT AV GANGEN,
etter fiks 1) fane fremhevet på hver, ingen konsollfeil. Scratch-miljøet
ryddet opp fullstendig.
**Rullet ut 2026-08-08** sammen med punkt 39 og 41 -- se punkt 41 for
utrullingsdetaljer.
41. **`/my-rounds/new` erstattet med ny-runde-veiviser fra egen V0-prompt
— 2026-08-08.** Samme mønster som punkt 40: prompt skrevet med full
teknisk spesifikasjon (alle 18 spilleformer, alle steg/felt/
valideringsregler, alle 12 backend-endepunkt, `submitWizard()`-
kontrakten) og eksplisitt UTEN visuell føring -- V0 fikk velge
utseendet fritt. Resultat: en ny, selvstendig palett (`--nr-*` i
`globals.css`, kald blå/grå, `--nr-accent: #1f5fd6`), additiv og
scoped til veiviseren alene, samme "co-eksisterende paletter via
Tailwind arbitrary-value-syntaks"-mønster som clubhouse-paletten
(punkt 39) -- ingen `@theme inline`-kobling, ingen påvirkning på
resten av appen.
Gammel `components/new-round.tsx` (3364 linjer) SLETTET -- bekreftet
via grep at kun `app/my-rounds/new/page.tsx` importerte den (de tre
andre grep-treffene, i `course-template-editor.tsx`/
`round-leaderboard.tsx`/`round-detail.tsx`, var bare beskrivende
norske kommentarer, urørt). Ny modul under
`components/ny-runde/` (`wizard-shell.tsx`, `wizard-context.tsx`,
fem steg-filer, `primitives.tsx`, `progress.tsx`, `footer-bar.tsx`,
`create-course-form.tsx`) + `lib/ny-runde/` (`types.ts`,
`formats.ts`, `api.ts`).
V0s eksport brukte mock-data gjennomgående -- hele portingsjobben
var å koble hvert steg til de faktiske backend-kontraktene fra
`app/routers/rounds.py` og den gamle `new-round.tsx`, IKKE bare et
overflatisk bytte av komponentnavn: `Gender`-oversettelse
(`"m"|"f"` API ↔ `"mann"|"kvinne"|"annet"` UI), `Tee`-formen
forenklet til kun `{id, name, genders}` (aldri CR/Slope til klienten),
`CourseMeta`-discriminated-union for teeoff- vs. egen-bane, ekte
`POST /rounds``POST .../sides``POST .../participants`
`PATCH .../participants/{id}`-sekvens i `submit()`, ekte
kontosøk/gjeste-oppslag i steg 3, ekte banesøk (teeoff-fasilitet +
egne baner, inkl. geolokasjon for "i nærheten") i steg 1.
**Fire reelle bugs funnet og rettet under scratch-verifisering:**
1. `TextInput` i `primitives.tsx` manglet `forwardRef` (feil i selve
V0-eksporten, ikke i portingen) -- ga 3 TS-feil der nedstrøms kode
sendte `ref` til komponenten. Rettet ved å pakke inn med
`forwardRef`, `tsc --noEmit` gikk fra 3 feil til rent.
2. Steg 3 viste "Utslag: Ikke valgt" for eieren selv etter at tee var
valgt i steg 1 -- min egen portingsfeil, jeg hadde ikke tatt med
den ekte `chooseCourse()`s side-effekt som synker valgt tee inn i
spillerlisten. Rettet ved å legge `players:
state.players.map(...)` til i `pickCourse()`
(`step1-course-time.tsx`). Verifisert rettet ved reload.
3. Manglende "Ingen"/"Alle"-hurtigknapp og "ingen kategori
valgt"-advarsel i steg 5s kategorivelger (samme mangel som ble
oppdaget og rettet i selve appen 2026-08-06, se punkt 36 -- V0s
eksport hadde ikke fått denne konteksten). Lagt til i
`step5-sharing.tsx`, samme mønster som punkt 36 (`role="group"`,
`aria-pressed`, `role="alert"` ved null valgt). Browser-verifisert
i scratch: "Ingen" tømmer alle 10 avkrysninger og viser advarselen,
"Alle" gjenoppretter alle.
4. Fulgte først en blindvei: trodde "Legg til gjest"-knappen i steg 3
var usynlig/klikk-slukende bak den klissede (`sticky bottom-0`)
`FooterBar`-en (samme feilklasse som `pb-32`-saken 2026-08-02,
nevnt i den gamle `new-round.tsx`s egne kommentarer) -- la til
`pb-28``<main>`. Padding-fiksen var faktisk RIKTIG og
nødvendig (uten den er det for lite scroll-klaring til å noensinne
få knappen helt fri av footeren), men den første reproduksjonen av
"feilen" var selv et testverktøy-artefakt: et koordinat-basert
klikk uten forutgående scroll traff footeren fordi den, ved
`scrollY: 0`, visuelt ligger over knappen (klissete element som
ikke har "festet seg" ennå fordi normal dokumentflyt allerede
plasserer det nederst i viewport). Bekreftet ved å måle
`getBoundingClientRect()` for begge før/etter scroll: uten scroll
overlapper de nesten fullstendig (y:776 vs. top:775); etter
`scrollIntoView` (174px, alt tilgjengelig scroll) står knappen på
y:602, godt klar. Et ekte, koordinat-basert klikk etter scroll
fungerte perfekt. Konklusjon: `pb-28`-fiksen var korrekt og er
beholdt (den gir nødvendig klaring når brukeren scroller helt ned),
ingen ytterligere kodefeil forelå.
`tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (ellevte
scratch-miljø denne økten, samme migrasjon-002-forsiktighet som
punkt 37-40, `teecup_db`s ACL bekreftet uendret før og etter): full
veiviser-gjennomkjøring for Fourball -- egen bane opprettet og valgt,
tee/HCP riktig synket til eier, gjest uten konto lagt til, begge
spillere tildelt hver sin side i steg 4, "Ingen"/"Alle"-toggle testet
i steg 5, fullført innsending. **DB-verifisert direkte**: `round`-rad
med riktig `play_format`/`visibility_mode`/bane-/tee-snapshot,
to `round_side`-rader, begge `round_participant`-rader korrekt
knyttet til hver sin `round_side_id` med riktig
HCP-/tee-/stat_level-snapshot. Ingen konsollfeil. Scratch-miljøet
(containere, images, DB, rolle) ryddet opp fullstendig etterpå.
**Rullet ut 2026-08-08** sammen med punkt 39 og 40, etter eksplisitt
brukerbekreftelse ("Rull ut alle tre samlet"): `docker compose build
teecup_frontend && docker compose up -d teecup_frontend` (kun
frontend-image, ingen migrasjon). Bekreftet `teecup_api`s image
UENDRET (bygget 2026-08-07, ikke i dag) -- containeren ble kun
re-opprettet fra samme eksisterende image pga. avhengighetskjeden i
`docker-compose.yml`, ikke bygget på nytt, så de uncommittede
backend-endringene som lå i arbeidstreet (`rounds.py`,
`handicap_engine.py` m.fl., urelatert til denne økten) ble IKKE
utilsiktet rullet ut. `https://teecup.golf/` bekreftet oppe, ingen
konsollfeil på innloggingssiden. Autentisert gjennomgang av
dashbord/BottomNav/ny-runde-veiviseren i produksjon overlatt til
brukeren selv (krever ekte pålogging).
42. **`/logg-inn` gjort til den EKTE innloggingssiden (erstatter `/` sin
gamle `LoginForm`) — 2026-08-08.** Brukeren la merke til at
innloggingssiden fortsatt viste "det gamle grensesnittet" etter
punkt 39-41s utrulling -- undersøkelse avdekket at `/logg-inn`
(clubhouse-palett, `TeeCupAuth`) var en fullstendig FORELDRELØS V0-
utforskning: INGENTING i appen lenket eller redirectet dit (bekreftet
ved grep), alle ~11 uautentisert-steder pekte fortsatt til `/` (gammel
`LoginForm`, Forest Green). Se dashboard.tsx sin egen kommentar
(linje 26-34) som allerede erkjente dette -- "fra en tidligere, ennå
ikke integrert /logg-inn-utforskning".
**Kritisk oppdagelse FØR arbeidet startet:** `TeeCupAuth` var 100 %
mock -- `apiSendMagicLink`/`apiPasswordLogin`/`apiJoinByCode` var alle
`sleep()`-baserte stubber (hardkodet demo-passord `"teecup123"`,
hardkodede demo-koder `TEECUP`/`RYDER-25`/`HOST2026`, en falsk
"2FA-kode sendes"-tekst som ALDRI faktisk sendte noe). Dette ble
eksplisitt flagget til brukeren (AskUserQuestion) FØR noe ble bygget,
siden dette er sikkerhetskritisk kode og omfanget var langt større enn
en ren redirect-ombytting -- brukeren bekreftet "gjør full port nå".
**Full port utført:**
- `components/teecup/teecup-auth.tsx` skrevet om fra bunnen: ekte
`POST /auth/request-link` (magic link), ekte
`POST /auth/login-password` (med `LoginResult`-status-håndtering
identisk med den gamle `LoginForm`), ekte
`GET /public/tournaments/by-code/{code}`. Alle demo-hint/mock-tekster
fjernet. `TwoFactorVerifyForm`/`TwoFactorSetupForm`
(`components/two-factor-flow.tsx`) gjenbrukt UENDRET -- samme
"delt komponent ikke re-stylet"-mønster som `RoundCard`/
`TournamentCard` i dashbord-reskinnet (punkt 39) -- lavest mulig
risiko for sikkerhetskritisk 2FA-kode.
- `app/logg-inn/page.tsx`: fikk samme server-side allerede-innlogget-
sjekk (`cookies()` + `/auth/me`) som `/`-siden hadde.
- `app/page.tsx` (root): redusert til en tynn videresending
(autentisert → `/dashboard`/`/account`, uautentisert → `/logg-inn`)
-- beholdt for gamle bokmerker/lenker til `teecup.golf/`. Gamle
`LoginForm`/`components/login-form.tsx` SLETTET (bekreftet ingen
andre importer via grep).
- Alle 11 `router.replace("/")`-steder (401-håndtering + logout) i
`dashboard.tsx`, `friends.tsx`, `more-menu.tsx`,
`account-settings.tsx`, `notifications.tsx`, `course-rounds.tsx`,
`own-rounds.tsx`, `rounds-stats-summary.tsx` byttet til
`router.replace("/logg-inn")`. `verify-form.tsx` sin
"Be om en ny lenke"-fallback-lenke (`href="/"`) byttet til
`/logg-inn`.
**Én reell bug funnet og rettet under scratch-verifisering:**
`app/logg-inn/page.tsx` kalte `redirect()` INNI `try`-blokken som
henter `/auth/me` -- Next.js sin `redirect()` fungerer ved å kaste en
egen `NEXT_REDIRECT`-kontrollflyt-exception som MÅ boble videre
urørt til Next.js sin render-maskineri. Den tomme `catch {}`-en rundt
slukte denne stille, så en allerede innlogget bruker som besøkte
`/logg-inn` fikk se innloggingsskjemaet på nytt i stedet for å bli
sendt videre -- oppdaget fordi en ekte innlogget test-bruker IKKE ble
omdirigert i scratch, mens en direkte nettleser-`fetch("/auth/me")`
(som går via `next.config.mjs` sin rewrite, ikke gjennom denne
server-komponentens egen kode) bekreftet sesjonen var helt gyldig --
avslørte at feilen satt i AKKURAT denne serverkomponentens egen
try/catch-struktur. Den opprinnelige `/`-sidens ekvivalente kode
unngikk dette ved å KUN sette `authenticated`/`profileComplete`-
variabler inni try/catch og kalle `redirect()` etterpå, UTENFOR
blokken -- samme mønster gjeninnført her. `tsc --noEmit` rent etter
fiks.
**Scratch-verifisert i ekte nettleser** (tolvte scratch-miljø denne
økten, samme migrasjon-002-forsiktighet som punkt 37-41 --
`teecup_db`s ACL bekreftet uendret før og etter; `TEECUP_DEV_LOG_MAGIC_LINKS=true`
brukt for å hente ekte magic-link-tokens fra API-loggen i stedet for å
sende ekte e-post til testadresser): full runde -- magic-link-
forespørsel + `/verify?token=`-innlogging (ny bruker auto-opprettet,
korrekt sendt til `/account` pga. ufullstendig profil), passord-
innlogging med feil passord (ekte feilmelding "E-post eller passord
er feil." fra `/auth/login-password`), invitasjonskode med ugyldig
kode (ekte feilmelding fra `/public/tournaments/by-code/`), logout
(→ `/logg-inn`), uautentisert besøk til `/dashboard` (→ `/logg-inn`,
ikke lenger `/`), `/` og `/logg-inn` besøkt allerede innlogget med
ufullstendig profil (→ `/account`) og med fullført profil
(→ `/dashboard`, satt via direkte `PATCH /auth/profile`-kall for å
unngå å klikke gjennom fødselsdato-datepickeren manuelt). Ingen
konsollfeil. **Ikke click-through-testet:** `2fa_required`-grenen
(krever en konto med 2FA allerede aktivert) -- vurdert lav risiko
siden `TwoFactorVerifyForm`/`TwoFactorSetupForm` er gjenbrukt helt
uendret fra den allerede beviste `LoginForm`-implementasjonen, kun
kablingen frem til dem (`handleLoginResult`) er ny kode, og den er
identisk portert fra `LoginForm`. Scratch-miljøet (containere, images,
DB, rolle) ryddet opp fullstendig etterpå, inkl. en disk-full-hendelse
underveis (`docker builder prune` frigjorde 11,75 GB build-cache fra
denne øktens mange scratch-bygg -- ingen kjørende containere eller
volumer berørt).
**Rullet ut 2026-08-08**, etter egen, eksplisitt brukerbekreftelse
separat fra punkt 39-41s batch-bekreftelse (sikkerhetskritisk --
autentisering). `docker compose build teecup_frontend && up -d` --
denne gangen ble kun `teecup_frontend` gjenskapt (`teecup_api` forble
"Running" med bekreftet uendret image-tidsstempel før/etter, ulikt
forrige utrulling i punkt 39-41 der en avhengighets-kjede-bivirkning
gjenskapte -- men ikke bygde om -- `teecup_api`). `https://teecup.golf/`
bekreftet å redirecte til `/logg-inn` med clubhouse-designet, ingen
konsollfeil.
43. **PRODUKSJONSBUG: "Laster baner…" hang for alltid ved offisiell
banevalg i ny-runde-veiviseren — funnet og fikset 2026-08-08.**
Brukeren rapporterte (skjermbilde) at steget etter å ha valgt et
teeoff-anlegg ("Baner") aldri kom videre fra "Laster baner…". Dette
hadde vært live siden punkt 41s utrulling -- treffer ALLE nye runder
som starter fra en offisiell (teeoff-) bane, ikke egne baner.
**Rotårsak:** `fetchFacilityCourses()` i `lib/ny-runde/api.ts` antok
at `GET /rounds/official-search/{slug}` returnerer banene direkte som
en array (`data.map(...)`), men det ekte endepunktet returnerer et
OBJEKT -- `OfficialFacilityDetail = {slug, name, courses: [...]}`
(`app/routers/rounds.py` linje 530). `data.map` er ikke en funksjon
på et objekt, så kallet kastet en `TypeError` -- og siden
`OfficialCourses` sin `useEffect` i `step1-course-time.tsx` kun hadde
`.then(...)` uten `.catch(...)`, ble denne exceptionen en stille,
ufanget promise-rejection: `setCourses` ble aldri kalt, og
`courses === null`-grenen ("Laster baner…") viste seg for alltid.
Bug i min egen porting (punkt 41) -- **denne konkrete stien
(offisiell teeoff-bane) ble aldri scratch-testet den runden**, kun
"egen bane"-veien ble klikket gjennom (se punkt 41s test-notat).
**To rettelser:**
1. `fetchFacilityCourses()`: leser nå `data.courses.map(...)` i
stedet for `data.map(...)`.
2. `OfficialCourses` (`step1-course-time.tsx`): la til en reell
`.catch()` + en `loadError`-tilstand (`role="alert"`, samme
`--nr-danger`-stil som resten av veiviseren) -- uten denne ville
ENHVER fremtidig feil her (f.eks. teeoff nede, 502) gitt nøyaktig
samme uendelige "Laster baner…"-hang på nytt, uansett om
datakontrakt-bugen over er rettet. Dette er defensivt, ikke bare
en fiks for det spesifikke tilfellet.
`tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (trettende
scratch-miljø denne økten, samme migrasjon-002-forsiktighet, `teecup_db`
bekreftet uendret) MOT EKTE teeoff-data (samme `teeoff_db`/`teeoff_api`
som produksjon deler, nådd direkte på `teeoff_default`-nettverket):
søkte opp "Hvaler Golfklubb" (reell fasilitet), banen ("Hovedbanen, 2
utslag") lastet korrekt i stedet for å henge, gikk videre til
felt-steget og bekreftet ekte utslag (Gul/Rød) hentet riktig fra
teeoff. Ingen konsollfeil (kun én forhåndseksisterende, ikke-relatert
"Deprecated feature"-info-melding fra nettleseren selv). Scratch-miljøet
ryddet opp fullstendig.
**Rullet ut umiddelbart** (produksjonsbug som traff alle live
brukere som prøvde å starte en runde på en offisiell bane) --
`docker compose build teecup_frontend && up -d`, kun frontend-image,
`teecup_api`s image-tidsstempel bekreftet uendret før/etter. Kunne
ikke click-through-verifisere selve produksjonssiden med ekte
brukerkonto (krever brukerens egen pålogging) -- basert på grundig
scratch-verifisering mot ekte teeoff-data rett før utrulling.
44. **PRODUKSJONSBUG: rundedeling med venner i flere kategorier virket
ikke som forventet — funnet og fikset 2026-08-08, se ADR-036
Beslutning E.** Brukeren rapporterte at en runde delt med kategorien
"Make" ikke ble synlig for vedkommendes ektefelle, til tross for at
"Make" var huket av. Diagnostisert direkte mot ekte `teecup_db`
(skrivebeskyttede spørringer): eieren (Erol) hadde kategorisert
ektefellen (Gina) under TRE kategorier (`close_family`,
`extended_family`, `spouse`), mens runden kun delte
(`close_family`, `golf_friends`, `spouse`) -- `extended_family`
manglet. `_can_view_round` krevde den gang at ALLE en venns
kategorier måtte være i rundens synlige sett (presisert av bruker
2026-07-29, kun dokumentert i kode-kommentarer, ALDRI i
ARCHITECTURE_DECISIONS.md -- selve dokumentasjonshullet som gjorde
dette vanskelig å spore tilbake), ikke bare én relevant kategori.
Brukeren fikk vist mekanismen konkret (hvilke kategorier Gina var
tagget i, hvilke runden delte, den strenge AND-regelen) og valgte via
et eksplisitt spørsmål å reversere til "minst én kategori er nok"
(som var den OPPRINNELIGE ADR-036 Beslutning B-regelen fra
2026-07-25 -- 2026-07-29-presiseringen hadde altså strammet inn en
regel utover det som noensinne ble ordentlig dokumentert som en
bevisst arkitekturbeslutning).
**Rettet i alle fire duplikate SQL-steder** (samme
kopier-inn-i-SQL-mønster som allerede omtalt i ADR-036 Beslutning B):
`app/routers/rounds.py` sin `_can_view_round` (selve
tilgangssjekken), `_friends_who_can_see_round` (varsel-fan-out ved
rundeopprettelse/fullføring), `list_friends_on_course`
(dashbordets "Venner på banen"); `app/routers/round_messages.py` sin
`/feed`-listing. Alle gikk fra "`NOT EXISTS` en kategori UTENFOR
synlig sett" til et enklere "`EXISTS` en kategori INNENFOR synlig
sett" -- enklere spørringer, ikke bare annen semantikk.
**Scratch-verifisert i ekte API-kall** (fjortende scratch-miljø denne
økten, samme migrasjon-002-forsiktighet, `teecup_db` bekreftet
uendret): gjenskapte den ekte Erol/Gina-situasjonen (venn kategorisert
i to kategorier, runde deler kun én av dem) -- vennen fikk nå 200 på
`GET /public/rounds/{id}` (tidligere 403). Bekreftet at motsatt
tilfelle (venn med INGEN overlappende kategori) fortsatt korrekt gir
403 -- ingen utilsiktet åpning av tilgangskontrollen. `/friends/
on-course` og `/feed` (de to andre endrede spørringene) begge 200
uten SQL-feil. Scratch-miljøet ryddet opp fullstendig.
**Rullet ut umiddelbart** (produksjonsbug, brukerens egen delte runde
var konkret berørt) -- `docker compose build teecup_api && up -d`,
KUN backend-image (ingen migrasjon, `visibility_mode`/
`round_visible_category` er uendret skjema), `teecup_frontend`s
image-tidsstempel bekreftet uendret før/etter. **Bekreftet direkte
mot ekte data etter utrulling**: samme spørring som
`_can_view_round` nå bruker, kjørt skrivebeskyttet mot den ekte
Erol/Gina/runde-situasjonen, returnerer nå `true`.
45. **Slag-for-slag GPS-avstandsmåling — inngangspunkt 1 av 2, i
scoring-veiviseren — 2026-08-08 (ADR-048).** Backend (migrasjon
`060_round_shot.sql`, `ShotIn`/`ShotOut`/`ShotShareIn`-endepunktene i
`rounds.py`, `lib/geo.ts` Haversine, Mapbox-token-plumbing) var
allerede bygget og scratch-verifisert fra tidligere i økten (se
ADR-048 i ARCHITECTURE_DECISIONS.md). Denne runden: selve
frontend-flaten.
V0-prompt skrevet med `DESIGN_SYSTEM.md`s tokens som en HARD
begrensning (i motsetning til dashbord-/veiviser-/login-promptene
tidligere i økten, som bevisst fikk null designføring) -- dette
arket lever INNI den eksisterende Forest Green-skjermen
(`round-detail.tsx`), ikke som en ny frittstående side. Eksporten
(zip 4) var meget tro mot spesifikasjonen: `next/dynamic({ssr:false})`
for kartsteget, ingen `mapbox-gl`-import i det hele tatt på
GPS-only-stien, kartet mountes kun én gang per arkåpning
(tom-deps `useEffect`, klikk/drag re-initialiserer aldri).
`components/shot/shot-measurement-sheet.tsx` +
`components/shot/map-point-picker.tsx` kopiert inn uendret (kun
én import-sti rettet: V0s egen plassholder-`ClubPicker` byttet til
den nå faktisk utrukne, delte `components/teecup/club-picker.tsx`
-- ren utrekking fra `round-detail.tsx`s tidligere lokale kopi,
ingen atferdsendring for veiviserens eksisterende bruk). Ny
`components/ui/textarea.tsx`-shadcn-primitiv lagt til (fantes ikke
fra før).
Koblet inn som `ScoringWizard`s nye `roundId`-prop + lokal
`shotSheetOpen`/`shotCount`-state: "Mål et slag"-knapp rett etter
kølle-plukkeren i detalj-steget, henter eksisterende slag-antall on
mount (`GET .../holes/{n}/shots`), sender til ekte
`POST .../holes/{n}/shots` ved innsending og (hvis deling valgt)
en påfølgende `POST /rounds/{id}/shots/{shot_id}/share`.
**Scratch-verifisert** (femtende scratch-miljø denne økten, samme
migrasjon-002-forsiktighet, `teecup_db` bekreftet uendret): full
klikk-gjennomgang fra "Mål et slag" til arket åpner riktig, korrekt
feilmelding+"Prøv igjen" når GPS-tillatelse mangler (bekreftet ekte
-- CDP-automatiserte nettlesersesjoner har `geolocation`-tillatelse
permanent `denied` uten noen dialog å akseptere, en verktøy-
begrensning i selve test-miljøet, ikke i appen). Siden selve
GPS-suksess-stien derfor ikke lot seg klikke gjennom i denne
økten, ble de eksakte kallene `submitShot()` sender (opprett slag,
list slag, del) i stedet verifisert direkte mot API-et med samme
data-kontrakt -- alle tre 200/201, ingen feil i API-loggen. Bekreftet
i ekte nettleser at slag-tellingen faktisk oppdateres og vises i
knappeteksten ("Mål et slag (1 målt)") etter et slag er opprettet.
Scratch-miljøet ryddet opp fullstendig.
**Ikke rullet ut** -- `round_shot`-tabellen finnes ennå ikke i ekte
`teecup_db` (migrasjonen er aldri kjørt der), og `.env` har fortsatt
tomme Mapbox-token-plassholdere (kart-veien vil ikke fungere i
produksjon før brukeren skaffer et ekte token). Gjenstår før dette
kan vurderes for utrulling: inngangspunkt 2 (alltid-synlig
"N slag målt"-merkelapp på selve hull-kortet, for retroaktiv måling
OG for lagformater/side-eide hull -- denne `ScoringWizard`-veien
dekker kun deltaker-eide hull, siden delt-ball-formater bruker en
egen, enklere side-veiviser uten dette detalj-steget), ekte
Mapbox-tokens, og migrering av `060_round_shot.sql` mot ekte
`teecup_db` (krever egen, eksplisitt brukerbekreftelse).

View file

@ -511,11 +511,7 @@ async def get_feed(
AND EXISTS (
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
)
AND NOT EXISTS (
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
AND fc.category NOT IN (
AND fc.category IN (
SELECT category FROM round_visible_category WHERE round_id = r.id
)
)

View file

@ -2031,13 +2031,15 @@ async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bo
# kategorisering av viewer (ikke omvendt -- det er eieren som
# begrenser, basert på egen gruppering, ADR-036 Beslutning B).
#
# Presisert av bruker 2026-07-29: "ikke vise" overstyrer "vise" -- er
# vennen satt i FLERE kategorier, holder det IKKE at én av dem er
# synlig for runden; ALLE vennens kategorier må være i rundens synlige
# sett. En venn med INGEN kategorier i det hele tatt vises ALDRI (samme
# runde: kategorisering er nå obligatorisk fra vennskapet inngås, se
# friends.py -- en ukategorisert venn er derfor alltid en igjenværende
# unntakstilstand fra FØR den regelen, ikke en normal, forventet sti).
# Regel: MINST ÉN av vennens kategorier må være i rundens synlige sett
# (ADR-036 Beslutning E, 2026-08-08). En strengere "ALLE kategorier må
# være synlige"-variant gjaldt 2026-07-29--2026-08-08, men ga et reelt
# forvirrende utfall i praksis (bruker delte en runde med "Make"
# huket av, men vedkommendes ektefelle -- ALSO tagget "Storfamilie" --
# fikk ikke se runden fordi den kategorien ikke var huket av). Reversert
# til den opprinnelige ADR-036 Beslutning B-regelen (som alltid sa
# "minst én kategori", se punkt 5 der) etter brukerrapport. En venn med
# INGEN kategorier i det hele tatt vises fortsatt ALDRI.
if viewer_user_id is None:
return False
owner_id = row["owner_user_id"]
@ -2060,11 +2062,7 @@ async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bo
SELECT EXISTS(
SELECT 1 FROM friend_categorization
WHERE owner_user_id = $1 AND friend_user_id = $2
)
AND NOT EXISTS(
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = $1 AND fc.friend_user_id = $2
AND fc.category NOT IN (SELECT category FROM round_visible_category WHERE round_id = $3)
AND category IN (SELECT category FROM round_visible_category WHERE round_id = $3)
)
""",
owner_id,
@ -2104,19 +2102,14 @@ async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) ->
return []
if row["visibility_mode"] == "public":
return friend_ids
# Samme "alle kategorier må være synlige, ikke bare én"-regel som
# _can_view_round -- en venn med minst én ikke-synlig kategori
# ekskluderes fra fan-out-varselet, selv om en annen av kategoriene
# deres er valgt.
# Samme "minst én kategori er nok"-regel som _can_view_round (ADR-036
# Beslutning E).
matched = await conn.fetch(
"""
SELECT fc.friend_user_id::text AS friend_id
SELECT DISTINCT fc.friend_user_id::text AS friend_id
FROM friend_categorization fc
WHERE fc.owner_user_id = $1 AND fc.friend_user_id = ANY($2::uuid[])
GROUP BY fc.friend_user_id
HAVING COUNT(*) FILTER (
WHERE fc.category NOT IN (SELECT category FROM round_visible_category WHERE round_id = $3)
) = 0
AND fc.category IN (SELECT category FROM round_visible_category WHERE round_id = $3)
""",
owner_user_id,
friend_ids,
@ -2183,11 +2176,7 @@ async def list_friends_on_course(user: CurrentUser = Depends(get_current_user))
AND EXISTS(
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
)
AND NOT EXISTS(
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
AND fc.category NOT IN (
AND fc.category IN (
SELECT category FROM round_visible_category WHERE round_id = r.id
)
)

View file

@ -237,3 +237,34 @@
--color-clubhouse-border: var(--clubhouse-border);
--color-clubhouse-field: var(--clubhouse-field);
}
/* ------------------------------------------------------------------ */
/* /my-rounds/new -- egen V0-utforskning (2026-08-08), sendt som en */
/* rendyrket TEKNISK spesifikasjon uten noen visuell føring -- V0 fikk */
/* fritt spillerom og valgte en tredje, egen palett (kald blå/grå), */
/* uavhengig av både "Forest Green" og "clubhouse". Skopet KUN til */
/* components/ny-runde/* via arbitrary-value var()-referanser */
/* (bg-[var(--nr-x)]), ikke via @theme inline -- rører derfor ingen */
/* andre tokens/sider. Eksporten refererte `font-display`/Bricolage -- */
/* bevisst IKKE tatt med her (samme utelatelse som clubhouse-runden), */
/* for å unngå en tredje font-avhengighet for én skjerm; ingen av */
/* filene under bruker faktisk `font-display` etter integrering. */
/* ------------------------------------------------------------------ */
:root {
--nr-bg: #e9edf2;
--nr-surface: #ffffff;
--nr-surface-2: #f4f6f9;
--nr-ink: #171b21;
--nr-muted: #59616d;
--nr-faint: #8b94a1;
--nr-border: #d5dae1;
--nr-border-strong: #b9c1cc;
--nr-accent: #1f5fd6;
--nr-accent-ink: #ffffff;
--nr-accent-soft: #e6eefc;
--nr-accent-ring: #9fbdf3;
--nr-danger: #b42318;
--nr-danger-soft: #fdeceb;
--nr-ok: #1f7a44;
--nr-ok-soft: #e6f4ec;
}

View file

@ -1,4 +1,6 @@
import type { Metadata } from "next"
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { TeeCupWordmark } from "@/components/teecup/wordmark"
import { TeeCupAuth } from "@/components/teecup/teecup-auth"
@ -7,7 +9,48 @@ export const metadata: Metadata = {
description: "Logg inn på TeeCup for live-scoring og golfturneringer med vennegjengen, klubben eller bedriften.",
}
export default function LoggInnPage() {
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
// app/t/[id]/page.tsx og den forrige 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 LoggInnPage() {
// Samme allerede-innlogget-sjekk som /-siden hadde (2026-08-08: /logg-inn
// overtok rollen som ekte innloggingsside, se CHANGELOG.md) -- en bruker
// med gyldig sesjonscookie skal aldri se innloggingsskjemaet på nytt.
const cookieStore = await cookies()
const session = cookieStore.get(SESSION_COOKIE_NAME)
let authenticated = false
let profileComplete = true
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)
}
} catch {
// API utilgjengelig -- vis innloggingsskjemaet 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
// (en catch der ville slukt den stille og latt innloggingsskjemaet vises
// i stedet for å omdirigere, reell bug funnet under scratch-verifisering
// 2026-08-08, se CHANGELOG.md).
if (authenticated) {
redirect(profileComplete ? "/dashboard" : "/account")
}
return (
<main className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-clubhouse-bg px-4 py-8 text-clubhouse-ink">
{/* Quiet turf-horizon backdrop — the one bit of atmosphere, kept subtle. */}

View file

@ -1,19 +1,19 @@
import { Suspense } from "react"
import { NewRound } from "@/components/new-round"
import { NewRoundWizard } from "@/components/ny-runde/wizard-shell"
export default function NewRoundPage() {
return (
<Suspense
fallback={
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
<div className="flex min-h-dvh items-center justify-center bg-[var(--nr-bg)]">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
className="h-10 w-10 animate-spin rounded-full border-4 border-[var(--nr-accent-ring)] border-t-[var(--nr-accent)]"
/>
</div>
}
>
<NewRound />
<NewRoundWizard />
</Suspense>
)
}

View file

@ -1,6 +1,5 @@
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { LoginForm } from "@/components/login-form"
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
@ -38,27 +37,15 @@ export default async function Page() {
authenticated = false
}
}
if (authenticated) {
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
// /account (som selv viser en fokusert fullførings-visning) i stedet
// for dashbordet -- se AccountSettings sin profile_complete-gren.
redirect(profileComplete ? "/dashboard" : "/account")
}
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
<div className="flex w-full max-w-sm flex-col gap-8">
{/* Redesignet kort (2026-08-01, V0/Forest Green) har sin egen
innebygde header (logo + tittel/undertekst per tilstand) --
den forrige separate Wordmark+tagline-headeren over kortet er
derfor fjernet for å unngå dobbel "TeeCup"-overskrift. */}
<LoginForm />
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">
Ved å logge inn godtar du våre vilkår og personvernregler.
</p>
</div>
</main>
)
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
// /account (som selv viser en fokusert fullførings-visning) i stedet
// for dashbordet -- se AccountSettings sin profile_complete-gren.
//
// /logg-inn overtok rollen som ekte innloggingsside 2026-08-08 (se
// CHANGELOG.md) -- denne siden (root) er nå bare en tynn videresending,
// beholdt for gamle bokmerker/lenker til teecup.golf/. Selve
// innloggingsskjemaet (LoginForm, Forest Green) er fjernet -- erstattet
// av TeeCupAuth (clubhouse-palett) på /logg-inn.
redirect(authenticated ? (profileComplete ? "/dashboard" : "/account") : "/logg-inn")
}

View file

@ -35,7 +35,7 @@ import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
import { BottomTabBar } from "@/components/dashboard"
import { BottomNav } from "@/components/teecup/bottom-nav"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer.
@ -339,7 +339,7 @@ export function AccountSettings() {
)}
</main>
<BottomTabBar active="profile" />
<BottomNav />
</div>
)
}
@ -416,7 +416,7 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
router.replace("/logg-inn")
}
}

View file

@ -101,7 +101,7 @@ export function CourseRounds({ courseName }: { courseName: string }) {
try {
const res = await fetch("/rounds", { credentials: "include" })
if (res.status === 401) {
router.replace("/")
router.replace("/logg-inn")
return
}
if (!res.ok) throw new Error(`rounds: ${res.status}`)

View file

@ -36,11 +36,18 @@
// visningsfonten (samme utelatelse som `/logg-inn` selv gjorde) -- Nunito
// beholdt for å unngå en ekstra font-avhengighet for kun to skjermer.
//
// Omfangsgrenser (delte komponenter IKKE re-stylet her, se CHANGELOG.md):
// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) og `BottomTabBar`
// (delt med de fem sidene som nettopp fikk den, venter på egen V0-prompt)
// beholder sin eksisterende styling inntil videre -- kun selve
// dashbord-skjermens EGNE, ikke-delte JSX er re-stylet i denne runden.
// Omfangsgrense (delt komponent IKKE re-stylet her, se CHANGELOG.md):
// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) beholder sin
// eksisterende styling inntil videre -- kun selve dashbord-skjermens
// EGNE, ikke-delte JSX er re-stylet i denne runden.
//
// Bunn-navigasjonen (2026-08-08, egen V0-prompt, se CHANGELOG.md): den
// gamle `BottomTabBar` (definert her, `active`-prop-styrt) er ERSTATTET av
// `BottomNav` (`components/teecup/bottom-nav.tsx`) -- en selvstendig,
// bevisst palett-nøytral "flytende hvit flate" som leser pathname selv
// via `usePathname()` i stedet for å få en `active`-prop utenfra, siden
// den deles mellom clubhouse- og Forest Green-sider. hrefs rettet til de
// faktiske rutene under integrering (V0 kjente dem ikke).
import type React from "react"
import { useCallback, useEffect, useState } from "react"
@ -50,18 +57,14 @@ import {
ArrowRight,
Bell,
Building2,
CalendarDays,
ChevronRight,
Flag,
Home,
KeyRound,
LogOut,
MapPin,
MoreHorizontal,
Trophy,
TrendingDown,
TrendingUp,
User,
UserCircle,
UserPlus,
X,
@ -77,36 +80,12 @@ import {
} from "@/components/ui/dropdown-menu"
import { InstallPrompt } from "@/components/install-prompt"
import { TeeCupWordmark } from "@/components/teecup/wordmark"
import { BottomNav } from "@/components/teecup/bottom-nav"
import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
// Låst "Forest Green"-palett -- v2 (2026-08-01, dashbord-fargekorrigering
// mot Stitch sitt faktiske skjermbilde). ALDRI #84b089/#a4a5a5 som tekst på
// mørkegrønn (#1a4325) -- målt ~4.5:1, under vårt AAA-mål. `primary` er
// bevisst IKKE Stitch sin rå pikselverdi (#2d950c, som målte kun 3,88:1 med
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
const C = {
card: "#ffffff",
glass: "rgba(255, 255, 255, 0.78)",
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
inkOn: "#ffffff",
primary: "#1f6b08", // lys, innbydende CTA-grønn -- hvit tekst/ikon (~6,6:1)
primaryInk: "#ffffff",
accent: "#1a4325", // mørkegrønn aksent (installer-banner-gradient)
accentMuted: "#bfeec4",
successBg: "#91f78e",
successInk: "#00731e",
muted: "#424941",
border: "#c1c9bf",
borderSoft: "#e4ebe3", // lysere strek for flatere/lysere overflater (Venner på banen)
tint: "#edf3ef",
warmBg: "#ffedd5",
warmInk: "#9a3412",
} as const
// Kategoriske pastellfarger for "Venner på banen" -- én tone per person, i
// stedet for én ensfarget tone. Hver par en lys bakgrunn med en mørk,
// kontrastrik tekst-tone for initialene.
@ -328,7 +307,7 @@ export function Dashboard() {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (!res.ok) {
router.replace("/")
router.replace("/logg-inn")
return
}
const data: Me = await res.json()
@ -338,7 +317,7 @@ export function Dashboard() {
}
setMe(data)
} catch {
router.replace("/")
router.replace("/logg-inn")
} finally {
setLoadingMe(false)
}
@ -446,7 +425,7 @@ export function Dashboard() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
router.replace("/logg-inn")
}
}
@ -458,7 +437,7 @@ export function Dashboard() {
)
}
if (!me) return null // router.replace("/") er allerede utløst
if (!me) return null // router.replace("/logg-inn") er allerede utløst
const rows = rounds ?? []
const activeRounds = rows.filter((r) => !r.completed_at)
@ -565,57 +544,11 @@ export function Dashboard() {
</div>
</main>
<BottomTabBar active="home" />
<BottomNav />
</div>
)
}
// Fast bunn-fanerad (2026-08-01, brukerens eget ønske etter å ha sett
// Stitch sin versjon) -- app-bred navigasjon, vist på ALLE skjermer som
// importerer denne. hrefs er bevisst RETTET fra V0 sitt forslag (/rounds,
// /tournaments) til de faktiske frontend-rutene -- /rounds er reservert
// API-prefiks (samme kollisjonsklasse som /rounds->/my-rounds tidligere i
// prosjektet), og det finnes ingen egen "/tournaments"-liste-side, kun
// /tournaments/[id]. "Turneringer" scroller derfor til dashbordets egen
// "Kommende turneringer"-seksjon i stedet for å lenke til en side som ikke
// finnes. "Mer" peker til en ny, minimal samleside (se app/more/page.tsx).
type TabKey = "home" | "rounds" | "tournaments" | "profile" | "more"
export function BottomTabBar({ active }: { active: TabKey }) {
const tabs: { key: TabKey; icon: typeof Flag; label: string; href: string }[] = [
{ key: "home", icon: Home, label: "Hjem", href: "/dashboard" },
{ key: "rounds", icon: CalendarDays, label: "Runder", href: "/my-rounds" },
{ key: "tournaments", icon: Trophy, label: "Turneringer", href: "/dashboard#kommende-turneringer" },
{ key: "profile", icon: User, label: "Profil", href: "/account" },
{ key: "more", icon: MoreHorizontal, label: "Mer", href: "/more" },
]
return (
<nav
aria-label="Hovednavigasjon"
className="fixed inset-x-0 bottom-0 z-20 border-t"
style={{ backgroundColor: C.card, borderColor: C.border, paddingBottom: "env(safe-area-inset-bottom)" }}
>
<ul className="mx-auto flex w-full max-w-2xl items-stretch justify-around px-1">
{tabs.map(({ key, icon: Icon, label, href }) => {
const isActive = key === active
return (
<li key={key} className="flex-1">
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className="flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-center transition-colors focus-visible:outline-none focus-visible:ring-2"
style={{ color: isActive ? C.primary : C.muted, outlineColor: C.accent }}
>
<Icon aria-hidden="true" className="size-6" strokeWidth={isActive ? 2.4 : 2} />
<span className="text-[11px] font-bold leading-none">{label}</span>
</Link>
</li>
)
})}
</ul>
</nav>
)
}
// V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications,
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som

View file

@ -5,7 +5,7 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { BottomTabBar } from "@/components/dashboard"
import { BottomNav } from "@/components/teecup/bottom-nav"
/** Rå feed-oppføring fra backend. */
export type ApiFeedEntry = {
@ -185,7 +185,7 @@ export function Feed() {
)}
</main>
<BottomTabBar active="more" />
<BottomNav />
</div>
)
}

View file

@ -23,7 +23,7 @@ import {
import { Input } from "@/components/ui/input"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
import { BottomTabBar } from "@/components/dashboard"
import { BottomNav } from "@/components/teecup/bottom-nav"
// --- Kategorier --------------------------------------------------------------
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
@ -130,7 +130,7 @@ export function Friends() {
try {
const res = await fetch("/friends", { credentials: "include" })
if (res.status === 401) {
router.replace("/")
router.replace("/logg-inn")
return
}
if (!res.ok) throw new Error(`friends: ${res.status}`)
@ -186,7 +186,7 @@ export function Friends() {
</div>
</main>
<BottomTabBar active="more" />
<BottomNav />
</div>
)
}

View file

@ -1,675 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import {
Mail,
Lock,
KeyRound,
ArrowLeft,
CheckCircle2,
Eye,
EyeOff,
Flag,
} from "lucide-react"
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
// Locked "Forest Green" palette (V0-redesign 2026-08-01, basert på Stitch --
// se DESIGN_SYSTEM.md sitt notat om denne runden). Anvendt inline med vilje,
// slik at ingenting tolkes om ved en senere V0-reeksport.
const C = {
card: "rgba(255, 255, 255, 0.72)",
ink: "#012c11", // primærtekst + primærknapp-bakgrunn
inkOn: "#ffffff",
accent: "#1a4325", // mørkegrønne aksenter (header-gradient)
accentMuted: "#bfeec4", // dempet tekst PÅ mørkegrønn -- ALDRI #84b089/#a4a5a5 her (målt ~4.5:1, under vårt AAA-mål)
successBg: "#91f78e",
successInk: "#00731e",
muted: "#424941", // dempet brødtekst på hvit bakgrunn
border: "#c1c9bf",
fieldBg: "#ffffff",
} as const
const RESEND_COOLDOWN = 30 // seconds
type SessionUser = {
id: string
email: string
display_name: string
preferred_locale: string
}
type LoginResult = {
status: "success" | "2fa_required" | "2fa_setup_required"
user?: SessionUser
two_factor_method?: "totp" | "email"
}
// ADR-021 Beslutning E: primær-autentisering (magic-link ELLER passord) kan
// returnere tre ulike utfall -- delt mellom denne komponenten og
// verify-form.tsx sin håndtering av magic-link-svaret.
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
type Mode = "default" | "sent" | "password" | "join" | "verify"
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
export function LoginForm() {
const router = useRouter()
const [mode, setMode] = useState<Mode>("default")
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null)
const [email, setEmail] = useState("")
const [emailTouched, setEmailTouched] = useState(false)
const [sending, setSending] = useState(false)
const [cooldown, setCooldown] = useState(0)
const [error, setError] = useState<string | null>(null)
const emailValid = isValidEmail(email)
const emailError = emailTouched && email.length > 0 && !emailValid
function handleLoginResult(result: LoginResult) {
if (result.status === "success") {
router.replace("/dashboard")
} else if (result.status === "2fa_required") {
setTwoFactorMethod(result.two_factor_method ?? "totp")
setPostAuth("2fa-verify")
} else {
setPostAuth("2fa-setup")
}
}
function handleTwoFactorSuccess() {
router.replace("/dashboard")
}
// Countdown timer for the resend cooldown.
useEffect(() => {
if (cooldown <= 0) return
const id = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
return () => clearInterval(id)
}, [cooldown])
async function sendLink() {
setSending(true)
setError(null)
try {
// Alltid samme suksess-respons uansett om e-posten finnes (anti-
// enumerering, se ADR-009) -- kun nettverks-/serverfeil havner i catch.
const res = await fetch("/auth/request-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
})
if (!res.ok) throw new Error(`request-link: ${res.status}`)
setMode("sent")
setCooldown(RESEND_COOLDOWN)
} catch {
setError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSending(false)
}
}
function submitDefault(e: React.FormEvent) {
e.preventDefault()
setEmailTouched(true)
if (!emailValid || sending) return
void sendLink()
}
function resend() {
if (cooldown > 0 || sending) return
void sendLink()
}
function backToDefault() {
setMode("default")
setEmailTouched(false)
setError(null)
}
return (
<div className="relative w-full">
{/* Soft green glow behind the card so the glass blur has something to work against */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-visible">
<div
className="absolute -left-10 -top-12 size-40 rounded-full blur-3xl"
style={{ backgroundColor: "rgba(26, 67, 37, 0.18)" }}
/>
<div
className="absolute -bottom-14 -right-8 size-44 rounded-full blur-3xl"
style={{ backgroundColor: "rgba(145, 247, 142, 0.28)" }}
/>
</div>
<div
className="w-full overflow-hidden rounded-3xl border backdrop-blur-xl"
style={{
backgroundColor: C.card,
borderColor: C.border,
boxShadow: "0 24px 60px -24px rgba(1, 44, 17, 0.45), 0 8px 20px -12px rgba(1, 44, 17, 0.2)",
}}
>
{/* 2FA bruker de EKSISTERENDE, sikkerhetskritiske komponentene uendret
(egen intern overskrift) -- kun logo-raden fra det nye headeret
vises over dem, ikke tittel/undertekst-blokken, for å unngå
dobbel overskrift. */}
<CardHeader mode={mode} showTitle={postAuth === null} />
<div className="flex flex-col gap-6 p-6 sm:p-7">
{postAuth === "2fa-verify" ? (
<TwoFactorVerifyForm
method={twoFactorMethod ?? "totp"}
onSuccess={handleTwoFactorSuccess}
onBack={() => setPostAuth(null)}
/>
) : postAuth === "2fa-setup" ? (
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
) : mode === "default" ? (
<DefaultState
email={email}
setEmail={setEmail}
emailError={emailError}
onBlur={() => setEmailTouched(true)}
onSubmit={submitDefault}
sending={sending}
error={error}
onPassword={() => setMode("password")}
onJoin={() => setMode("join")}
/>
) : mode === "sent" ? (
<SentState
email={email}
cooldown={cooldown}
sending={sending}
error={error}
onResend={resend}
onBack={backToDefault}
/>
) : mode === "password" ? (
<PasswordState onResult={handleLoginResult} onBack={() => setMode("default")} />
) : (
<JoinState onBack={() => setMode("default")} />
)}
</div>
</div>
</div>
)
}
/* --- Shared shell -------------------------------------------------------- */
const HEADINGS: Record<Mode, { title: string; subtitle: string }> = {
default: {
title: "Velkommen til TeeCup",
subtitle: "Logg inn for å følge turneringen din live.",
},
sent: {
title: "Sjekk innboksen din",
subtitle: "Vi har sendt deg en sikker innloggingslenke.",
},
password: {
title: "Logg inn med passord",
subtitle: "Skriv inn e-post og passord for å fortsette.",
},
join: {
title: "Gå rett til turneringen",
subtitle: "Har du fått en invitasjonskode? Skriv den inn her.",
},
verify: {
title: "Bekreft innloggingen",
subtitle: "Vi har sendt en kode for å bekrefte at det er deg.",
},
}
function CardHeader({ mode, showTitle }: { mode: Mode; showTitle: boolean }) {
const { title, subtitle } = HEADINGS[mode]
return (
<div
className="flex flex-col items-center gap-3 px-6 pt-8 pb-6 text-center"
style={{
background: `linear-gradient(180deg, ${C.accent} 0%, #123019 100%)`,
}}
>
<div className="flex items-center gap-2.5">
<div
className="flex size-11 items-center justify-center rounded-2xl"
style={{ backgroundColor: "rgba(255,255,255,0.12)" }}
>
<Flag aria-hidden="true" className="size-6 text-brand-orange" fill="currentColor" />
</div>
<span className="text-2xl font-extrabold tracking-tight" style={{ color: C.inkOn }}>
TeeCup
</span>
</div>
{showTitle && (
<div className="flex flex-col gap-1">
<h1 className="text-xl font-bold tracking-tight text-balance" style={{ color: C.inkOn }}>
{title}
</h1>
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.accentMuted }}>
{subtitle}
</p>
</div>
)}
</div>
)
}
/* --- Reusable field + button -------------------------------------------- */
function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
return (
<label htmlFor={htmlFor} className="text-sm font-semibold" style={{ color: C.ink }}>
{children}
</label>
)
}
function TextInput({
id,
icon: Icon,
invalid,
trailing,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
id: string
icon: typeof Mail
invalid?: boolean
trailing?: React.ReactNode
}) {
return (
<div className="relative">
<Icon
aria-hidden="true"
className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2"
style={{ color: C.muted }}
/>
<input
id={id}
name={id}
className="h-14 w-full rounded-2xl border pl-12 pr-4 text-base font-medium outline-none transition-shadow placeholder:font-normal focus-visible:ring-2 focus-visible:ring-[#1a4325] focus-visible:ring-offset-1"
style={{
backgroundColor: C.fieldBg,
borderColor: invalid ? "#b3261e" : C.border,
color: C.ink,
}}
{...props}
/>
{trailing}
</div>
)
}
function PrimaryButton({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className="flex h-14 w-full items-center justify-center rounded-2xl text-base font-bold transition-opacity disabled:opacity-60"
style={{ backgroundColor: C.ink, color: C.inkOn }}
{...props}
>
{children}
</button>
)
}
function BackLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl px-3 text-sm font-semibold transition-colors hover:underline"
style={{ color: C.accent }}
>
<ArrowLeft aria-hidden="true" className="size-4" />
{children}
</button>
)
}
function SecondaryLink({
icon: Icon,
children,
onClick,
}: {
icon: typeof Lock
children: React.ReactNode
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-12 items-center gap-2.5 rounded-xl px-2 text-left text-sm font-semibold transition-colors hover:underline"
style={{ color: C.accent }}
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
{children}
</button>
)
}
/* --- State 1: Default (magic link), ekte /auth/request-link -------------- */
function DefaultState({
email,
setEmail,
emailError,
onBlur,
onSubmit,
sending,
error,
onPassword,
onJoin,
}: {
email: string
setEmail: (v: string) => void
emailError: boolean
onBlur: () => void
onSubmit: (e: React.FormEvent) => void
sending: boolean
error: string | null
onPassword: () => void
onJoin: () => void
}) {
return (
<form onSubmit={onSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<FieldLabel htmlFor="email">E-post</FieldLabel>
<TextInput
id="email"
icon={Mail}
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={onBlur}
invalid={emailError}
aria-invalid={emailError}
aria-describedby={emailError ? "email-error" : undefined}
/>
{emailError && (
<p id="email-error" className="text-sm font-medium" style={{ color: "#b3261e" }}>
Skriv inn en gyldig e-postadresse.
</p>
)}
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
{error}
</p>
)}
<PrimaryButton type="submit" disabled={sending}>
{sending ? "Sender …" : "Send innloggingslenke"}
</PrimaryButton>
<p className="text-center text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
Ingen passord nødvendig. Vi sender deg en sikker lenke e-post.
</p>
<div className="flex flex-col gap-1 border-t pt-4" style={{ borderColor: C.border }}>
<SecondaryLink icon={Lock} onClick={onPassword}>
Logg inn med e-post og passord i stedet
</SecondaryLink>
<SecondaryLink icon={KeyRound} onClick={onJoin}>
Har du en invitasjonskode? rett til turneringen
</SecondaryLink>
</div>
</form>
)
}
/* --- State 2: Link sent, ekte resend/cooldown ----------------------------- */
function SentState({
email,
cooldown,
sending,
error,
onResend,
onBack,
}: {
email: string
cooldown: number
sending: boolean
error: string | null
onResend: () => void
onBack: () => void
}) {
const liveRef = useRef<HTMLDivElement>(null)
useEffect(() => {
liveRef.current?.focus()
}, [])
return (
<div className="flex flex-col items-center gap-5 text-center">
<div
ref={liveRef}
tabIndex={-1}
className="flex size-16 items-center justify-center rounded-2xl outline-none"
style={{ backgroundColor: C.successBg }}
>
<CheckCircle2 aria-hidden="true" className="size-9" style={{ color: C.successInk }} />
</div>
<div className="w-full rounded-2xl px-4 py-3" style={{ backgroundColor: C.successBg }} aria-live="polite">
<p className="text-sm font-semibold leading-relaxed" style={{ color: C.successInk }}>
Lenke sendt til <span className="font-extrabold">{email || "din e-post"}</span>
</p>
</div>
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
Åpne lenken denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
</p>
{error && (
<p role="alert" className="text-sm font-medium" style={{ color: "#b3261e" }}>
{error}
</p>
)}
<div className="w-full">
{cooldown > 0 ? (
<p className="text-sm" style={{ color: C.muted }} aria-live="polite">
Fikk du ingen e-post? Send igjen om{" "}
<span className="font-bold tabular-nums" style={{ color: C.ink }}>
{cooldown}s
</span>
</p>
) : (
<button
type="button"
onClick={onResend}
disabled={sending}
className="inline-flex min-h-12 items-center justify-center rounded-xl px-4 text-sm font-bold transition-opacity hover:underline disabled:opacity-60"
style={{ color: C.ink }}
>
{sending ? "Sender …" : "Send lenken igjen"}
</button>
)}
</div>
<div className="w-full border-t pt-4" style={{ borderColor: C.border }}>
<BackLink onClick={onBack}>Bruk en annen e-postadresse</BackLink>
</div>
</div>
)
}
/* --- State 3: Password, ekte /auth/login-password ------------------------ */
function PasswordState({
onResult,
onBack,
}: {
onResult: (result: LoginResult) => void
onBack: () => void
}) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email.trim() || !password || submitting) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/login-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), password }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "E-post eller passord er feil.")
}
onResult(await res.json())
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<FieldLabel htmlFor="pw-email">E-post</FieldLabel>
<TextInput
id="pw-email"
icon={Mail}
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<FieldLabel htmlFor="pw-pass">Passord</FieldLabel>
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
<TextInput
id="pw-pass"
icon={Lock}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Passordet ditt"
value={password}
onChange={(e) => setPassword(e.target.value)}
trailing={
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
className="absolute right-2 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-xl transition-colors"
style={{ color: C.muted }}
>
{showPassword ? <EyeOff aria-hidden="true" className="size-5" /> : <Eye aria-hidden="true" className="size-5" />}
</button>
}
/>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
{error}
</p>
)}
<PrimaryButton type="submit" disabled={submitting || !email.trim() || !password}>
{submitting ? "Logger inn …" : "Logg inn"}
</PrimaryButton>
<div className="border-t pt-4" style={{ borderColor: C.border }}>
<BackLink onClick={onBack}>Bruk innloggingslenke e-post i stedet</BackLink>
</div>
</form>
)
}
/* --- State 4: Join by code, ekte /public/tournaments/by-code -------------- */
function JoinState({ onBack }: { onBack: () => void }) {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = code.trim()
if (!trimmed || loading) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
return
}
const data: { tournament_id: string } = await res.json()
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
} catch {
setError("Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<FieldLabel htmlFor="invite-code">Invitasjonskode</FieldLabel>
<TextInput
id="invite-code"
icon={KeyRound}
type="text"
inputMode="text"
autoCapitalize="characters"
autoComplete="off"
autoFocus
placeholder="F.eks. RAPWAR"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
style={{ letterSpacing: "0.08em" }}
/>
<p className="text-sm leading-relaxed" style={{ color: C.muted }}>
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for å
se turneringen eller melde deg .
</p>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
{error}
</p>
)}
<PrimaryButton type="submit" disabled={loading || !code.trim()}>
{loading ? "Sjekker …" : "Gå til turnering"}
</PrimaryButton>
<div className="border-t pt-4" style={{ borderColor: C.border }}>
<BackLink onClick={onBack}>Tilbake til innlogging</BackLink>
</div>
</form>
)
}

View file

@ -1,16 +1,17 @@
"use client"
// "Mer"-siden (2026-08-01) -- den femte fanen i den nye bunn-navigasjonen
// (dashboard.tsx sin BottomTabBar) trengte et reelt mål, ikke en lenke til
// en side som ikke fantes. Samler destinasjoner som allerede finnes andre
// steder i appen (header, dashbordets organisasjon-fot) på ett sted, i
// stedet for å bygge noe nytt -- en ren navigasjons-samleside.
// "Mer"-siden (2026-08-01) -- den femte fanen i den faste bunn-
// navigasjonen (`components/teecup/bottom-nav.tsx`, ADR/V0 2026-08-08)
// trengte et reelt mål, ikke en lenke til en side som ikke fantes.
// Samler destinasjoner som allerede finnes andre steder i appen (header,
// dashbordets organisasjon-fot) på ett sted, i stedet for å bygge noe
// nytt -- en ren navigasjons-samleside.
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
import { BottomTabBar } from "@/components/dashboard"
import { BottomNav } from "@/components/teecup/bottom-nav"
const C = {
card: "#ffffff",
@ -38,7 +39,7 @@ export function MoreMenu() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
router.replace("/logg-inn")
}
}
@ -88,7 +89,7 @@ export function MoreMenu() {
</button>
</main>
<BottomTabBar active="more" />
<BottomNav />
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
import { BottomTabBar } from "@/components/dashboard"
import { BottomNav } from "@/components/teecup/bottom-nav"
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
@ -74,7 +74,7 @@ export function Notifications() {
fetch("/notifications", { credentials: "include" })
.then((res) => {
if (res.status === 401) {
router.replace("/")
router.replace("/logg-inn")
return null
}
return res.ok ? res.json() : []
@ -159,7 +159,7 @@ export function Notifications() {
)}
</main>
<BottomTabBar active="more" />
<BottomNav />
</div>
)
}

View file

@ -0,0 +1,384 @@
"use client"
import type { ApiGender, Course } from "@/lib/ny-runde/types"
import { createOwnCourse } from "@/lib/ny-runde/api"
import { cn } from "@/lib/utils"
import { Plus, Trash2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Field, NativeSelect, TextInput, Toggle, useAutoFocus } from "./primitives"
interface DraftRating {
cr: string
slope: string
par: string
}
interface DraftTee {
id: string
name: string
menEnabled: boolean
womenEnabled: boolean
men: DraftRating
women: DraftRating
}
interface DraftHole {
par: number
si: number | 0 // 0 = ikke satt
}
let teeCounter = 0
function newTee(): DraftTee {
teeCounter += 1
return {
id: `draft-tee-${teeCounter}`,
name: "",
menEnabled: true,
womenEnabled: false,
men: { cr: "", slope: "", par: "" },
women: { cr: "", slope: "", par: "" },
}
}
// seed.tees har KUN genders (ikke CR/Slope-tall) -- et forhåndsutfylt
// utslag fra en mal kan derfor bare forhåndsutfylle navn+hvilke(t) kjønn
// som er aktive, ikke selve ratingtallene (de er ikke kjent klient-side).
function seedToDraft(seed?: Course): { holes: DraftHole[]; tees: DraftTee[]; name: string } {
if (!seed) {
return {
name: "",
holes: Array.from({ length: 18 }, () => ({ par: 4, si: 0 })),
tees: [newTee()],
}
}
const holes: DraftHole[] = seed.templateHoles?.length
? seed.templateHoles.map((h) => ({ par: h.par, si: h.stroke_index }))
: Array.from({ length: 18 }, () => ({ par: 4, si: 0 }))
const tees: DraftTee[] = seed.templateTees?.length
? seed.templateTees.map((t) => {
teeCounter += 1
const men = t.ratings.find((r) => r.gender === "m")
const women = t.ratings.find((r) => r.gender === "f")
return {
id: `draft-tee-${teeCounter}`,
name: t.name,
menEnabled: !!men,
womenEnabled: !!women,
men: men ? { cr: String(men.course_rating), slope: String(men.slope_rating), par: String(men.par) } : { cr: "", slope: "", par: "" },
women: women ? { cr: String(women.course_rating), slope: String(women.slope_rating), par: String(women.par) } : { cr: "", slope: "", par: "" },
}
})
: [newTee()]
return { name: `${seed.name} (kopi)`, holes, tees }
}
function ratingComplete(r: DraftRating) {
return r.cr.trim() !== "" && r.slope.trim() !== "" && r.par.trim() !== ""
}
export function CreateCourseForm({
seed,
onCancel,
onCreate,
}: {
seed?: Course
onCancel: () => void
onCreate: (course: Course) => void
}) {
const initial = useMemo(() => seedToDraft(seed), [seed])
const [name, setName] = useState(initial.name)
const [holes, setHoles] = useState<DraftHole[]>(initial.holes)
const [tees, setTees] = useState<DraftTee[]>(initial.tees)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameRef = useAutoFocus<HTMLInputElement>(true)
const siValues = holes.map((h) => h.si)
const siError = useMemo(() => {
const filled = siValues.filter((v) => v >= 1 && v <= 18)
const set = new Set(filled)
const complete = set.size === 18 && filled.length === 18
return complete ? null : "Hver stroke-indeks (118) må brukes nøyaktig én gang."
}, [siValues])
const teesValid = tees.every((t) => {
if (!t.name.trim()) return false
if (!t.menEnabled && !t.womenEnabled) return false
if (t.menEnabled && !ratingComplete(t.men)) return false
if (t.womenEnabled && !ratingComplete(t.women)) return false
return true
})
const canSubmit = name.trim() !== "" && !siError && tees.length >= 1 && teesValid && !submitting
function setHole(i: number, patch: Partial<DraftHole>) {
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
}
function setTee(id: string, patch: Partial<DraftTee>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
}
function setTeeRating(id: string, gender: "men" | "women", patch: Partial<DraftRating>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, [gender]: { ...t[gender], ...patch } } : t)))
}
async function submit() {
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
const toRatings = (t: DraftTee) => {
const ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] = []
if (t.menEnabled) ratings.push({ gender: "m", course_rating: Number.parseFloat(t.men.cr), slope_rating: Number.parseInt(t.men.slope, 10), par: Number.parseInt(t.men.par, 10) })
if (t.womenEnabled) ratings.push({ gender: "f", course_rating: Number.parseFloat(t.women.cr), slope_rating: Number.parseInt(t.women.slope, 10), par: Number.parseInt(t.women.par, 10) })
return ratings
}
// Kun personlige baner (ikke offisielle teeoff-baner) kan "forkes" --
// seed.id er et syntetisk teeoff-N-id for offisielle baner, en ekte
// UUID for personlige (se courseFromOfficial/fetchOwnCourse i api.ts).
const forkedFromId = seed && !seed.id.startsWith("teeoff-") ? seed.id : null
const course = await createOwnCourse({
name: name.trim(),
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.si })),
tees: tees.map((t) => ({ name: t.name.trim(), ratings: toRatings(t) })),
forkedFromId,
})
onCreate(course)
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-5">
<Field label="Navn på banen" htmlFor="cc-name">
<TextInput
id="cc-name"
ref={nameRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Skogsbanen"
maxLength={120}
/>
</Field>
{error ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
{error}
</p>
) : null}
{/* Hull */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Hull</h3>
<span className="text-xs text-[var(--nr-muted)]">Par og stroke-indeks for 18 hull</span>
</div>
<div className="overflow-hidden rounded-xl border border-[var(--nr-border)]">
<div className="grid grid-cols-[auto_1fr_1fr] gap-px bg-[var(--nr-border)] text-xs">
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Hull</div>
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Par</div>
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Stroke-indeks</div>
{holes.map((h, i) => (
<HoleRow key={i} index={i} hole={h} onChange={(patch) => setHole(i, patch)} />
))}
</div>
</div>
{siError ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
{siError}
</p>
) : (
<p className="text-xs text-[var(--nr-ok)]">Alle stroke-indekser 118 er satt.</p>
)}
</div>
{/* Utslag */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Utslag</h3>
<button
type="button"
onClick={() => setTees((p) => [...p, newTee()])}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 text-[13px] font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Legg til utslag
</button>
</div>
{tees.map((t, i) => (
<div key={t.id} className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-3">
<div className="flex items-end gap-2">
<Field label="Navn på utslag" htmlFor={`${t.id}-name`} className="flex-1">
<TextInput
id={`${t.id}-name`}
value={t.name}
onChange={(e) => setTee(t.id, { name: e.target.value })}
placeholder={`Utslag ${i + 1}`}
invalid={t.name.trim() === ""}
className="bg-[var(--nr-surface)]"
/>
</Field>
<button
type="button"
onClick={() => tees.length > 1 && setTees((p) => p.filter((x) => x.id !== t.id))}
disabled={tees.length <= 1}
aria-label={`Fjern ${t.name.trim() || `utslag ${i + 1}`}`}
className="mb-0.5 flex h-11 w-11 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="mt-3 flex flex-col gap-3">
<RatingBlock
title="Herre-rating"
enabled={t.menEnabled}
onToggle={(v) => setTee(t.id, { menEnabled: v })}
rating={t.men}
onChange={(patch) => setTeeRating(t.id, "men", patch)}
idPrefix={`${t.id}-men`}
/>
<RatingBlock
title="Dame-rating"
enabled={t.womenEnabled}
onToggle={(v) => setTee(t.id, { womenEnabled: v })}
rating={t.women}
onChange={(patch) => setTeeRating(t.id, "women", patch)}
idPrefix={`${t.id}-women`}
/>
{!t.menEnabled && !t.womenEnabled ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
Minst én rating (herre eller dame) være aktiv for dette utslaget.
</p>
) : null}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={onCancel}
className="inline-flex min-h-11 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)]"
>
Avbryt
</button>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit}
className="inline-flex min-h-11 items-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Lagrer…" : "Lagre bane"}
</button>
</div>
</div>
)
}
function HoleRow({
index,
hole,
onChange,
}: {
index: number
hole: DraftHole
onChange: (patch: Partial<DraftHole>) => void
}) {
return (
<>
<div className="flex items-center bg-[var(--nr-surface)] px-3 py-1.5 text-sm font-medium tabular-nums text-[var(--nr-muted)]">
{index + 1}
</div>
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
<NativeSelect
aria-label={`Par for hull ${index + 1}`}
value={hole.par}
onChange={(e) => onChange({ par: Number.parseInt(e.target.value, 10) })}
className="h-9 bg-[var(--nr-surface-2)]"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</NativeSelect>
</div>
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
<NativeSelect
aria-label={`Stroke-indeks for hull ${index + 1}`}
value={hole.si || ""}
onChange={(e) => onChange({ si: Number.parseInt(e.target.value, 10) })}
className="h-9 bg-[var(--nr-surface-2)]"
>
<option value=""></option>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={si}>
{si}
</option>
))}
</NativeSelect>
</div>
</>
)
}
function RatingBlock({
title,
enabled,
onToggle,
rating,
onChange,
idPrefix,
}: {
title: string
enabled: boolean
onToggle: (v: boolean) => void
rating: DraftRating
onChange: (patch: Partial<DraftRating>) => void
idPrefix: string
}) {
return (
<div className={cn("rounded-lg border p-3", enabled ? "border-[var(--nr-accent-ring)] bg-[var(--nr-surface)]" : "border-[var(--nr-border)] bg-[var(--nr-surface)]")}>
<Toggle checked={enabled} onChange={onToggle} label={title} />
{enabled ? (
<div className="mt-3 grid grid-cols-3 gap-2">
<Field label="Course Rating" htmlFor={`${idPrefix}-cr`}>
<TextInput
id={`${idPrefix}-cr`}
inputMode="decimal"
value={rating.cr}
onChange={(e) => onChange({ cr: e.target.value })}
placeholder="72.1"
invalid={rating.cr.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
<Field label="Slope" htmlFor={`${idPrefix}-slope`}>
<TextInput
id={`${idPrefix}-slope`}
inputMode="numeric"
value={rating.slope}
onChange={(e) => onChange({ slope: e.target.value.replace(/[^\d]/g, "") })}
placeholder="128"
invalid={rating.slope.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
<Field label="Par" htmlFor={`${idPrefix}-par`}>
<TextInput
id={`${idPrefix}-par`}
inputMode="numeric"
value={rating.par}
onChange={(e) => onChange({ par: e.target.value.replace(/[^\d]/g, "") })}
placeholder="72"
invalid={rating.par.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,53 @@
"use client"
import { cn } from "@/lib/utils"
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react"
export function FooterBar({
onBack,
onNext,
nextLabel = "Neste",
nextDisabled,
backDisabled,
backLabel = "Tilbake",
submitting,
isSubmitStep,
}: {
onBack: () => void
onNext: () => void
nextLabel?: string
nextDisabled?: boolean
backDisabled?: boolean
backLabel?: string
submitting?: boolean
isSubmitStep?: boolean
}) {
return (
<div className="sticky bottom-0 z-10 border-t border-[var(--nr-border)] bg-[var(--nr-surface)]/90 backdrop-blur">
<div className="mx-auto flex max-w-2xl items-center justify-between gap-3 px-4 py-3">
<button
type="button"
onClick={onBack}
disabled={backDisabled}
className="inline-flex min-h-11 items-center gap-2 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:cursor-not-allowed disabled:opacity-40"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{backLabel}
</button>
<button
type="button"
onClick={onNext}
disabled={nextDisabled || submitting}
className={cn(
"inline-flex min-h-11 items-center gap-2 rounded-lg px-5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/50",
"bg-[var(--nr-accent)] text-[var(--nr-accent-ink)] hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40",
)}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
{nextLabel}
{!submitting && !isSubmitStep ? <ArrowRight className="h-4 w-4" aria-hidden="true" /> : null}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,455 @@
"use client"
import { cn } from "@/lib/utils"
import { Check, Minus, Plus } from "lucide-react"
import type React from "react"
import { forwardRef, useEffect, useId, useRef, useState } from "react"
/* ------------------------------------------------------------------ */
/* Debounce hook */
/* ------------------------------------------------------------------ */
export function useDebouncedValue<T>(value: T, delay = 250): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(t)
}, [value, delay])
return debounced
}
/* ------------------------------------------------------------------ */
/* Field wrapper */
/* ------------------------------------------------------------------ */
export function Field({
label,
hint,
error,
optional,
htmlFor,
children,
className,
}: {
label?: string
hint?: string
error?: string | null
optional?: boolean
htmlFor?: string
children: React.ReactNode
className?: string
}) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label ? (
<label
htmlFor={htmlFor}
className="text-[13px] font-medium tracking-tight text-[var(--nr-ink)]"
>
{label}
{optional ? <span className="ml-1 font-normal text-[var(--nr-faint)]">(valgfritt)</span> : null}
</label>
) : null}
{children}
{hint && !error ? <p className="text-xs leading-relaxed text-[var(--nr-muted)]">{hint}</p> : null}
{error ? (
<p role="alert" className="text-xs leading-relaxed text-[var(--nr-danger)]">
{error}
</p>
) : null}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Text input / textarea / native select */
/* ------------------------------------------------------------------ */
const controlBase =
"w-full rounded-lg border bg-[var(--nr-surface-2)] px-3 text-[16px] text-[var(--nr-ink)] outline-none transition-colors placeholder:text-[var(--nr-faint)] focus-visible:border-[var(--nr-accent)] focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:opacity-50"
export const TextInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }>(
({ className, invalid, ...props }, ref) => (
<input
ref={ref}
className={cn(
controlBase,
"h-11",
invalid ? "border-[var(--nr-danger)]" : "border-[var(--nr-border)]",
className,
)}
aria-invalid={invalid || undefined}
{...props}
/>
),
)
TextInput.displayName = "TextInput"
export const TextArea = ({
className,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
<textarea className={cn(controlBase, "min-h-[44px] py-2.5 border-[var(--nr-border)]", className)} {...props} />
)
export const NativeSelect = ({
className,
invalid,
children,
...props
}: React.SelectHTMLAttributes<HTMLSelectElement> & { invalid?: boolean }) => (
<div className="relative">
<select
className={cn(
controlBase,
"h-11 appearance-none pr-9",
invalid ? "border-[var(--nr-danger)]" : "border-[var(--nr-border)]",
className,
)}
aria-invalid={invalid || undefined}
{...props}
>
{children}
</select>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-muted)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)
/* ------------------------------------------------------------------ */
/* Choice card (single or multi select) */
/* ------------------------------------------------------------------ */
export function ChoiceCard({
selected,
onSelect,
title,
description,
icon,
disabled,
className,
}: {
selected: boolean
onSelect: () => void
title: React.ReactNode
description?: React.ReactNode
icon?: React.ReactNode
disabled?: boolean
className?: string
}) {
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={selected}
className={cn(
"group relative flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-all min-h-11",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
selected
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface)] hover:border-[var(--nr-border-strong)]",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
{icon ? (
<span
className={cn(
"mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg",
selected ? "bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "bg-[var(--nr-surface-2)] text-[var(--nr-muted)]",
)}
>
{icon}
</span>
) : null}
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-semibold text-[var(--nr-ink)]">{title}</span>
{description ? (
<span className="text-xs leading-relaxed text-[var(--nr-muted)]">{description}</span>
) : null}
</span>
<span
aria-hidden="true"
className={cn(
"ml-auto mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors",
selected ? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "border-[var(--nr-border-strong)]",
)}
>
{selected ? <Check className="h-3.5 w-3.5" strokeWidth={3} /> : null}
</span>
</button>
)
}
/* ------------------------------------------------------------------ */
/* Segmented control */
/* ------------------------------------------------------------------ */
export function Segmented<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (v: T) => void
options: { value: T; label: string }[]
label?: string
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="inline-flex w-full rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-1"
>
{options.map((o) => {
const active = o.value === value
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(o.value)}
className={cn(
"min-h-9 flex-1 rounded-md px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
active
? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm"
: "text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
)}
>
{o.label}
</button>
)
})}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Toggle switch */
/* ------------------------------------------------------------------ */
export function Toggle({
checked,
onChange,
label,
description,
}: {
checked: boolean
onChange: (v: boolean) => void
label: string
description?: string
}) {
const id = useId()
return (
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<label htmlFor={id} className="text-sm font-medium text-[var(--nr-ink)]">
{label}
</label>
{description ? <span className="text-xs text-[var(--nr-muted)]">{description}</span> : null}
</div>
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
checked ? "bg-[var(--nr-accent)]" : "bg-[var(--nr-border-strong)]",
)}
>
<span
className={cn(
"inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform",
checked ? "translate-x-[22px]" : "translate-x-0.5",
)}
/>
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Stepper (numeric, +/-) */
/* ------------------------------------------------------------------ */
export function Stepper({
value,
onChange,
min = 0,
max = 100,
step = 5,
snap = false,
suffix,
}: {
value: number
onChange: (v: number) => void
min?: number
max?: number
step?: number
snap?: boolean
suffix?: string
}) {
const [text, setText] = useState(String(value))
useEffect(() => setText(String(value)), [value])
const clamp = (n: number) => Math.min(max, Math.max(min, n))
function commit() {
const parsed = Number.parseInt(text, 10)
if (Number.isNaN(parsed)) {
setText(String(value))
return
}
let next = clamp(parsed)
if (snap) next = clamp(Math.round(next / step) * step)
onChange(next)
setText(String(next))
}
return (
<div className="inline-flex items-stretch overflow-hidden rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)]">
<button
type="button"
aria-label="Reduser"
onClick={() => onChange(clamp(value - step))}
disabled={value <= min}
className="flex h-11 w-11 items-center justify-center text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] disabled:opacity-40"
>
<Minus className="h-4 w-4" strokeWidth={2.5} />
</button>
<div className="flex items-center border-x border-[var(--nr-border)] px-1">
<input
inputMode="numeric"
value={text}
onChange={(e) => setText(e.target.value.replace(/[^\d]/g, ""))}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur()
}}
className="h-11 w-12 bg-transparent text-center text-[16px] font-semibold tabular-nums text-[var(--nr-ink)] outline-none"
aria-label="Verdi"
/>
{suffix ? <span className="pr-1 text-sm text-[var(--nr-muted)]">{suffix}</span> : null}
</div>
<button
type="button"
aria-label="Øk"
onClick={() => onChange(clamp(value + step))}
disabled={value >= max}
className="flex h-11 w-11 items-center justify-center text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] disabled:opacity-40"
>
<Plus className="h-4 w-4" strokeWidth={2.5} />
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Small pill / badge */
/* ------------------------------------------------------------------ */
export function Pill({
children,
tone = "neutral",
className,
}: {
children: React.ReactNode
tone?: "neutral" | "accent" | "ok" | "danger"
className?: string
}) {
const tones: Record<string, string> = {
neutral: "bg-[var(--nr-surface-2)] text-[var(--nr-muted)] border-[var(--nr-border)]",
accent: "bg-[var(--nr-accent-soft)] text-[var(--nr-accent)] border-[var(--nr-accent-ring)]",
ok: "bg-[var(--nr-ok-soft)] text-[var(--nr-ok)] border-[var(--nr-ok)]/30",
danger: "bg-[var(--nr-danger-soft)] text-[var(--nr-danger)] border-[var(--nr-danger)]/30",
}
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
tones[tone],
className,
)}
>
{children}
</span>
)
}
/* ------------------------------------------------------------------ */
/* Toggle-button grid item (used for formats / numeric picks) */
/* ------------------------------------------------------------------ */
export function ToggleButton({
active,
onClick,
children,
className,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
className?: string
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
active
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
className,
)}
>
{children}
</button>
)
}
/* ------------------------------------------------------------------ */
/* Avatar initials */
/* ------------------------------------------------------------------ */
export function initials(first: string, last?: string) {
const a = first?.trim()?.[0] ?? "?"
const b = last?.trim()?.[0] ?? ""
return (a + b).toUpperCase()
}
export function Avatar({ first, last, className }: { first: string; last?: string; className?: string }) {
return (
<span
aria-hidden="true"
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--nr-surface-2)] text-xs font-semibold text-[var(--nr-muted)] ring-1 ring-inset ring-[var(--nr-border)]",
className,
)}
>
{initials(first, last)}
</span>
)
}
/** Card container used throughout the wizard body. */
export function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-4 sm:p-5", className)}>
{children}
</div>
)
}
/** Keep a ref-focus helper for autofocus fields on mount. */
export function useAutoFocus<T extends HTMLElement>(active = true) {
const ref = useRef<T>(null)
useEffect(() => {
if (active) ref.current?.focus()
}, [active])
return ref
}

View file

@ -0,0 +1,67 @@
"use client"
import { cn } from "@/lib/utils"
import { Check } from "lucide-react"
import { useWizard } from "./wizard-context"
const STEP_LABELS: Record<number, string> = {
1: "Bane & tid",
2: "Spilleform",
3: "Spillere",
4: "Lag og rekkefølge",
5: "Deling",
}
export function ProgressSteps() {
const { state, visibleSteps } = useWizard()
const currentIdx = visibleSteps.indexOf(state.step)
return (
<nav aria-label="Fremdrift">
<ol className="flex items-center gap-1.5">
{visibleSteps.map((stepNo, i) => {
const isCurrent = i === currentIdx
const isDone = i < currentIdx
return (
<li key={stepNo} className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold tabular-nums transition-colors",
isCurrent && "bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]",
isDone && "bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]",
!isCurrent && !isDone && "bg-[var(--nr-surface-2)] text-[var(--nr-faint)] ring-1 ring-inset ring-[var(--nr-border)]",
)}
aria-hidden="true"
>
{isDone ? <Check className="h-3.5 w-3.5" strokeWidth={3} /> : i + 1}
</span>
<span
className={cn(
"hidden truncate text-xs font-medium sm:inline",
isCurrent ? "text-[var(--nr-ink)]" : "text-[var(--nr-muted)]",
)}
aria-current={isCurrent ? "step" : undefined}
>
{STEP_LABELS[stepNo]}
</span>
</div>
{i < visibleSteps.length - 1 ? (
<span
aria-hidden="true"
className={cn(
"h-px flex-1 transition-colors",
isDone ? "bg-[var(--nr-accent-ring)]" : "bg-[var(--nr-border)]",
)}
/>
) : null}
</li>
)
})}
</ol>
<p className="sr-only" aria-live="polite">
Steg {currentIdx + 1} av {visibleSteps.length}: {STEP_LABELS[state.step]}
</p>
</nav>
)
}

View file

@ -0,0 +1,603 @@
"use client"
import { ArrowLeft, Building2, ChevronRight, Flag, MapPin, PencilLine, Plus, Search } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
fetchFacilityCourses,
fetchOwnCourse,
nearbyOfficialFacilities,
searchOfficialFacilities,
searchOwnCourses,
} from "@/lib/ny-runde/api"
import type { Course, Facility } from "@/lib/ny-runde/types"
import { CreateCourseForm } from "./create-course-form"
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
import { useWizard } from "./wizard-context"
export function Step1CourseTime() {
const { state, patch, exit } = useWizard()
const sub = state.s1Sub
function pickCourse(course: Course, meta: { source: "teeoff"; facilitySlug: string; teeoffCourseId: number } | { source: "custom"; personalCourseId: string }) {
if (state.templateMode) {
patch({ templateSeed: course, templateMode: false, s1Sub: "own-create" })
return
}
const compatible = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
const tee = compatible[0]
patch({
course,
courseMeta: meta,
courseSource: meta.source === "teeoff" ? "official" : "own",
teeId: tee?.id,
s1Sub: "fields",
// Eierens EGET spillerkort (steg 3) har sitt eget teeId-felt, atskilt
// fra state.teeId over -- uten denne synkroniseringen viser kortet
// "Ikke valgt" selv om et utslag faktisk er valgt (kun kosmetisk, selve
// innsendingen leser state.teeId direkte for eieren -- men samme
// synkronisering som den opprinnelige new-round.tsx sin chooseCourse()
// gjorde, så oppførselen matcher eksakt).
players: state.players.map((p) => (p.kind === "owner" ? { ...p, teeId: tee?.id } : p)),
})
}
switch (sub) {
case "source":
return (
<SourceScreen
templateMode={false}
onOfficial={() => patch({ s1Sub: "official-search" })}
onOwn={() => patch({ s1Sub: "own-search" })}
onBack={exit}
/>
)
case "template-source":
return (
<SourceScreen
templateMode
onOfficial={() => patch({ templateMode: true, s1Sub: "official-search" })}
onOwn={() => patch({ templateMode: true, s1Sub: "own-search" })}
onBack={() => patch({ s1Sub: "own-create" })}
/>
)
case "official-search":
return (
<OfficialSearch
templateMode={state.templateMode}
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onPickFacility={(f) => patch({ selectedFacilitySlug: f.slug, s1Sub: "official-courses" })}
/>
)
case "official-courses":
return (
<OfficialCourses
slug={state.selectedFacilitySlug}
templateMode={state.templateMode}
onBack={() => patch({ s1Sub: "official-search" })}
onPick={(teeoffCourseId, course) =>
pickCourse(course, { source: "teeoff", facilitySlug: state.selectedFacilitySlug!, teeoffCourseId })
}
/>
)
case "own-search":
return (
<OwnSearch
templateMode={state.templateMode}
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
/>
)
case "own-create":
return (
<OwnCreate
onBack={() => patch({ s1Sub: "own-search", templateSeed: undefined })}
onTemplate={() => patch({ s1Sub: "template-source", ownCreateOrigin: "own-create" })}
onCreate={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
seed={state.templateSeed}
/>
)
case "fields":
return <Fields />
default:
return null
}
}
/* ------------------------------------------------------------------ */
function SubHeader({ title, onBack, subtitle }: { title: string; onBack: () => void; subtitle?: string }) {
return (
<div className="mb-4 flex items-start gap-3">
<button
type="button"
onClick={onBack}
aria-label="Tilbake"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</button>
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">{title}</h2>
{subtitle ? <p className="text-sm text-[var(--nr-muted)]">{subtitle}</p> : null}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* source / template-source */
function SourceScreen({
templateMode,
onOfficial,
onOwn,
onBack,
}: {
templateMode: boolean
onOfficial: () => void
onOwn: () => void
onBack: () => void
}) {
return (
<div>
<SubHeader
title={templateMode ? "Velg en mal" : "Hvor spiller du?"}
subtitle={templateMode ? "Velg en bane å basere din egen bane på." : "Velg en offisiell bane eller din egen."}
onBack={onBack}
/>
<div className="grid gap-3">
<ChoiceCard
selected={false}
onSelect={onOfficial}
icon={<Building2 className="h-5 w-5" />}
title="Offisiell bane"
description="Søk opp en registrert golfbane fra databasen."
/>
<ChoiceCard
selected={false}
onSelect={onOwn}
icon={<PencilLine className="h-5 w-5" />}
title="Egen bane"
description="Bruk eller lag din egen manuelt registrerte bane."
/>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* official-search (med geolokasjon "nærmest deg") */
function OfficialSearch({
templateMode,
onBack,
onPickFacility,
}: {
templateMode: boolean
onBack: () => void
onPickFacility: (f: Facility) => void
}) {
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
const [results, setResults] = useState<Facility[] | null>(null)
const [nearest, setNearest] = useState<(Facility & { distanceKm: number })[]>([])
const requested = useRef(false)
useEffect(() => {
if (requested.current) return
requested.current = true
if (typeof navigator === "undefined" || !navigator.geolocation) return
navigator.geolocation.getCurrentPosition(
(pos) => {
void nearbyOfficialFacilities(pos.coords.latitude, pos.coords.longitude).then(setNearest)
},
() => {
/* stille ved avslag/feil */
},
{ timeout: 8000 },
)
}, [])
useEffect(() => {
const trimmed = debounced.trim()
if (trimmed === "") {
setResults(null)
return
}
let cancelled = false
void searchOfficialFacilities(trimmed).then((r) => {
if (!cancelled) setResults(r)
})
return () => {
cancelled = true
}
}, [debounced])
return (
<div>
<SubHeader
title={templateMode ? "Offisiell bane som mal" : "Offisiell bane"}
subtitle="Søk etter anlegg på navn."
onBack={onBack}
/>
<Field label="Banenavn" htmlFor="official-q">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="official-q"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter anlegg…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
<div className="mt-4">
{results === null ? (
nearest.length > 0 ? (
<>
<p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-[var(--nr-muted)]">
<MapPin className="h-4 w-4" aria-hidden="true" /> Nærmest deg
</p>
<ul className="flex flex-col gap-2">
{nearest.map((f) => (
<FacilityRow
key={f.slug}
facility={f}
onClick={() => onPickFacility(f)}
distance={f.distanceKm * 1000 < 1000 ? `${Math.round(f.distanceKm * 1000)} m` : `${Math.round(f.distanceKm)} km`}
/>
))}
</ul>
</>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Begynn å skrive for å søke etter en bane.
</p>
)
) : results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((f) => (
<FacilityRow key={f.slug} facility={f} onClick={() => onPickFacility(f)} />
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Ingen anlegg matcher «{debounced}».
</p>
)}
</div>
</div>
)
}
function FacilityRow({ facility, onClick, distance }: { facility: Facility; onClick: () => void; distance?: string }) {
return (
<li>
<button
type="button"
onClick={onClick}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Building2 className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{facility.name}</span>
<span className="block text-xs text-[var(--nr-muted)]">{[facility.city, facility.county].filter(Boolean).join(", ") || " "}</span>
</span>
{distance ? <Pill tone="neutral">{distance}</Pill> : null}
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
</button>
</li>
)
}
/* ------------------------------------------------------------------ */
/* official-courses */
function OfficialCourses({
slug,
templateMode,
onBack,
onPick,
}: {
slug?: string
templateMode: boolean
onBack: () => void
onPick: (teeoffCourseId: number, course: Course) => void
}) {
const [courses, setCourses] = useState<{ teeoffCourseId: number; course: Course }[] | null>(null)
const [loadError, setLoadError] = useState(false)
useEffect(() => {
if (!slug) return
let cancelled = false
setCourses(null)
setLoadError(false)
fetchFacilityCourses(slug)
.then((r) => {
if (!cancelled) setCourses(r)
})
.catch(() => {
if (!cancelled) setLoadError(true)
})
return () => {
cancelled = true
}
}, [slug])
return (
<div>
<SubHeader title="Baner" subtitle="Velg en bane / sløyfe." onBack={onBack} />
{loadError ? (
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-4 py-3 text-sm text-[var(--nr-danger)]">
Klarte ikke å hente baner fra teeoff akkurat . Prøv igjen om litt.
</p>
) : courses === null ? (
<p className="text-sm text-[var(--nr-muted)]">Laster baner</p>
) : (
<ul className="flex flex-col gap-2">
{courses.map(({ teeoffCourseId, course }) => (
<li key={teeoffCourseId}>
<CourseRow course={course} templateMode={templateMode} onPick={() => onPick(teeoffCourseId, course)} />
</li>
))}
</ul>
)}
</div>
)
}
function CourseRow({ course, templateMode, onPick }: { course: Course; templateMode: boolean; onPick: () => void }) {
return (
<button
type="button"
onClick={onPick}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{course.name}</span>
<span className="block text-xs text-[var(--nr-muted)]">{course.tees.length} utslag</span>
</span>
{templateMode ? <Pill tone="accent">Bruk som mal for egen bane</Pill> : <ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />}
</button>
)
}
/* ------------------------------------------------------------------ */
/* own-search */
function OwnSearch({
templateMode,
onBack,
onCreate,
onPick,
}: {
templateMode: boolean
onBack: () => void
onCreate: () => void
onPick: (c: Course) => void
}) {
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
const [results, setResults] = useState<{ id: string; name: string }[] | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
const trimmed = debounced.trim()
if (trimmed === "") {
setResults(null)
return
}
let cancelled = false
void searchOwnCourses(trimmed).then((r) => {
if (!cancelled) setResults(r)
})
return () => {
cancelled = true
}
}, [debounced])
async function pick(id: string) {
const course = await fetchOwnCourse(id)
if (course) onPick(course)
}
return (
<div>
<SubHeader
title={templateMode ? "Egen bane som mal" : "Egen bane"}
subtitle="Søk blant banene du har laget selv."
onBack={onBack}
/>
<Field label="Banenavn" htmlFor="own-q">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="own-q"
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk blant egne baner…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
{!templateMode ? (
<button
type="button"
onClick={onCreate}
className="mt-3 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Opprett ny bane
</button>
) : null}
<div className="mt-4">
{results === null ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Søk etter en egen bane{templateMode ? "" : ", eller opprett en ny"}.
</p>
) : results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => void pick(c.id)}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1 text-sm font-medium text-[var(--nr-ink)]">{c.name}</span>
{templateMode ? <Pill tone="accent">Bruk som mal for egen bane</Pill> : <ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />}
</button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Fant ingen egne baner som matcher «{debounced}».
</p>
)}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* own-create */
function OwnCreate({
seed,
onBack,
onTemplate,
onCreate,
}: {
seed?: Course
onBack: () => void
onTemplate: () => void
onCreate: (c: Course) => void
}) {
return (
<div>
<SubHeader
title={seed ? "Ny bane (fra mal)" : "Ny egen bane"}
subtitle="Registrer hull, par, stroke-indeks og utslag."
onBack={onBack}
/>
{!seed ? (
<button
type="button"
onClick={onTemplate}
className="mb-4 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Basér en eksisterende bane i stedet
</button>
) : (
<div className="mb-4">
<Pill tone="accent">Forhåndsutfylt fra «{seed.name}»</Pill>
</div>
)}
<CreateCourseForm seed={seed} onCancel={onBack} onCreate={onCreate} />
</div>
)
}
/* ------------------------------------------------------------------ */
/* fields */
function Fields() {
const { state, patch } = useWizard()
const course = state.course
if (!course) return null
const compatibleTees = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
const noCompatibleTee = compatibleTees.length === 0
return (
<div>
<div className="mb-4">
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Bane & tid</h2>
<p className="text-sm text-[var(--nr-muted)]">Bekreft detaljene for runden.</p>
</div>
<Panel className="mb-4 bg-[var(--nr-surface-2)]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--nr-ink)]">{course.name}</p>
<p className="text-xs text-[var(--nr-muted)]">{course.tees.length} utslag</p>
</div>
<Pill tone={state.courseSource === "own" ? "neutral" : "accent"}>{state.courseSource === "own" ? "Egen bane" : "Offisiell"}</Pill>
</div>
<button
type="button"
onClick={() => patch({ s1Sub: "source", course: undefined, courseMeta: undefined, teeId: undefined })}
className="mt-3 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Bytt bane
</button>
</Panel>
<div className="flex flex-col gap-4">
<Field label="Navn på runden" optional htmlFor="round-name" hint="Maks 200 tegn.">
<TextInput
id="round-name"
value={state.roundName}
onChange={(e) => patch({ roundName: e.target.value.slice(0, 200) })}
placeholder={course.name}
maxLength={200}
/>
</Field>
<Field
label="Utslag"
htmlFor="tee"
error={
noCompatibleTee
? "Ingen utslag på denne banen har rating for ditt kjønn. Handicap kan ikke spores for denne runden, men du kan fortsette."
: null
}
>
{noCompatibleTee ? (
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
Ingen kompatible utslag.
</div>
) : (
<NativeSelect id="tee" value={state.teeId ?? ""} onChange={(e) => patch({ teeId: e.target.value })}>
{compatibleTees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</NativeSelect>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Dato" htmlFor="date">
<TextInput id="date" type="date" value={state.date} onChange={(e) => patch({ date: e.target.value })} />
</Field>
<Field label="Utslagstid" optional htmlFor="teetime">
<TextInput id="teetime" type="time" value={state.teeTime} onChange={(e) => patch({ teeTime: e.target.value })} />
</Field>
</div>
<Field label="Starthull" htmlFor="starthole">
<NativeSelect
id="starthole"
value={state.startHole}
onChange={(e) => patch({ startHole: Number.parseInt(e.target.value, 10) })}
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((h) => (
<option key={h} value={h}>
Hull {h}
</option>
))}
</NativeSelect>
</Field>
</div>
</div>
)
}

View file

@ -0,0 +1,315 @@
"use client"
import { FORMATS, FORMAT_MAP, isScrambleVsSolo, isTwoSided } from "@/lib/ny-runde/formats"
import type { StatLevel } from "@/lib/ny-runde/types"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
import { useState } from "react"
import { ChoiceCard, Field, Panel, Segmented, Stepper, TextInput, Toggle, ToggleButton } from "./primitives"
import { useWizard } from "./wizard-context"
const STAT_OPTIONS: { value: StatLevel; title: string; description: string }[] = [
{ value: "score", title: "Kun slag", description: "Registrer bare score per hull." },
{ value: "score_putts", title: "Slag og putter", description: "Score pluss antall putter." },
{ value: "full", title: "All statistikk", description: "Kølle, retning, chip, bunker, straff m.m." },
]
export function Step2Format() {
const { state, patch } = useWizard()
const format = state.format
const selectedDef = FORMAT_MAP[format]
const svs = isScrambleVsSolo(format)
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spilleform</h2>
<p className="text-sm text-[var(--nr-muted)]">Velg statistikknivå og hvordan runden spilles.</p>
</div>
{/* Stat level for owner */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Statistikk for deg selv</h3>
<div className="grid gap-2 sm:grid-cols-3">
{STAT_OPTIONS.map((o) => (
<ChoiceCard
key={o.value}
selected={state.statLevel === o.value}
onSelect={() => patch({ statLevel: o.value })}
title={o.title}
description={o.description}
/>
))}
</div>
</section>
{/* Format grid */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Spilleform</h3>
<div
role="radiogroup"
aria-label="Spilleform"
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
>
{FORMATS.map((f) => (
<ToggleButton key={f.id} active={format === f.id} onClick={() => patch({ format: f.id })}>
{f.label}
</ToggleButton>
))}
</div>
<Panel className="mt-1 bg-[var(--nr-surface-2)] py-3">
<p className="text-sm text-[var(--nr-ink)]">
<span className="font-semibold">{selectedDef.label}.</span>{" "}
<span className="text-[var(--nr-muted)]">{selectedDef.description}</span>
</p>
</Panel>
</section>
{/* Format-specific config */}
<FormatConfig />
{/* Solo allowance (scramble vs solo only) */}
{svs ? (
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Individualspillerens handicap-andel</h3>
<div className="flex items-center gap-3">
<Stepper
value={state.soloAllowance}
onChange={(v) => patch({ soloAllowance: v })}
min={0}
max={100}
step={5}
snap
suffix="%"
/>
<p className="text-xs text-[var(--nr-muted)]">Andel av enkeltspillerens handicap som teller (0100 %).</p>
</div>
</section>
) : null}
{/* Advanced HCP (hidden for scramble vs solo) */}
{!svs ? <AdvancedHcp /> : null}
{/* Number of holes */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Antall hull</h3>
<div className="max-w-xs">
<Segmented
label="Antall hull"
value={String(state.numHoles) as "9" | "18"}
onChange={(v) => patch({ numHoles: v === "9" ? 9 : 18 })}
options={[
{ value: "9", label: "9 hull" },
{ value: "18", label: "18 hull" },
]}
/>
</div>
</section>
</div>
)
}
function FormatConfig() {
const { state, patch } = useWizard()
const f = state.format
if (f === "match") {
return (
<ConfigCard>
<CheckboxRow
checked={state.matchExcludeHcp}
onChange={(v) => patch({ matchExcludeHcp: v })}
label="Ekskluder denne runden fra mitt faktiske HCP"
/>
</ConfigCard>
)
}
if (f === "skins") {
return (
<ConfigCard>
<Field label="Poengberegning">
<Segmented
value={state.skinsScoring}
onChange={(v) => patch({ skinsScoring: v })}
options={[
{ value: "net", label: "Netto (m/HCP)" },
{ value: "gross", label: "Brutto" },
]}
/>
</Field>
<Field label="Ved uavgjort hull">
<Segmented
value={state.skinsTie}
onChange={(v) => patch({ skinsTie: v })}
options={[
{ value: "carry", label: "Rullerer over" },
{ value: "split", label: "Deles likt" },
]}
/>
</Field>
<p className="text-xs text-[var(--nr-muted)]">Skins krever minst 3 spillere.</p>
</ConfigCard>
)
}
if (f === "bbb") {
return (
<ConfigCard>
<CheckboxRow
checked={state.bbbSweep}
onChange={(v) => patch({ bbbSweep: v })}
label="Bonuspoeng ved «sveip»"
/>
</ConfigCard>
)
}
if (f === "shamble") {
return (
<ConfigCard>
<Field label="Beste N av laget teller per hull">
<div className="flex gap-2">
{[1, 2, 3, 4].map((n) => (
<ToggleButton
key={n}
active={state.shambleBestN === n}
onClick={() => patch({ shambleBestN: n })}
className="flex-1"
>
{n}
</ToggleButton>
))}
</div>
</Field>
</ConfigCard>
)
}
if (f === "moneyball") {
return (
<ConfigCard>
<p className="text-sm text-[var(--nr-muted)]">Rotasjonen for «money ball» settes opp i neste steg.</p>
</ConfigCard>
)
}
if (f === "copenhagen") {
return (
<ConfigCard>
<p className="text-sm text-[var(--nr-muted)]">Nøyaktig 3 spillere.</p>
</ConfigCard>
)
}
if (isScrambleVsSolo(f)) {
return null // handled by the dedicated allowance stepper
}
if (isTwoSided(f)) {
return (
<ConfigCard>
<p className="text-sm text-[var(--nr-muted)]">Du setter opp lagene i neste steg.</p>
</ConfigCard>
)
}
return null
}
function AdvancedHcp() {
const { state, patch } = useWizard()
const [open, setOpen] = useState(false)
const f = state.format
const isSlagOrStable = f === "slagspill" || f === "stableford"
return (
<section className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex min-h-11 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<span className="text-sm font-semibold text-[var(--nr-ink)]">Avanserte handicap-innstillinger</span>
<ChevronDown className={cn("h-4 w-4 text-[var(--nr-muted)] transition-transform", open && "rotate-180")} aria-hidden="true" />
</button>
{open ? (
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
<Toggle checked={state.useHandicap} onChange={(v) => patch({ useHandicap: v })} label="Bruk handicap" />
{state.useHandicap && !isSlagOrStable ? (
<Toggle
checked={state.useCourseHcpAdj}
onChange={(v) => patch({ useCourseHcpAdj: v })}
label="Bruk course handicap-justering"
/>
) : null}
{state.useHandicap && isTwoSided(f) ? (
<Toggle
checked={state.useMatchplayHcp}
onChange={(v) => patch({ useMatchplayHcp: v })}
label="Bruk matchplay-handicap"
/>
) : null}
{state.useHandicap ? (
<Field label="HCP-prosent" optional htmlFor="hcp-pct">
<TextInput
id="hcp-pct"
inputMode="decimal"
value={state.hcpPercent}
onChange={(e) => patch({ hcpPercent: e.target.value })}
placeholder="F.eks. 95"
className="max-w-[10rem]"
/>
</Field>
) : null}
</div>
) : null}
</section>
)
}
function ConfigCard({ children }: { children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-4">
{children}
</section>
)
}
function CheckboxRow({
checked,
onChange,
label,
}: {
checked: boolean
onChange: (v: boolean) => void
label: string
}) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex min-h-11 w-full items-center gap-3 text-left focus-visible:outline-none"
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors",
checked ? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "border-[var(--nr-border-strong)] bg-[var(--nr-surface)]",
)}
>
{checked ? (
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 8.5l3.5 3.5L13 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
</span>
<span className="text-sm text-[var(--nr-ink)]">{label}</span>
</button>
)
}

View file

@ -0,0 +1,576 @@
"use client"
import { apiGenderOf, lookupKnownGuest, searchAccounts } from "@/lib/ny-runde/api"
import type { Account, Course, Gender, KnownGuest, Player, StatLevel } from "@/lib/ny-runde/types"
import { cn } from "@/lib/utils"
import { ChevronDown, Search, Sparkles, UserPlus, X } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Avatar, Field, NativeSelect, Pill, TextInput, ToggleButton, useDebouncedValue } from "./primitives"
import { useWizard } from "./wizard-context"
const GENDERS: { value: Gender; label: string }[] = [
{ value: "mann", label: "Mann" },
{ value: "kvinne", label: "Kvinne" },
{ value: "annet", label: "Annet" },
]
const STAT: { value: StatLevel; label: string }[] = [
{ value: "score", label: "Kun slag" },
{ value: "score_putts", label: "Slag + putter" },
{ value: "full", label: "Alt" },
]
let keyCounter = 0
function nextKey() {
keyCounter += 1
return `p-${Date.now()}-${keyCounter}`
}
function teeName(course: Course | undefined, teeId?: string) {
if (!course || !teeId) return "Ikke valgt"
return course.tees.find((t) => t.id === teeId)?.name ?? "Ikke valgt"
}
function compatibleTees(course: Course | undefined, gender?: Gender) {
if (!course) return []
if (!gender) return course.tees // ukjent kjønn (nylig lenket konto): vis alle
const g = apiGenderOf(gender)
if (g === "x") return course.tees
return course.tees.filter((t) => t.genders.includes(g))
}
export function Step3Players() {
const { state, patch } = useWizard()
const [adding, setAdding] = useState(false)
function updatePlayer(key: string, p: Partial<Player>) {
patch({ players: state.players.map((pl) => (pl.key === key ? { ...pl, ...p } : pl)) })
}
function removePlayer(key: string) {
patch({ players: state.players.filter((pl) => pl.key !== key) })
}
function addPlayer(p: Player) {
patch({ players: [...state.players, p] })
setAdding(false)
}
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spillere</h2>
<p className="text-sm text-[var(--nr-muted)]">
{state.players.length} {state.players.length === 1 ? "spiller" : "spillere"} i runden.
</p>
</div>
<ul className="flex flex-col gap-2">
{state.players.map((player) => (
<li key={player.key}>
<PlayerCard
player={player}
course={state.course}
onChange={(p) => updatePlayer(player.key, p)}
onRemove={() => removePlayer(player.key)}
/>
</li>
))}
</ul>
{adding ? (
<AddPlayerPanel
course={state.course}
defaultStat={state.statLevel}
existing={state.players}
onCancel={() => setAdding(false)}
onAdd={addPlayer}
/>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<UserPlus className="h-4 w-4" aria-hidden="true" />
Medspiller
</button>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Player card */
function PlayerCard({
player,
course,
onChange,
onRemove,
}: {
player: Player
course?: Course
onChange: (p: Partial<Player>) => void
onRemove: () => void
}) {
const [open, setOpen] = useState(false)
const fullName = `${player.firstName}${player.lastName ? ` ${player.lastName}` : ""}`.trim()
const isOwner = player.kind === "owner"
const hcpText = player.hcpFromProfile
? player.hcp != null
? String(player.hcp)
: "fra profil"
: player.hcp != null
? String(player.hcp)
: ""
const tees = compatibleTees(course, player.gender)
return (
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
<div className="flex items-center gap-3 p-3">
<Avatar first={player.firstName || "?"} last={player.lastName} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-[var(--nr-ink)]">{fullName || "Ny spiller"}</span>
{isOwner ? <Pill tone="accent">Deg · Eier</Pill> : null}
{player.isGuest ? <Pill tone="neutral">Gjest</Pill> : null}
</div>
<p className="truncate text-xs text-[var(--nr-muted)]">
Utslag: {teeName(course, player.teeId)} · HCP: {hcpText}
</p>
</div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={open ? `Skjul redigering av ${fullName}` : `Rediger ${fullName}`}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<ChevronDown className={cn("h-4 w-4 transition-transform", open && "rotate-180")} aria-hidden="true" />
</button>
{!isOwner ? (
<button
type="button"
onClick={onRemove}
aria-label={`Fjern ${fullName}`}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
</div>
{open ? (
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
{player.isGuest ? (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Fornavn" htmlFor={`${player.key}-first`}>
<TextInput
id={`${player.key}-first`}
value={player.firstName}
onChange={(e) => onChange({ firstName: e.target.value })}
invalid={player.firstName.trim() === ""}
/>
</Field>
<Field label="Etternavn" optional htmlFor={`${player.key}-last`}>
<TextInput
id={`${player.key}-last`}
value={player.lastName}
onChange={(e) => onChange({ lastName: e.target.value })}
/>
</Field>
</div>
<Field label="Kjønn">
<GenderChoice
value={player.gender ?? "mann"}
onChange={(g) => {
const stillOk = compatibleTees(course, g).some((t) => t.id === player.teeId)
onChange({ gender: g, teeId: stillOk ? player.teeId : compatibleTees(course, g)[0]?.id })
}}
/>
</Field>
<Field label="E-post" optional htmlFor={`${player.key}-email`}>
<TextInput
id={`${player.key}-email`}
type="email"
value={player.email ?? ""}
onChange={(e) => onChange({ email: e.target.value })}
/>
</Field>
</>
) : null}
<Field label="Utslag" htmlFor={`${player.key}-tee`}>
<NativeSelect
id={`${player.key}-tee`}
value={player.teeId ?? ""}
onChange={(e) => onChange({ teeId: e.target.value })}
>
<option value="">Velg utslag</option>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</NativeSelect>
</Field>
{player.isGuest ? (
<Field label="HCP" optional htmlFor={`${player.key}-hcp`}>
<TextInput
id={`${player.key}-hcp`}
inputMode="decimal"
value={player.hcp != null ? String(player.hcp) : ""}
onChange={(e) => {
const v = e.target.value
onChange({ hcp: v.trim() === "" ? null : Number.parseFloat(v.replace(",", ".")) })
}}
className="max-w-[10rem]"
/>
</Field>
) : player.hcpFromProfile && !isOwner ? (
<Field label="HCP">
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
{player.hcp != null ? `${player.hcp} · hentes fra profilen` : "Hentes fra profilen…"}
</div>
</Field>
) : null}
<Field label="Statistikk">
<StatChoice value={player.statLevel} onChange={(s) => onChange({ statLevel: s })} />
</Field>
</div>
) : null}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Legg til-panel (søk + gjest) */
function AddPlayerPanel({
course,
defaultStat,
existing,
onCancel,
onAdd,
}: {
course?: Course
defaultStat: StatLevel
existing: Player[]
onCancel: () => void
onAdd: (p: Player) => void
}) {
const [mode, setMode] = useState<"search" | "guest">("search")
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
const [results, setResults] = useState<Account[]>([])
// gjesteskjema
const [first, setFirst] = useState("")
const [last, setLast] = useState("")
const [email, setEmail] = useState("")
const [gender, setGender] = useState<Gender>("mann")
const [hcp, setHcp] = useState("")
const [stat, setStat] = useState<StatLevel>(defaultStat)
const firstRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (mode === "guest") firstRef.current?.focus()
}, [mode])
const existingAccountIds = new Set(existing.map((p) => p.accountId).filter(Boolean))
useEffect(() => {
const q = debounced.trim()
if (q.length < 2) {
setResults([])
return
}
let cancelled = false
void searchAccounts(q).then((r) => {
if (!cancelled) setResults(r.filter((a) => !existingAccountIds.has(a.id)))
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounced])
function switchToGuest() {
const parts = query.trim().split(/\s+/)
setFirst(parts[0] ?? "")
setLast(parts.slice(1).join(" "))
setMode("guest")
}
function addAccount(acc: Account) {
onAdd({
key: nextKey(),
kind: "account",
accountId: acc.id,
firstName: acc.firstName,
lastName: acc.lastName,
gender: undefined, // ukjent klient-side inntil profilen lastes
isGuest: false,
hcp: acc.handicap,
hcpFromProfile: true,
teeId: course?.tees[0]?.id,
statLevel: defaultStat,
})
}
function addGuest() {
if (first.trim() === "") return
onAdd({
key: nextKey(),
kind: "guest",
firstName: first.trim(),
lastName: last.trim(),
gender,
isGuest: true,
email: email.trim() || undefined,
hcp: hcp.trim() === "" ? null : Number.parseFloat(hcp.replace(",", ".")),
teeId: compatibleTees(course, gender)[0]?.id,
statLevel: stat,
})
}
return (
<div className="rounded-xl border border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Legg til medspiller</h3>
<button
type="button"
onClick={onCancel}
aria-label="Lukk"
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{mode === "search" ? (
<div className="flex flex-col gap-3">
<Field label="Navn på medspilleren" htmlFor="add-search" hint="Skriv minst 2 tegn for å søke.">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="add-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter en konto…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
{debounced.trim().length >= 2 ? (
results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((a) => (
<li key={a.id}>
<button
type="button"
onClick={() => addAccount(a)}
className="flex min-h-14 w-full items-center gap-3 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 py-2 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Avatar first={a.firstName} last={a.lastName} />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{a.firstName} {a.lastName}</span>
<span className="block text-xs text-[var(--nr-muted)]">{a.handicap != null ? `HCP ${a.handicap}` : "Ingen HCP registrert"}</span>
</span>
<UserPlus className="h-4 w-4 text-[var(--nr-accent)]" aria-hidden="true" />
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-[var(--nr-muted)]">Ingen kontoer matcher søket.</p>
)
) : null}
<button
type="button"
onClick={switchToGuest}
className="text-left text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Legg til uten konto (gjest)
</button>
</div>
) : (
<GuestForm
first={first}
setFirst={setFirst}
last={last}
setLast={setLast}
email={email}
setEmail={setEmail}
gender={gender}
setGender={setGender}
hcp={hcp}
setHcp={setHcp}
stat={stat}
setStat={setStat}
firstRef={firstRef}
onBackToSearch={() => setMode("search")}
onSubmit={addGuest}
/>
)}
</div>
)
}
function GuestForm({
first,
setFirst,
last,
setLast,
email,
setEmail,
gender,
setGender,
hcp,
setHcp,
stat,
setStat,
firstRef,
onBackToSearch,
onSubmit,
}: {
first: string
setFirst: (v: string) => void
last: string
setLast: (v: string) => void
email: string
setEmail: (v: string) => void
gender: Gender
setGender: (v: Gender) => void
hcp: string
setHcp: (v: string) => void
stat: StatLevel
setStat: (v: StatLevel) => void
firstRef: React.RefObject<HTMLInputElement | null>
onBackToSearch: () => void
onSubmit: () => void
}) {
// kjent-gjest-oppslag på e-post (scoped til DENNE brukeren server-side)
const debouncedEmail = useDebouncedValue(email, 350)
const [match, setMatch] = useState<KnownGuest | null>(null)
useEffect(() => {
const e = debouncedEmail.trim()
if (e.length < 5 || !e.includes("@")) {
setMatch(null)
return
}
let cancelled = false
void lookupKnownGuest(e).then((g) => {
if (!cancelled) setMatch(g)
})
return () => {
cancelled = true
}
}, [debouncedEmail])
return (
<div className="flex flex-col gap-3">
<button
type="button"
onClick={onBackToSearch}
className="self-start text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Søk etter konto i stedet
</button>
<div className="grid grid-cols-2 gap-3">
<Field label="Fornavn" htmlFor="g-first">
<TextInput id="g-first" ref={firstRef} value={first} onChange={(e) => setFirst(e.target.value)} invalid={first.trim() === ""} />
</Field>
<Field label="Etternavn" optional htmlFor="g-last">
<TextInput id="g-last" value={last} onChange={(e) => setLast(e.target.value)} />
</Field>
</div>
<Field label="E-post" optional htmlFor="g-email">
<TextInput id="g-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="navn@example.com" />
</Field>
{match ? (
<div className="flex items-start gap-3 rounded-lg border border-[var(--nr-accent-ring)] bg-[var(--nr-accent-soft)] p-3">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-[var(--nr-accent)]" aria-hidden="true" />
<div className="flex-1">
<p className="text-[13px] text-[var(--nr-ink)]">
Vi fant {match.firstName} {match.lastName} fra en tidligere runde du satte opp.
</p>
<button
type="button"
onClick={() => {
setFirst(match.firstName)
setLast(match.lastName)
if (match.gender === "m") setGender("mann")
else if (match.gender === "f") setGender("kvinne")
if (match.hcp != null) setHcp(String(match.hcp))
}}
className="mt-1 text-[13px] font-semibold text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Fyll inn automatisk
</button>
</div>
</div>
) : null}
<Field label="Kjønn">
<GenderChoice value={gender} onChange={setGender} />
</Field>
<Field label="HCP" optional htmlFor="g-hcp">
<TextInput
id="g-hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="max-w-[10rem]"
/>
</Field>
<Field label="Statistikk for denne spilleren">
<StatChoice value={stat} onChange={setStat} />
</Field>
<button
type="button"
onClick={onSubmit}
disabled={first.trim() === ""}
className="mt-1 inline-flex min-h-11 items-center justify-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
Legg til gjest
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
function GenderChoice({ value, onChange }: { value: Gender; onChange: (v: Gender) => void }) {
return (
<div className="flex gap-2">
{GENDERS.map((g) => (
<ToggleButton key={g.value} active={value === g.value} onClick={() => onChange(g.value)} className="flex-1">
{g.label}
</ToggleButton>
))}
</div>
)
}
function StatChoice({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
return (
<div className="flex gap-2">
{STAT.map((s) => (
<ToggleButton key={s.value} active={value === s.value} onClick={() => onChange(s.value)} className="flex-1">
{s.label}
</ToggleButton>
))}
</div>
)
}

View file

@ -0,0 +1,309 @@
"use client"
import { isScrambleVsSolo, isTwoSided } from "@/lib/ny-runde/formats"
import type { Player } from "@/lib/ny-runde/types"
import { cn } from "@/lib/utils"
import { ArrowLeftRight, Check, UserMinus } from "lucide-react"
import { Avatar, NativeSelect, Panel, Pill, TextInput } from "./primitives"
import { useWizard } from "./wizard-context"
function fullName(p: Player) {
return `${p.firstName}${p.lastName ? ` ${p.lastName}` : ""}`.trim() || "Spiller"
}
export function Step4Teams() {
const { state } = useWizard()
const f = state.format
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Lag og rekkefølge</h2>
<p className="text-sm text-[var(--nr-muted)]">Sett opp lagene for denne spilleformen.</p>
</div>
{isTwoSided(f) ? <TwoSided /> : f === "moneyball" ? <MoneyBall /> : isScrambleVsSolo(f) ? <ScrambleVsSolo /> : null}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Two-sided A/B */
/* ------------------------------------------------------------------ */
function TwoSided() {
const { state, patch } = useWizard()
const assign = state.sideAssign
const unassigned = state.players.filter((p) => !assign[p.key])
const sideA = state.players.filter((p) => assign[p.key] === "A")
const sideB = state.players.filter((p) => assign[p.key] === "B")
function set(key: string, side: "A" | "B" | undefined) {
patch({ sideAssign: { ...assign, [key]: side } })
}
return (
<div className="flex flex-col gap-4">
{unassigned.length > 0 ? (
<Panel className="bg-[var(--nr-surface-2)]">
<p className="mb-2 text-[13px] font-medium text-[var(--nr-muted)]">Ikke tildelt</p>
<ul className="flex flex-col gap-2">
{unassigned.map((p) => (
<li key={p.key} className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface)] p-2 pr-3">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<div className="flex gap-1.5">
<AssignBtn onClick={() => set(p.key, "A")}>{state.sideALabel || "Side A"}</AssignBtn>
<AssignBtn onClick={() => set(p.key, "B")}>{state.sideBLabel || "Side B"}</AssignBtn>
</div>
</li>
))}
</ul>
</Panel>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<SideColumn
label={state.sideALabel}
placeholder="Side A"
suggestion="Rødt lag"
onLabel={(v) => patch({ sideALabel: v })}
players={sideA}
otherSideLabel={state.sideBLabel || "Side B"}
onMoveOther={(k) => set(k, "B")}
onRemove={(k) => set(k, undefined)}
/>
<SideColumn
label={state.sideBLabel}
placeholder="Side B"
suggestion="Blått lag"
onLabel={(v) => patch({ sideBLabel: v })}
players={sideB}
otherSideLabel={state.sideALabel || "Side A"}
onMoveOther={(k) => set(k, "A")}
onRemove={(k) => set(k, undefined)}
/>
</div>
</div>
)
}
function SideColumn({
label,
placeholder,
suggestion,
onLabel,
players,
otherSideLabel,
onMoveOther,
onRemove,
}: {
label: string
placeholder: string
suggestion: string
onLabel: (v: string) => void
players: Player[]
otherSideLabel: string
onMoveOther: (key: string) => void
onRemove: (key: string) => void
}) {
return (
<div className="flex flex-col gap-2 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-3">
<TextInput value={label} onChange={(e) => onLabel(e.target.value)} placeholder={placeholder} className="h-10 font-medium" />
{!label ? (
<button
type="button"
onClick={() => onLabel(suggestion)}
className="self-start text-xs font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Foreslå «{suggestion}»
</button>
) : null}
{players.length === 0 ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-6 text-center text-xs text-[var(--nr-muted)]">
Ingen spillere ennå.
</p>
) : (
<ul className="flex flex-col gap-2">
{players.map((p) => (
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<button
type="button"
onClick={() => onMoveOther(p.key)}
aria-label={`Flytt ${fullName(p)} til ${otherSideLabel}`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-accent)]"
>
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onRemove(p.key)}
aria-label={`Fjern ${fullName(p)} fra laget`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-danger)]"
>
<UserMinus className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
)
}
function AssignBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-9 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 text-xs font-medium text-[var(--nr-ink)] transition-colors hover:border-[var(--nr-accent)] hover:text-[var(--nr-accent)]"
>
{children}
</button>
)
}
/* ------------------------------------------------------------------ */
/* Money Ball */
/* ------------------------------------------------------------------ */
function MoneyBall() {
const { state, patch } = useWizard()
return (
<Panel>
<p className="mb-3 text-sm text-[var(--nr-muted)]">Gi hver spiller en plass i rotasjonen (03).</p>
<ul className="flex flex-col gap-2">
{state.players.map((p) => (
<li key={p.key} className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-3">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<NativeSelect
aria-label={`Plass for ${fullName(p)}`}
value={state.moneyballSlots[p.key] ?? 0}
onChange={(e) => patch({ moneyballSlots: { ...state.moneyballSlots, [p.key]: Number.parseInt(e.target.value, 10) } })}
className="h-10 w-24 bg-[var(--nr-surface)]"
>
{[0, 1, 2, 3].map((n) => (
<option key={n} value={n}>
Plass {n}
</option>
))}
</NativeSelect>
</li>
))}
</ul>
</Panel>
)
}
/* ------------------------------------------------------------------ */
/* Scramble vs solo (asymmetric) */
/* ------------------------------------------------------------------ */
function ScrambleVsSolo() {
const { state, patch } = useWizard()
const assign = state.scrambleAssign
const team = state.players.filter((p) => assign[p.key] === "team")
const opponent = state.players.find((p) => assign[p.key] === "opponent")
const unplaced = state.players.filter((p) => !assign[p.key])
const teamReady = team.length >= 2
const opponentFilled = !!opponent
function set(key: string, slot: "team" | "opponent" | undefined) {
patch({ scrambleAssign: { ...assign, [key]: slot } })
}
return (
<div className="flex flex-col gap-4">
{/* The team — open-ended, emphasized */}
<div className="rounded-xl border-2 border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-base font-semibold text-[var(--nr-ink)]">Laget</h3>
<span
role="status"
aria-live="polite"
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
teamReady
? "border-[var(--nr-ok)]/30 bg-[var(--nr-ok-soft)] text-[var(--nr-ok)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface-2)] text-[var(--nr-muted)]",
)}
>
{teamReady ? <Check className="h-3 w-3" strokeWidth={3} /> : null}
{teamReady ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}
</span>
</div>
{team.length === 0 ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-6 text-center text-xs text-[var(--nr-muted)]">
Legg til spillere fra listen under.
</p>
) : (
<ul className="flex flex-col gap-2">
{team.map((p) => (
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<button
type="button"
onClick={() => set(p.key, undefined)}
aria-label={`Fjern ${fullName(p)} fra laget`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-danger)]"
>
<UserMinus className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
{/* Individual opponent — capped at 1, visually distinct single slot */}
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-4">
<h3 className="mb-2 text-sm font-semibold text-[var(--nr-ink)]">Individuell motstander</h3>
{opponent ? (
<div className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface)] p-2 pr-3 ring-1 ring-inset ring-[var(--nr-border)]">
<Avatar first={opponent.firstName} last={opponent.lastName} className="h-10 w-10" />
<span className="flex-1 truncate text-sm font-medium text-[var(--nr-ink)]">{fullName(opponent)}</span>
<Pill tone="neutral">1 av 1</Pill>
<button
type="button"
onClick={() => set(opponent.key, undefined)}
aria-label={`Fjern ${fullName(opponent)} som motstander`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)] hover:text-[var(--nr-danger)]"
>
<UserMinus className="h-4 w-4" aria-hidden="true" />
</button>
</div>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-5 text-center text-xs text-[var(--nr-muted)]">
Én spiller. Velg «Som motstander» en spiller under.
</p>
)}
</div>
{/* Unplaced */}
{unplaced.length > 0 ? (
<Panel>
<p className="mb-2 text-[13px] font-medium text-[var(--nr-muted)]">Ikke plassert</p>
<ul className="flex flex-col gap-2">
{unplaced.map((p) => (
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<AssignBtn onClick={() => set(p.key, "team")}>Til laget</AssignBtn>
<button
type="button"
onClick={() => set(p.key, "opponent")}
disabled={opponentFilled}
className="inline-flex min-h-9 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 text-xs font-medium text-[var(--nr-ink)] transition-colors hover:border-[var(--nr-accent)] hover:text-[var(--nr-accent)] disabled:cursor-not-allowed disabled:opacity-40"
>
Som motstander
</button>
</li>
))}
</ul>
</Panel>
) : null}
</div>
)
}

View file

@ -0,0 +1,140 @@
"use client"
import { Lock, Users, Globe } from "lucide-react"
import { useWizard, CATEGORY_OPTIONS } from "./wizard-context"
import { ChoiceCard } from "./primitives"
import { cn } from "@/lib/utils"
import type { Visibility } from "@/lib/ny-runde/types"
const VISIBILITY_CARDS: {
value: Visibility
title: string
description: string
icon: React.ReactNode
}[] = [
{
value: "private",
title: "Privat",
description: "Bare du kan se denne runden.",
icon: <Lock className="h-5 w-5" aria-hidden="true" />,
},
{
value: "friends",
title: "Venner",
description: "Del med utvalgte kategorier av venner.",
icon: <Users className="h-5 w-5" aria-hidden="true" />,
},
{
value: "public",
title: "Offentlig",
description: "Kan ses av alle, også uten innlogging.",
icon: <Globe className="h-5 w-5" aria-hidden="true" />,
},
]
export function Step5Sharing() {
const { state, patch } = useWizard()
function toggleCategory(code: string) {
const set = new Set(state.categories)
if (set.has(code)) set.delete(code)
else set.add(code)
patch({ categories: Array.from(set) })
}
const allCodes = CATEGORY_OPTIONS.map((c) => c.code)
const noneSelected = state.categories.length === 0
const allSelected = state.categories.length === allCodes.length
return (
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h2 className="text-xl font-semibold tracking-tight text-[var(--nr-ink)]">Deling</h2>
<p className="text-sm text-[var(--nr-muted)]">Bestem hvem som kan se denne runden.</p>
</header>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-[13px] font-medium text-[var(--nr-ink)]">Synlighet</legend>
<div className="grid grid-cols-1 gap-2">
{VISIBILITY_CARDS.map((c) => (
<ChoiceCard
key={c.value}
selected={state.visibility === c.value}
onSelect={() => patch({ visibility: c.value })}
title={c.title}
description={c.description}
icon={c.icon}
/>
))}
</div>
</fieldset>
{state.visibility === "friends" ? (
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-[13px] font-medium text-[var(--nr-ink)]">Del med kategorier</legend>
<p className="mb-1 text-xs text-[var(--nr-muted)]">
Alle er forhåndsvalgt velg heller bort det du ikke vil skal se runden.
</p>
<div className="mb-1 flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
<button
type="button"
onClick={() => patch({ categories: [] })}
aria-pressed={noneSelected}
className={cn(
"min-h-9 rounded-full border px-3.5 text-[13px] font-semibold transition-colors",
noneSelected
? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
)}
>
Ingen
</button>
<button
type="button"
onClick={() => patch({ categories: allCodes })}
aria-pressed={allSelected}
className={cn(
"min-h-9 rounded-full border px-3.5 text-[13px] font-semibold transition-colors",
allSelected
? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
)}
>
Alle
</button>
</div>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map((opt) => {
const active = state.categories.includes(opt.code)
return (
<button
key={opt.code}
type="button"
role="checkbox"
aria-checked={active}
onClick={() => toggleCategory(opt.code)}
className={cn(
"min-h-11 rounded-full border px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
active
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
)}
>
{opt.label}
</button>
)
})}
</div>
{noneSelected ? (
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/40 bg-[var(--nr-danger-soft)] px-3 py-2 text-[13px] font-semibold text-[var(--nr-danger)]">
Ingen kategori valgt ennå ingen venner kan se runden før du velger minst én.
</p>
) : null}
</fieldset>
) : null}
</div>
)
}

View file

@ -0,0 +1,480 @@
"use client"
import { useSearchParams } from "next/navigation"
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import {
API_TO_FORMAT,
FORMAT_TO_API,
ORDINARY_FORMATS,
SCRAMBLE_VS_SOLO,
SIDE_IS_UNIT_FORMAT,
TWO_SIDED,
apiStatLevel,
showsTeamStep,
} from "@/lib/ny-runde/formats"
import { apiGenderOf } from "@/lib/ny-runde/api"
import type { ApiGender, Course, CourseMeta, FormatId, Player, StatLevel, Sub1, Visibility } from "@/lib/ny-runde/types"
/** Samme faste sett/rekkefølge som friends.py sin Category. */
export const CATEGORY_OPTIONS = [
{ code: "spouse", label: "Make" },
{ code: "close_family", label: "Nær familie" },
{ code: "extended_family", label: "Storfamilie" },
{ code: "close_friends", label: "Nære venner" },
{ code: "golf_friends", label: "Golfvenner" },
{ code: "colleagues", label: "Kollegaer" },
{ code: "business", label: "Forretningsforbindelser" },
{ code: "classmates", label: "Studiekamerater" },
{ code: "acquaintances", label: "Perifere bekjente" },
{ code: "other", label: "Ymse" },
] as const
export interface WizardState {
step: number // 1..5 (logisk; steg 4 kan hoppes over)
// Auth/profil
loadingMe: boolean
ownGender: ApiGender | null
// Steg 1 under-veiviser
s1Sub: Sub1
ownCreateOrigin: Sub1
templateMode: boolean
courseSource: "official" | "own"
selectedFacilitySlug?: string
course?: Course
courseMeta?: CourseMeta
templateSeed?: Course
// Steg 1-felt
roundName: string
teeId?: string
date: string
teeTime: string
startHole: number
// Steg 2
statLevel: StatLevel
format: FormatId
numHoles: 9 | 18
matchExcludeHcp: boolean
skinsScoring: "net" | "gross"
skinsTie: "carry" | "split"
bbbSweep: boolean
shambleBestN: number
soloAllowance: number
useHandicap: boolean
useCourseHcpAdj: boolean
useMatchplayHcp: boolean
hcpPercent: string
// Steg 3
players: Player[]
// Steg 4
sideALabel: string
sideBLabel: string
sideAssign: Record<string, "A" | "B" | undefined>
moneyballSlots: Record<string, number>
scrambleAssign: Record<string, "team" | "opponent" | undefined>
// Steg 5
visibility: Visibility
categories: string[]
submitting: boolean
submitError: string | null
// "Legg til en flight til" (migrasjon 035)
flightGroupId: string | null
}
function todayISO() {
const now = new Date()
const offset = now.getTimezoneOffset()
const local = new Date(now.getTime() - offset * 60 * 1000)
return local.toISOString().slice(0, 10)
}
function nowTime() {
const d = new Date()
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
}
function initialState(params: {
flightGroupId: string | null
prefillPlayedAt?: string
prefillStartHole?: string | null
prefillHoles?: string | null
prefillPlayFormatApi?: string | null
}): WizardState {
const statLevel: StatLevel = "score"
const format = (params.prefillPlayFormatApi && API_TO_FORMAT[params.prefillPlayFormatApi]) || "slagspill"
return {
step: 1,
loadingMe: true,
ownGender: null,
s1Sub: "source",
ownCreateOrigin: "own-search",
templateMode: false,
courseSource: "official",
roundName: "",
date: params.prefillPlayedAt ?? todayISO(),
teeTime: nowTime(),
startHole: params.prefillStartHole ? Number(params.prefillStartHole) : 1,
statLevel,
format,
numHoles: params.prefillHoles === "9" ? 9 : 18,
matchExcludeHcp: true,
skinsScoring: "net",
skinsTie: "carry",
bbbSweep: false,
shambleBestN: 2,
soloAllowance: 100,
useHandicap: true,
useCourseHcpAdj: true,
useMatchplayHcp: true,
hcpPercent: "",
players: [],
sideALabel: "",
sideBLabel: "",
sideAssign: {},
moneyballSlots: {},
scrambleAssign: {},
visibility: "private",
categories: CATEGORY_OPTIONS.map((c) => c.code),
submitting: false,
submitError: null,
flightGroupId: params.flightGroupId,
}
}
interface WizardCtx {
state: WizardState
patch: (p: Partial<WizardState>) => void
visibleSteps: number[]
goNext: () => void
goBack: () => void
canProceedStep1: boolean
isAdditionalFlight: boolean
submit: () => Promise<void>
exit: () => void
}
const Ctx = createContext<WizardCtx | null>(null)
export function WizardProvider({
children,
onExit,
onCreated,
}: {
children: React.ReactNode
onExit: () => void
onCreated: (roundId: string) => void
}) {
const searchParams = useSearchParams()
const flightGroupId = searchParams.get("flightGroupId")
const prefillPlayedAt = searchParams.get("playedAt") ?? undefined
const prefillStartHole = searchParams.get("startHole")
const prefillHoles = searchParams.get("holes")
const prefillPlayFormatApi = searchParams.get("playFormat")
const isAdditionalFlight = Boolean(flightGroupId)
const [state, setState] = useState<WizardState>(() =>
initialState({ flightGroupId, prefillPlayedAt, prefillStartHole, prefillHoles, prefillPlayFormatApi }),
)
const patch = useCallback((p: Partial<WizardState>) => {
setState((s) => ({ ...s, ...p }))
}, [])
// Egen profil -- eieren av runden er ALLTID første spiller i listen.
useEffect(() => {
let cancelled = false
async function loadMe() {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (res.status === 401) {
onExit()
return
}
const data: {
gender: ApiGender | "x" | null
handicap_index: number | null
first_name: string | null
last_name: string | null
} = await res.json()
if (cancelled) return
const ownGender = data.gender === "m" || data.gender === "f" ? data.gender : null
const ownerPlayer: Player = {
key: "owner",
kind: "owner",
firstName: data.first_name ?? "",
lastName: data.last_name ?? "",
gender: data.gender === "f" ? "kvinne" : "mann",
isGuest: false,
hcp: data.handicap_index,
hcpFromProfile: true,
statLevel: "score",
}
setState((s) => ({ ...s, ownGender, loadingMe: false, players: [ownerPlayer] }))
} catch {
if (!cancelled) setState((s) => ({ ...s, loadingMe: false }))
}
}
void loadMe()
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const hasTeamStep = showsTeamStep(state.format)
const visibleSteps = useMemo(() => (hasTeamStep ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]), [hasTeamStep])
const canProceedStep1 = state.s1Sub === "fields" && !!state.course && !!state.teeId && !!state.date
const goNext = useCallback(() => {
setState((s) => {
const team = showsTeamStep(s.format)
const seq = team ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]
const idx = seq.indexOf(s.step)
const next = seq[Math.min(idx + 1, seq.length - 1)]
return { ...s, step: next }
})
}, [])
// Speiler EKSAKT den forrige handleBack() sin steg-1-undertilstandslogikk
// (new-round.tsx) -- hver s1Sub har sitt eget, spesifikke tilbake-mål.
const goBack = useCallback(() => {
setState((s) => {
if (s.step === 1) {
switch (s.s1Sub) {
case "source":
onExit()
return s
case "template-source":
return { ...s, s1Sub: "own-create" }
case "official-search":
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
case "official-courses":
return { ...s, s1Sub: "official-search" }
case "own-search":
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
case "own-create":
return { ...s, s1Sub: s.ownCreateOrigin }
case "fields":
return { ...s, s1Sub: s.courseSource === "official" ? "official-courses" : "own-search" }
default:
return s
}
}
const team = showsTeamStep(s.format)
const seq = team ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]
const idx = seq.indexOf(s.step)
const prev = seq[Math.max(idx - 1, 0)]
return { ...s, step: prev }
})
}, [onExit])
// --- Innsending -- speiler EKSAKT new-round.tsx sin submitWizard() -----
const submit = useCallback(async () => {
const s = state
if (!s.course || !s.courseMeta || !s.teeId) return
patch({ submitting: true, submitError: null })
try {
const startedAt = s.teeTime ? new Date(`${s.date}T${s.teeTime}`).toISOString() : null
const apiFormat = FORMAT_TO_API[s.format]
const percentValue = s.hcpPercent.trim() === "" ? undefined : Number(s.hcpPercent.replace(",", "."))
const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue)
const twoSided = TWO_SIDED.includes(s.format)
const matchplayIsDefault = !twoSided || s.useMatchplayHcp
let allowanceOverride: Record<string, unknown> | null = null
if (!(s.useHandicap && s.useCourseHcpAdj && matchplayIsDefault && !hasPercent)) {
const override: Record<string, unknown> = { use_handicap: s.useHandicap, use_course_handicap: s.useCourseHcpAdj }
if (twoSided) override.use_matchplay_handicap = s.useMatchplayHcp
if (hasPercent) {
const fraction = percentValue! / 100
override.strategy = SIDE_IS_UNIT_FORMAT.includes(s.format)
? { type: "combined", percentage: fraction }
: { type: "per_player", percentage: fraction }
}
allowanceOverride = override
}
const teeName = s.course.tees.find((t) => t.id === s.teeId)?.name ?? ""
const owner = s.players.find((p) => p.kind === "owner")
const shared = {
tee_name: teeName,
name: s.roundName.trim() || null,
played_at: s.date,
started_at: startedAt,
start_hole: s.startHole,
holes_planned: s.numHoles,
stat_level: apiStatLevel(owner?.statLevel ?? "score"),
play_format: apiFormat,
exclude_owner_from_handicap: s.format === "match" ? s.matchExcludeHcp : false,
skins_scoring: s.format === "skins" ? s.skinsScoring : null,
skins_tie_handling: s.format === "skins" ? s.skinsTie : null,
bbb_sweep_bonus_enabled: s.format === "bbb" ? s.bbbSweep : false,
shamble_best_n: s.format === "shamble" ? s.shambleBestN : null,
scramble_solo_individual_pct: SCRAMBLE_VS_SOLO.includes(s.format) ? s.soloAllowance : 100,
visibility_mode: s.visibility === "private" ? "private" : s.visibility === "friends" ? "friends" : "public",
visible_categories: s.visibility === "friends" ? s.categories : [],
flight_group_id: s.flightGroupId || undefined,
allowance_override: allowanceOverride,
}
const body =
s.courseMeta.source === "teeoff"
? {
course_source: "teeoff",
teeoff_facility_slug: s.courseMeta.facilitySlug,
teeoff_course_id: s.courseMeta.teeoffCourseId,
...shared,
}
: { course_source: "custom", personal_course_id: s.courseMeta.personalCourseId, ...shared }
const roundRes = await fetch("/rounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!roundRes.ok) throw new Error("create round:" + roundRes.status)
const round: { id: string; participants: { id: string; is_owner: boolean }[] } = await roundRes.json()
const roundId = round.id
const ownerParticipantId = round.participants.find((p) => p.is_owner)?.id ?? round.participants[0]?.id
const sideIdMap = new Map<string, string>()
if (TWO_SIDED.includes(s.format)) {
const labels: [string, string][] = [
["A", s.sideALabel],
["B", s.sideBLabel],
]
for (const [localId, label] of labels) {
const res = await fetch(`/rounds/${roundId}/sides`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ label: label || null }),
})
if (!res.ok) throw new Error("create side:" + res.status)
const created: { id: string } = await res.json()
sideIdMap.set(localId, created.id)
}
} else if (SCRAMBLE_VS_SOLO.includes(s.format)) {
const roles: { localId: "team" | "opponent"; label: string; side_role: "team" | "individual" }[] = [
{ localId: "team", label: "Laget", side_role: "team" },
{ localId: "opponent", label: "Individuell motstander", side_role: "individual" },
]
for (const { localId, label, side_role } of roles) {
const res = await fetch(`/rounds/${roundId}/sides`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ label, side_role }),
})
if (!res.ok) throw new Error("create side:" + res.status)
const created: { id: string } = await res.json()
sideIdMap.set(localId, created.id)
}
}
const sideKeyOf = (p: Player): string | undefined =>
TWO_SIDED.includes(s.format) ? s.sideAssign[p.key] : SCRAMBLE_VS_SOLO.includes(s.format) ? s.scrambleAssign[p.key] : undefined
const participantIdMap = new Map<string, string>([["owner", ownerParticipantId]])
for (const p of s.players) {
if (p.kind === "owner") continue
const teeSelected = p.teeId ? s.course.tees.find((t) => t.id === p.teeId)?.name : undefined
const sideKey = sideKeyOf(p)
const realSideId = sideKey ? sideIdMap.get(sideKey) : undefined
const participantBody: Record<string, unknown> = {
stat_level: apiStatLevel(p.statLevel),
...(teeSelected ? { tee_name: teeSelected } : {}),
...(realSideId ? { round_side_id: realSideId } : {}),
}
if (p.accountId) {
participantBody.user_id = p.accountId
} else {
participantBody.guest_first_name = p.firstName.trim()
participantBody.guest_last_name = p.lastName.trim() || null
participantBody.gender = apiGenderOf(p.gender ?? "mann")
participantBody.handicap_index = p.hcp ?? null
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
}
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(participantBody),
})
if (!res.ok) {
const errBody = await res.json().catch(() => null)
throw new Error(errBody?.detail?.message ?? `add participant: ${res.status}`)
}
const created: { id: string } = await res.json()
participantIdMap.set(p.key, created.id)
}
if (TWO_SIDED.includes(s.format) || SCRAMBLE_VS_SOLO.includes(s.format)) {
const sideKey = owner ? sideKeyOf(owner) : undefined
const realSideId = sideKey ? sideIdMap.get(sideKey) : undefined
if (realSideId) {
await fetch(`/rounds/${roundId}/participants/${ownerParticipantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ round_side_id: realSideId }),
})
}
}
if (s.format === "moneyball") {
for (const p of s.players) {
if (s.moneyballSlots[p.key] === undefined) continue
const realId = participantIdMap.get(p.key)
if (!realId) continue
await fetch(`/rounds/${roundId}/participants/${realId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ lineup_order: s.moneyballSlots[p.key] }),
})
}
}
onCreated(roundId)
} catch (err) {
const msg = err instanceof Error ? err.message : ""
patch({
submitting: false,
submitError: msg && !msg.startsWith("create round:") && !msg.startsWith("create side:") ? msg : "Klarte ikke å opprette runden. Prøv igjen.",
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, patch, onCreated])
const value: WizardCtx = {
state,
patch,
visibleSteps,
goNext,
goBack,
canProceedStep1,
isAdditionalFlight,
submit,
exit: onExit,
}
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export function useWizard() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error("useWizard must be used within WizardProvider")
return ctx
}
export { ORDINARY_FORMATS }

View file

@ -0,0 +1,142 @@
"use client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Flag } from "lucide-react"
import { WizardProvider, useWizard } from "./wizard-context"
import { ProgressSteps } from "./progress"
import { FooterBar } from "./footer-bar"
import { Step1CourseTime } from "./step1-course-time"
import { Step2Format } from "./step2-format"
import { Step3Players } from "./step3-players"
import { Step4Teams } from "./step4-teams"
import { Step5Sharing } from "./step5-sharing"
function StepBody() {
const { state } = useWizard()
switch (state.step) {
case 1:
return <Step1CourseTime />
case 2:
return <Step2Format />
case 3:
return <Step3Players />
case 4:
return <Step4Teams />
case 5:
return <Step5Sharing />
default:
return null
}
}
function WizardInner() {
const { state, goNext, goBack, canProceedStep1, submit, isAdditionalFlight } = useWizard()
const isStep1Sub = state.step === 1 && state.s1Sub !== "fields"
const isStep1Fields = state.step === 1 && state.s1Sub === "fields"
const isLastStep = state.step === 5
function handleNext() {
if (state.submitting) return
if (isLastStep) {
void submit()
return
}
goNext()
}
const nextDisabled = isStep1Fields ? !canProceedStep1 : false
if (state.loadingMe) {
return (
<div className="flex min-h-dvh items-center justify-center bg-[var(--nr-bg)]">
<div aria-hidden="true" className="h-10 w-10 animate-spin rounded-full border-4 border-[var(--nr-accent-ring)] border-t-[var(--nr-accent)]" />
</div>
)
}
if (!state.ownGender) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-[var(--nr-bg)] px-5 text-center">
<p className="max-w-prose text-[15px] text-[var(--nr-ink)]">
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
<Link href="/account" className="font-semibold text-[var(--nr-accent)] underline underline-offset-2">
til kontoinnstillinger
</Link>
.
</p>
</div>
)
}
return (
<div className="nr flex min-h-dvh flex-col bg-[var(--nr-bg)]">
<header className="sticky top-0 z-10 border-b border-[var(--nr-border)] bg-[var(--nr-surface)]/90 backdrop-blur">
<div className="mx-auto flex max-w-2xl flex-col gap-3 px-4 py-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]">
<Flag className="h-4 w-4" aria-hidden="true" />
</span>
<span className="text-sm font-semibold tracking-tight text-[var(--nr-ink)]">Ny runde</span>
</div>
<ProgressSteps />
</div>
</header>
{isAdditionalFlight ? (
<div className="mx-auto mt-3 w-full max-w-2xl px-4">
<p className="rounded-lg border border-[var(--nr-accent-ring)] bg-[var(--nr-accent-soft)] px-3 py-2 text-[13px] text-[var(--nr-ink)]">
Legger til en ny flight i samme rundegruppe -- dato, starthull, antall hull og spilleform er
forhåndsutfylt fra den første flighten, men kan endres.
</p>
</div>
) : null}
{state.submitError ? (
<div className="mx-auto mt-3 w-full max-w-2xl px-4">
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-3 py-2 text-[13px] text-[var(--nr-danger)]">
{state.submitError}
</p>
</div>
) : null}
{/* pb-28 er reservert klaring for FooterBar (sticky bottom-0) --
ALDRI en py-/sm:py-klasse her (Tailwind emitterer sm-varianter i
en egen media-blokk etter grunnklassene, en slik klasse ville
slått padding-bottom tilbake ned uansett rekkefølge i selve
className-strengen). Reell, browser-verifisert bug denne runden:
uten denne klaringen overlapper FooterBar innhold nederst
lengre steg (f.eks. "Legg til gjest"-knappen i steg 3), og sluker
klikk stille -- samme feilklasse som er dokumentert i
ARCHITECTURE_DECISIONS.md/CHANGELOG.md fra 2026-08-02. En
pt-klasse er trygt, den påvirker aldri padding-bottom. */}
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-28 pt-5">
<StepBody />
</main>
{!isStep1Sub ? (
<FooterBar
onBack={goBack}
onNext={handleNext}
nextDisabled={nextDisabled}
backDisabled={state.submitting}
submitting={state.submitting}
isSubmitStep={isLastStep}
nextLabel={isLastStep ? (state.submitting ? "Oppretter…" : "Start runden") : "Neste"}
/>
) : null}
</div>
)
}
export function NewRoundWizard() {
const router = useRouter()
const exit = () => router.push("/my-rounds")
const created = (roundId: string) => router.replace(`/my-rounds/${roundId}`)
return (
<WizardProvider onExit={exit} onCreated={created}>
<WizardInner />
</WizardProvider>
)
}

View file

@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button"
import { Wordmark } from "@/components/wordmark"
import { RoundCard, type Round } from "@/components/round-card"
import { ArrowLeft } from "lucide-react"
import { BottomTabBar } from "@/components/dashboard"
import { BottomNav } from "@/components/teecup/bottom-nav"
type ApiRoundParticipant = {
id: string
@ -105,7 +105,7 @@ export function OwnRounds() {
try {
const res = await fetch("/rounds", { credentials: "include" })
if (res.status === 401) {
router.replace("/")
router.replace("/logg-inn")
return
}
if (!res.ok) throw new Error(`rounds: ${res.status}`)
@ -181,7 +181,7 @@ export function OwnRounds() {
</div>
</main>
<BottomTabBar active="rounds" />
<BottomNav />
</div>
)
}

View file

@ -30,6 +30,7 @@ import {
Minus,
Plus,
RefreshCw,
Ruler,
Search,
Settings2,
Target,
@ -45,6 +46,8 @@ import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/sc
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { ClubPicker } from "@/components/teecup/club-picker"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types -----------------------------------------------------------------
@ -1638,6 +1641,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)}
{wizardPlayer && hole && (
<ScoringWizard
roundId={roundId}
players={players}
player={wizardPlayer}
hole={hole}
@ -1694,6 +1698,7 @@ function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPutt
}
function ScoringWizard({
roundId,
players,
player,
hole,
@ -1707,6 +1712,7 @@ function ScoringWizard({
playFormat,
strokesReceived,
}: {
roundId: string
players: Player[]
player: Player
hole: Hole
@ -1732,6 +1738,71 @@ function ScoringWizard({
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- kun
// deltaker-eide hull (denne veiviseren brukes utelukkende for
// per-spiller-formater, se ScoringWizard sin egen kommentar over --
// delt-ball-formater bruker en egen, enklere side-veiviser uten dette
// steget). `shotCount` lastes på nytt hver gang hull/spiller endres.
const [shotSheetOpen, setShotSheetOpen] = useState(false)
const [shotCount, setShotCount] = useState(0)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((shots: unknown[]) => {
if (!cancelled) setShotCount(shots.length)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId, player.id, hole.holeNumber])
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endLat: number
endLng: number
share: boolean
shareText: string
}) {
const res = await fetch(
`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_lat: result.endLat,
end_lng: result.endLng,
}),
},
)
if (!res.ok) {
setShotSheetOpen(false)
return
}
const shot = await res.json()
if (result.share) {
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
}
setShotCount((n) => n + 1)
setShotSheetOpen(false)
}
const steps = wizardStepsFor(player.statLevel)
const [stepIndex, setStepIndex] = useState(0)
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
@ -1936,6 +2007,17 @@ function ScoringWizard({
readOnly={readOnly}
/>
{!readOnly && (
<button
type="button"
onClick={() => setShotSheetOpen(true)}
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-5 shrink-0" aria-hidden="true" />
{shotCount > 0 ? `Mål et slag (${shotCount} målt)` : "Mål et slag"}
</button>
)}
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
@ -1998,6 +2080,16 @@ function ScoringWizard({
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
</Button>
</footer>
{shotSheetOpen && (
<ShotMeasurementSheet
holeNumber={hole.holeNumber}
existingShotCount={shotCount}
ownBagClubs={player.isSelf ? ownBagClubs : []}
onSubmit={submitShot}
onCancel={() => setShotSheetOpen(false)}
/>
)}
</div>
)
}
@ -6255,63 +6347,6 @@ function DirectionCross({
)
}
// --- Kølle-plukker -----------------------------------------------------
// Delt mellom ScoringWizard sitt "details"-steg (utslags-kølle) og
// ShotMeasurementSheet (ADR-048, slag-for-slag måling) -- ren utrekking,
// ingen atferdsendring for veiviserens eksisterende bruk.
function ClubPicker({
value,
onChange,
ownBagClubs,
readOnly = false,
label = "Kølle",
}: {
value: string
onChange: (club: string) => void
ownBagClubs: string[]
readOnly?: boolean
label?: string
}) {
return (
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">{label}</Label>
{ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = value === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onChange(club)}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
function ChoiceRow({

View file

@ -411,7 +411,7 @@ export function RoundsStatsSummary() {
try {
const res = await fetch(`/rounds/stats/summary?window=${window}`, { credentials: "include" })
if (res.status === 401) {
router.replace("/")
router.replace("/logg-inn")
return
}
if (!res.ok) throw new Error(`stats: ${res.status}`)

View file

@ -0,0 +1,125 @@
"use client"
import { useEffect, useRef, useState } from "react"
import mapboxgl from "mapbox-gl"
import "mapbox-gl/dist/mapbox-gl.css"
import { MapPin } from "lucide-react"
type LngLat = { lng: number; lat: number }
/**
* MapPointPicker
*
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per
* sheet-open and stays mounted until the sheet closes, so the Mapbox map is
* created exactly once (empty-deps useEffect) taps, pans and marker drags
* never re-initialise it. This keeps Mapbox map loads (which are billed) to a
* single load per sheet-open.
*/
export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => void }) {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markerRef = useRef<mapboxgl.Marker | null>(null)
const [loaded, setLoaded] = useState(false)
const [point, setPoint] = useState<LngLat | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
const container = containerRef.current
if (!container) return
if (!token) {
setError("Kartet er ikke tilgjengelig akkurat nå. Bruk «Min posisjon nå» i stedet.")
return
}
mapboxgl.accessToken = token
const map = new mapboxgl.Map({
container,
style: "mapbox://styles/mapbox/satellite-v9",
center: [10.7522, 59.9139], // Oslo fallback; real app centers on last-known position
zoom: 16,
attributionControl: false,
})
mapRef.current = map
map.on("load", () => setLoaded(true))
map.on("error", () => setError("Kunne ikke laste kartet."))
// Tap to place/move a single draggable marker — never re-inits the map.
map.on("click", (e) => {
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
if (!markerRef.current) {
const marker = new mapboxgl.Marker({ draggable: true, color: "#d2551a" })
.setLngLat(e.lngLat)
.addTo(map)
marker.on("dragend", () => {
const p = marker.getLngLat()
setPoint({ lng: p.lng, lat: p.lat })
})
markerRef.current = marker
} else {
markerRef.current.setLngLat(e.lngLat)
}
setPoint(lngLat)
})
return () => {
markerRef.current?.remove()
markerRef.current = null
map.remove()
mapRef.current = null
}
}, [])
return (
<div className="flex h-full flex-col">
<div className="relative flex-1">
{/* Map canvas host — kept mounted for the map's whole lifetime. */}
<div ref={containerRef} className="absolute inset-0" aria-label="Satellittkart" role="application" />
{!loaded && !error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Laster kart </p>
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<p role="alert" className="max-w-sm text-center text-base text-destructive">
{error}
</p>
</div>
) : null}
{loaded && !point && !error ? (
<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">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
Trykk kartet for å plassere punktet
</p>
</div>
) : null}
</div>
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!point}
onClick={() => point && onConfirm(point)}
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>
)
}
export default MapPointPicker

View file

@ -0,0 +1,446 @@
"use client"
import { useEffect, useRef, useState } from "react"
import dynamic from "next/dynamic"
import { Crosshair, LocateFixed, MapPin, X, ChevronLeft, Check } from "lucide-react"
import { ClubPicker } from "@/components/teecup/club-picker"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
/**
* Lazy, client-only reference to the map step. Because dynamic() defers the
* underlying import() until <MapPointPicker /> is actually rendered, neither
* this module nor a GPS-only run ever pulls in mapbox-gl. A Mapbox map load
* (which is billed) only happens once the user chooses "Velg punkt på kart".
*/
const MapPointPicker = dynamic(
() => import("@/components/shot/map-point-picker").then((m) => m.MapPointPicker),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
),
},
)
type LngLat = { lng: number; lat: number }
type Step = "start" | "map" | "end" | "club" | "result"
type GeoStatus = "idle" | "loading" | "error"
export type ShotMeasurementSheetProps = {
holeNumber: number
existingShotCount: number
ownBagClubs: string[]
/**
* Computed by the caller (Haversine) once both points are known. Optional
* here so the sheet stays previewable; when absent a rough preview-only
* stub is used purely for display.
*/
distanceMeters?: number
onSubmit: (result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endLat: number
endLng: number
share: boolean
shareText: string
}) => void
onCancel: () => void
}
// Preview-only rough distance so the result step shows a real number in v0.
// The real app passes distanceMeters computed server-/caller-side.
function previewDistance(a: LngLat | null, b: LngLat | null): number {
if (!a || !b) return 0
const R = 6371000
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({
holeNumber,
existingShotCount,
ownBagClubs,
distanceMeters,
onSubmit,
onCancel,
}: ShotMeasurementSheetProps) {
const [step, setStep] = useState<Step>("start")
const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null)
const [startPoint, setStartPoint] = useState<LngLat | null>(null)
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
const [endStatus, setEndStatus] = useState<GeoStatus>("idle")
const [club, setClub] = useState<string>("")
const [share, setShare] = useState(false)
const [shareText, setShareText] = useState("")
const shareEdited = useRef(false)
function acquirePosition(onOk: (p: LngLat) => void, onFail: () => void) {
if (typeof navigator === "undefined" || !navigator.geolocation) {
onFail()
return
}
navigator.geolocation.getCurrentPosition(
(pos) => onOk({ lng: pos.coords.longitude, lat: pos.coords.latitude }),
() => onFail(),
{ enableHighAccuracy: true, timeout: 10000 },
)
}
// Start via device GPS.
function useMyPosition() {
setStartStatus("loading")
acquirePosition(
(p) => {
setStartPoint(p)
setStartMethod("gps")
setStartStatus("idle")
setStep("end")
},
() => setStartStatus("error"),
)
}
// Ball position is ALWAYS GPS, regardless of how the start was chosen.
const endRequested = useRef(false)
useEffect(() => {
if (step !== "end") {
endRequested.current = false
return
}
if (endRequested.current) return
endRequested.current = true
setEndStatus("loading")
acquirePosition(
(p) => {
setEndPoint(p)
setEndStatus("idle")
setStep("club")
},
() => setEndStatus("error"),
)
}, [step])
const distance =
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
// Prefill the (editable) share text once the result step is reached.
useEffect(() => {
if (step === "result" && !shareEdited.current) {
setShareText(`${club || "slag"}, ${distance} m hull ${holeNumber}`)
}
}, [step, club, distance, holeNumber])
function retryEnd() {
setEndStatus("loading")
acquirePosition(
(p) => {
setEndPoint(p)
setEndStatus("idle")
setStep("club")
},
() => setEndStatus("error"),
)
}
function goBack() {
if (step === "map" || step === "end") {
setStartStatus("idle")
setEndStatus("idle")
setStep("start")
} else if (step === "club") {
// Re-measure the ball position rather than reusing a stale fix.
setEndPoint(null)
setStep("end")
} else if (step === "result") {
setStep("club")
}
}
function confirmSubmit() {
if (!startPoint || !endPoint || !club) return
onSubmit({
club,
distanceMeters: distance,
startMethod: startMethod ?? "gps",
startLat: startPoint.lat,
startLng: startPoint.lng,
endLat: endPoint.lat,
endLng: endPoint.lng,
share,
shareText: share ? shareText : "",
})
}
const onMapStep = step === "map"
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
style={{ fontFamily: "var(--font-nunito), ui-sans-serif, system-ui, sans-serif" }}
role="dialog"
aria-modal="true"
aria-label={`Mål et slag, hull ${holeNumber}`}
>
{/* Header */}
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
<div className="flex items-start gap-2">
{step !== "start" ? (
<button
type="button"
onClick={goBack}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
Tilbake
</button>
) : null}
<div className="pt-1.5">
<h1 className="text-lg font-bold leading-tight text-balance">
{`Mål et slag — hull ${holeNumber}`}
</h1>
{existingShotCount > 0 ? (
<p className="mt-0.5 text-sm text-muted-foreground">
{`${existingShotCount} slag målt tidligere`}
</p>
) : null}
</div>
</div>
<button
type="button"
onClick={onCancel}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-5 shrink-0" aria-hidden="true" />
Lukk
</button>
</header>
{/* Main */}
<main className={cn("flex-1", onMapStep ? "overflow-hidden" : "overflow-y-auto p-4")}>
{step === "start" ? (
<div className="mx-auto flex max-w-md flex-col gap-4 pt-2">
<p className="text-base text-muted-foreground text-pretty">
Velg hvor slaget startet. Ballens sluttposisjon måles alltid med GPS.
</p>
<button
type="button"
onClick={useMyPosition}
disabled={startStatus === "loading"}
className="flex min-h-16 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 disabled:opacity-60"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<LocateFixed className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Min posisjon </span>
<span className="text-sm text-muted-foreground">Bruk telefonens GPS</span>
</span>
</button>
<button
type="button"
onClick={() => {
setStartMethod("map_tap")
setStep("map")
}}
className="flex min-h-16 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">Velg punkt kart</span>
<span className="text-sm text-muted-foreground">Trykk satellittkartet</span>
</span>
</button>
{startStatus === "loading" ? (
<div className="flex items-center justify-center gap-3 pt-2">
<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>
</div>
) : null}
{startStatus === "error" ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<p role="alert" className="text-base text-destructive text-pretty">
Fant ikke posisjonen din. Sjekk at posisjonstjenester er , og prøv igjen.
</p>
<button
type="button"
onClick={useMyPosition}
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>
</div>
) : null}
</div>
) : null}
{step === "map" ? <MapPointPicker onConfirm={(p) => {
setStartPoint(p)
setStep("end")
}} /> : null}
{step === "end" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-10 text-center">
{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={retryEnd}
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>
</div>
) : null}
</div>
) : null}
{step === "club" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold">Hvilken kølle brukte du?</h2>
<p className="text-base text-muted-foreground">Velg køllen for dette slaget.</p>
</div>
{/* Reuses the shared ClubPicker (imported, not rebuilt). */}
<ClubPicker value={club} onChange={setClub} ownBagClubs={ownBagClubs} />
</div>
) : null}
{step === "result" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Målt lengde
</span>
<span className="text-6xl font-extrabold tabular-nums leading-none">
{distance}
<span className="ml-1 text-3xl font-bold text-muted-foreground">m</span>
</span>
{club ? (
<span className="mt-2 inline-flex items-center rounded-full bg-muted px-3 py-1 text-sm font-semibold text-foreground">
{club}
</span>
) : null}
</div>
{/* Private vs share segmented control */}
<div className="flex flex-col gap-3">
<span className="text-sm font-semibold text-muted-foreground">Deling</span>
<div
role="radiogroup"
aria-label="Deling av slaget"
className="grid grid-cols-2 gap-2 rounded-full bg-muted p-1"
>
{[
{ key: false, label: "Behold privat" },
{ key: true, label: "Del i feeden" },
].map((opt) => {
const selected = share === opt.key
return (
<button
key={String(opt.key)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setShare(opt.key)}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded-full px-4 text-base font-bold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
)
})}
</div>
{share ? (
<div className="flex flex-col gap-2">
<label htmlFor="share-text" className="text-sm font-semibold text-muted-foreground">
Innlegg
</label>
<Textarea
id="share-text"
value={shareText}
onChange={(e) => {
shareEdited.current = true
setShareText(e.target.value)
}}
rows={3}
/>
</div>
) : null}
</div>
</div>
) : null}
</main>
{/* Footer hidden on map step (MapPointPicker owns its own confirm bar)
and on the transient GPS steps. */}
{step === "club" || step === "result" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{step === "club" ? (
<button
type="button"
disabled={!club.trim()}
onClick={() => setStep("result")}
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"
>
Neste
</button>
) : (
<button
type="button"
onClick={confirmSubmit}
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"
>
<Check className="size-5 shrink-0" aria-hidden="true" />
Lagre slag
</button>
)}
</footer>
) : null}
</div>
)
}
export default ShotMeasurementSheet

View file

@ -0,0 +1,119 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Flag, Home, MoreHorizontal, Trophy, UserRound } from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
type Tab = {
label: string
icon: LucideIcon
href: string
/** Optional count shown as a small badge (e.g. unread items). */
badge?: number
/** Ekstra ruter som også skal telle som "denne fanen er aktiv" --
* for sider som ikke selv er et direkte fanemål, men som appens egen
* IA (se more-menu.tsx) allerede grupperer under denne fanen. */
matchPaths?: string[]
}
// hrefs rettet til de faktiske frontend-rutene (V0 kjente ikke disse) --
// /rounds er reservert API-prefiks (samme kollisjonsklasse som
// /rounds->/my-rounds tidligere i prosjektet), og det finnes ingen egen
// "/tournaments"-liste-side, kun /tournaments/[id]. "Turneringer" peker
// derfor til dashbordets egen "Kommende turneringer"-seksjon.
const TABS: Tab[] = [
{ label: "Hjem", icon: Home, href: "/dashboard" },
{ label: "Runder", icon: Flag, href: "/my-rounds" },
{ label: "Turneringer", icon: Trophy, href: "/dashboard#kommende-turneringer" },
{ label: "Profil", icon: UserRound, href: "/account" },
// /my-friends, /my-feed og /my-notifications er ikke egne fanemål, men er
// allerede listet som "Mer"-sidens egne undersider (more-menu.tsx) -- uten
// matchPaths viste ingen fane som aktiv i det hele tatt på disse tre
// sidene (reelt funnet under scratch-verifisering, se CHANGELOG.md).
{ label: "Mer", icon: MoreHorizontal, href: "/more", matchPaths: ["/my-friends", "/my-feed", "/my-notifications"] },
]
function isActive(pathname: string, href: string) {
// IKKE strippe et evt. #hash før sammenligning (fristende, men galt --
// forsøkt og reelt browser-verifisert som en regresjon, se CHANGELOG.md):
// "Turneringer" sin href er et anker PÅ dashbordet (/dashboard#kommende-
// turneringer), og usePathname() inneholder ALDRI hash. Strippes hashen
// før sammenligning, matcher BÅDE "Hjem" og "Turneringer" samtidig på
// /dashboard -- to faner fremhevet på én gang. Rå strengsammenligning
// (V0s opprinnelige, ikke rørt her) unngår dette: "Turneringer" matcher
// aldri via ren pathname, nøyaktig som dagens BottomTabBar-erstatning
// uansett aldri fremhevet den (dashbordet satte alltid active="home").
if (href === "/") return pathname === "/"
return pathname === href || pathname.startsWith(`${href}/`)
}
export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) {
const pathname = usePathname() ?? ""
return (
// The bar is deliberately self-contained: it renders on six screens that
// belong to two different visual languages (the new "clubhouse" look and
// the older one). Rather than inherit any page palette, it reads as its
// own floating white surface with a hairline + soft lift, so it looks
// intentional on any background. The only brand token it leans on is the
// green accent, which is the one constant shared across both palettes.
<nav
aria-label="Hovednavigasjon"
className="fixed inset-x-0 bottom-0 z-40 border-t border-black/10 bg-white/90 shadow-[0_-1px_12px_rgba(23,32,15,0.06)] backdrop-blur-md supports-[backdrop-filter]:bg-white/80"
>
<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) => {
const Icon = tab.icon
const active =
isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false)
return (
<li key={tab.href} className="flex-1">
<Link
href={tab.href}
aria-current={active ? "page" : undefined}
className={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",
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
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>
</Link>
</li>
)
})}
</ul>
</nav>
)
}

View file

@ -0,0 +1,61 @@
"use client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
// Delt mellom ScoringWizard sitt "details"-steg (round-detail.tsx,
// utslags-kølle) og ShotMeasurementSheet (ADR-048, slag-for-slag måling) --
// ren utrekking 2026-08-08, ingen atferdsendring for veiviserens
// eksisterende bruk.
export function ClubPicker({
value,
onChange,
ownBagClubs,
readOnly = false,
label = "Kølle",
}: {
value: string
onChange: (club: string) => void
ownBagClubs: string[]
readOnly?: boolean
label?: string
}) {
return (
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">{label}</Label>
{ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = value === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onChange(club)}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
)
}

View file

@ -1,8 +1,10 @@
"use client"
import type React from "react"
import { useEffect, useId, useRef, useState } from "react"
import { useEffect, useId, useState } from "react"
import { useRouter } from "next/navigation"
import { cn } from "@/lib/utils"
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
/* ------------------------------------------------------------------ */
/* Icons — inline SVG (currentColor), so every action can pair an */
@ -123,40 +125,23 @@ const backBtn = cn(
focusRing,
)
/* ------------------------------------------------------------------ */
/* Mocked backend — stands in for the real API so the flow actually */
/* runs (loading, success, and error responses are all real here). */
/* ------------------------------------------------------------------ */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
async function apiSendMagicLink(_email: string) {
await sleep(1100)
return { ok: true as const }
}
async function apiPasswordLogin(_email: string, password: string) {
await sleep(1100)
// Demo credential so the success path is reachable; anything else fails.
if (password === "teecup123") return { ok: true as const }
return { ok: false as const, error: "Feil e-post eller passord. Prøv igjen." }
}
async function apiJoinByCode(code: string) {
await sleep(1000)
const known: Record<string, string> = {
TEECUP: "Lørdagsrunden",
"RYDER-25": "Ryder Cup 2025",
HOST2026: "Høstcupen 2026",
}
const name = known[code.trim().toUpperCase()]
if (name) return { ok: true as const, name }
return {
ok: false as const,
error: `Fant ingen turnering med koden «${code.trim()}». Sjekk at den er riktig skrevet.`,
}
}
const RESEND_SECONDS = 30
type Mode = "email" | "sent" | "password" | "code"
const RESEND_SECONDS = 30
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
type SessionUser = {
id: string
email: string
display_name: string
preferred_locale: string
}
type LoginResult = {
status: "success" | "2fa_required" | "2fa_setup_required"
user?: SessionUser
two_factor_method?: "totp" | "email"
}
/* ------------------------------------------------------------------ */
/* Small building blocks */
@ -186,7 +171,10 @@ function FormAlert({ children }: { children: React.ReactNode }) {
/* Main component */
/* ------------------------------------------------------------------ */
export function TeeCupAuth() {
const router = useRouter()
const [mode, setMode] = useState<Mode>("email")
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email">("totp")
// shared field state (email persists across email <-> password)
const [email, setEmail] = useState("")
@ -200,25 +188,50 @@ export function TeeCupAuth() {
const [formError, setFormError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
/* ---- State 1: magic link ---- */
function handleLoginResult(result: LoginResult) {
if (result.status === "success") {
router.replace("/dashboard")
} else if (result.status === "2fa_required") {
setTwoFactorMethod(result.two_factor_method ?? "totp")
setPostAuth("2fa-verify")
} else {
setPostAuth("2fa-setup")
}
}
function handleTwoFactorSuccess() {
router.replace("/dashboard")
}
/* ---- State 1: magic link, ekte /auth/request-link ---- */
async function handleSendLink(e: React.FormEvent) {
e.preventDefault()
setEmailTouched(true)
if (!EMAIL_RE.test(email)) return
if (!EMAIL_RE.test(email) || loading) return
setFormError(null)
setLoading(true)
const res = await apiSendMagicLink(email)
setLoading(false)
if (res.ok) setMode("sent")
try {
// Alltid samme suksess-respons uansett om e-posten finnes (anti-
// enumerering, se ADR-009) -- kun nettverks-/serverfeil havner i catch.
const res = await fetch("/auth/request-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
})
if (!res.ok) throw new Error(`request-link: ${res.status}`)
setMode("sent")
setCooldown(RESEND_SECONDS)
} catch {
setFormError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
} finally {
setLoading(false)
}
}
/* ---- State 2: cooldown ---- */
const [cooldown, setCooldown] = useState(0)
const [resentNote, setResentNote] = useState(false)
useEffect(() => {
if (mode !== "sent") return
setCooldown(RESEND_SECONDS)
}, [mode])
useEffect(() => {
if (cooldown <= 0) return
const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
@ -228,55 +241,80 @@ export function TeeCupAuth() {
async function handleResend() {
if (cooldown > 0 || loading) return
setResentNote(false)
setFormError(null)
setLoading(true)
await apiSendMagicLink(email)
setLoading(false)
setCooldown(RESEND_SECONDS)
setResentNote(true)
try {
const res = await fetch("/auth/request-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
})
if (!res.ok) throw new Error(`request-link: ${res.status}`)
setCooldown(RESEND_SECONDS)
setResentNote(true)
} catch {
setFormError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
} finally {
setLoading(false)
}
}
/* ---- State 3: password ---- */
/* ---- State 3: password, ekte /auth/login-password ---- */
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const pwIds = useId()
async function handlePasswordLogin(e: React.FormEvent) {
e.preventDefault()
if (!email || !password) return
if (!email.trim() || !password || loading) return
setFormError(null)
setLoading(true)
const res = await apiPasswordLogin(email, password)
setLoading(false)
if (res.ok) {
setSuccess("Logget inn! Neste steg er en engangskode på e-post (2FA).")
} else {
setFormError(res.error)
try {
const res = await fetch("/auth/login-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), password }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "E-post eller passord er feil.")
}
handleLoginResult(await res.json())
} catch (err) {
setFormError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setLoading(false)
}
}
/* ---- State 4: join by code ---- */
/* ---- State 4: join by code, ekte /public/tournaments/by-code ---- */
const [code, setCode] = useState("")
async function handleJoin(e: React.FormEvent) {
e.preventDefault()
if (!code.trim()) return
const trimmed = code.trim()
if (!trimmed || loading) return
setFormError(null)
setLoading(true)
const res = await apiJoinByCode(code)
setLoading(false)
if (res.ok) {
setSuccess(`Fant turneringen «${res.name}». Sender deg dit …`)
} else {
setFormError(res.error)
try {
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
}
const data: { tournament_id: string } = await res.json()
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
} catch (err) {
setFormError(err instanceof Error ? err.message : "Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.")
} finally {
setLoading(false)
}
}
/* ---- shared success confirmation ---- */
const [success, setSuccess] = useState<string | null>(null)
// Reset transient per-mode state when switching modes.
function switchMode(next: Mode, opts?: { clearEmail?: boolean }) {
setFormError(null)
setSuccess(null)
setPassword("")
setShowPassword(false)
setResentNote(false)
@ -288,22 +326,19 @@ export function TeeCupAuth() {
setMode(next)
}
/* ---------------- success view (shared continuation) ------------- */
if (success) {
/* ---------------- 2FA continuation (shared with password login) --- */
if (postAuth === "2fa-verify") {
return (
<div className="text-center">
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-tee/20 text-tee-strong">
<CheckIcon className="size-8" />
</div>
<h2 className="text-pretty text-xl font-bold text-clubhouse-ink">Alt klart</h2>
<p className="mx-auto mt-2 max-w-xs text-pretty text-base leading-relaxed text-clubhouse-muted">{success}</p>
<button type="button" className={cn(backBtn, "mt-6 justify-center")} onClick={() => switchMode("email", { clearEmail: true })}>
<ArrowLeftIcon className="size-5" />
Tilbake til innlogging
</button>
</div>
<TwoFactorVerifyForm
method={twoFactorMethod}
onSuccess={handleTwoFactorSuccess}
onBack={() => setPostAuth(null)}
/>
)
}
if (postAuth === "2fa-setup") {
return <TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
}
return (
<div>
@ -338,6 +373,8 @@ export function TeeCupAuth() {
{emailInvalid && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
</div>
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
{loading ? "Sender …" : "Send innloggingslenke"}
@ -375,7 +412,9 @@ export function TeeCupAuth() {
Åpne lenken denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
</p>
{resentNote && (
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
{resentNote && !formError && (
<p role="status" className="mt-4 flex items-center gap-2 text-[0.9375rem] font-medium text-tee-strong">
<CheckIcon className="size-5 shrink-0" />
Lenken er sendt nytt.
@ -454,7 +493,7 @@ export function TeeCupAuth() {
</div>
</div>
<button type="submit" disabled={loading || !email || !password} className={cn(primaryBtn, "mt-6")}>
<button type="submit" disabled={loading || !email.trim() || !password} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
{loading ? "Logger inn …" : "Logg inn"}
</button>
@ -494,12 +533,8 @@ export function TeeCupAuth() {
placeholder="F.EKS. RYDER-25"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
aria-describedby="invite-hint"
className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")}
/>
<p id="invite-hint" className="mt-1.5 text-[0.875rem] text-clubhouse-muted">
Prøv <span className="font-mono font-semibold text-clubhouse-ink">TEECUP</span> for en demo.
</p>
</div>
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>

View file

@ -1,8 +1,9 @@
"use client"
// Delt mellom login-form.tsx (passord-modus) og verify-form.tsx (magic-link)
// -- begge kan returnere status "2fa_required"/"2fa_setup_required" fra
// backend (ADR-021 Beslutning E), og trenger identisk oppfølging.
// Delt mellom teecup-auth.tsx (passord-modus, /logg-inn) og verify-form.tsx
// (magic-link) -- begge kan returnere status "2fa_required"/
// "2fa_setup_required" fra backend (ADR-021 Beslutning E), og trenger
// identisk oppfølging.
import type React from "react"
import { useEffect, useState } from "react"

View file

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex min-h-24 w-full rounded-xl border border-border bg-background px-4 py-3 text-base text-foreground shadow-sm transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"resize-none leading-relaxed",
className,
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -171,7 +171,7 @@ export function VerifyForm() {
</Button>
<a
href="/"
href="/logg-inn"
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
Be om en ny lenke

View file

@ -0,0 +1,175 @@
import type { ApiCourseHole, ApiCourseTee } from "@/components/course-template-editor"
import type { Account, ApiGender, Course, Facility, Gender, KnownGuest, Tee } from "./types"
// --- Kjønn-konvertering (samme mønster som new-round.tsx) ------------------
export function apiGenderOf(g: Gender): ApiGender | "x" {
return g === "mann" ? "m" : g === "kvinne" ? "f" : "x"
}
export function uiGenderOf(g: ApiGender): Gender {
return g === "m" ? "mann" : "kvinne"
}
// --- Rå API-typer (kun det denne veiviseren faktisk leser) -----------------
type ApiTeeOption = { name: string; genders: ApiGender[] }
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiNearbyFacility = ApiFacility & { distance_km: number }
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
tees: ApiTeeOption[]
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}
type ApiPersonalCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiPersonalCourseDetail = {
id: string
name: string
tees: ApiTeeOption[]
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
is_mine: boolean
created_by_display_name: string
forked_from_id: string | null
}
export type PersonMatch = {
id: string
first_name: string
last_name: string
avatar_url: string | null
home_club: string | null
handicap_index: number | null
}
type ApiKnownGuest = {
guest_first_name: string
guest_last_name: string | null
gender: ApiGender | null
handicap_index: number | null
}
let idSeq = 0
function newId(prefix: string) {
idSeq += 1
return `${prefix}-${idSeq}`
}
function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
return apiTees.map((t, i) => ({ id: `${t.name}-${i}`, name: t.name, genders: t.genders }))
}
function courseFromOfficial(teeoffCourseId: number, c: {
name: string
tees: ApiTeeOption[]
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}): Course {
return {
id: `teeoff-${teeoffCourseId}`,
name: c.name,
tees: apiTeesToTees(c.tees),
templateHoles: c.holes,
templateTees: c.full_tees,
}
}
// --- Offisielt banesøk -------------------------------------------------------
export async function searchOfficialFacilities(query: string): Promise<Facility[]> {
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query)}`, { credentials: "include" })
if (!res.ok) return []
const data: ApiFacility[] = await res.json()
return data
}
export async function nearbyOfficialFacilities(lat: number, lng: number): Promise<(Facility & { distanceKm: number })[]> {
const res = await fetch(`/rounds/official-search/nearby?lat=${lat}&lng=${lng}&limit=5`, { credentials: "include" })
if (!res.ok) return []
const data: ApiNearbyFacility[] = await res.json()
return data.map((f) => ({ slug: f.slug, name: f.name, city: f.city, county: f.county, distanceKm: f.distance_km }))
}
export async function fetchFacilityCourses(slug: string): Promise<{ teeoffCourseId: number; course: Course }[]> {
const res = await fetch(`/rounds/official-search/${slug}`, { credentials: "include" })
if (!res.ok) return []
const data: { slug: string; name: string; courses: ApiOfficialCourseOption[] } = await res.json()
return data.courses.map((c) => ({
teeoffCourseId: c.teeoff_course_id,
course: courseFromOfficial(c.teeoff_course_id, c),
}))
}
// --- Egne baner ---------------------------------------------------------------
export async function searchOwnCourses(query: string): Promise<{ id: string; name: string }[]> {
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query)}`, { credentials: "include" })
if (!res.ok) return []
const data: ApiPersonalCourse[] = await res.json()
return data.map((c) => ({ id: c.id, name: c.name }))
}
export async function fetchOwnCourse(id: string): Promise<Course | null> {
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
if (!res.ok) return null
const c: ApiPersonalCourseDetail = await res.json()
return {
id: c.id,
name: c.name,
tees: apiTeesToTees(c.tees),
templateHoles: c.holes,
templateTees: c.full_tees,
}
}
export async function createOwnCourse(body: {
name: string
holes: { hole_number: number; par: number; stroke_index: number }[]
tees: { name: string; ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] }[]
forkedFromId?: string | null
}): Promise<Course> {
const res = await fetch("/personal-courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: body.name,
holes: body.holes,
tees: body.tees,
forked_from_id: body.forkedFromId ?? null,
}),
})
if (!res.ok) throw new Error(`create: ${res.status}`)
const created: { id: string; name: string } = await res.json()
return {
id: created.id,
name: created.name,
tees: body.tees.map((t) => ({
id: newId(t.name),
name: t.name,
genders: t.ratings.map((r) => r.gender),
})),
}
}
// --- Spillersøk / kjent gjest -------------------------------------------------
export async function searchAccounts(query: string): Promise<Account[]> {
const res = await fetch(`/people/search?q=${encodeURIComponent(query)}`, { credentials: "include" })
if (!res.ok) return []
const data: PersonMatch[] = await res.json()
return data.map((p) => ({ id: p.id, firstName: p.first_name, lastName: p.last_name, handicap: p.handicap_index }))
}
export async function lookupKnownGuest(email: string): Promise<KnownGuest | null> {
const res = await fetch(`/rounds/guests/known?email=${encodeURIComponent(email)}`, { credentials: "include" })
if (!res.ok) return null
const g: ApiKnownGuest | null = await res.json()
if (!g) return null
return {
firstName: g.guest_first_name,
lastName: g.guest_last_name ?? "",
gender: g.gender,
hcp: g.handicap_index,
}
}

View file

@ -0,0 +1,156 @@
import type { FormatId } from "./types"
export interface FormatDef {
id: FormatId
label: string
description: string
}
export const FORMATS: FormatDef[] = [
{ id: "slagspill", label: "Slagspill", description: "Vanlig individuell runde." },
{
id: "stableford",
label: "Stableford",
description: "Poeng per hull i stedet for slag kan plukke opp ved 0 poeng.",
},
{ id: "match", label: "Match", description: "To sider, hull mot hull." },
{ id: "skins", label: "Skins", description: "Hvert hull er en egen premie minst 3 spillere." },
{ id: "fourball", label: "Fourball", description: "To mot to, beste ball per side." },
{ id: "foursome", label: "Foursome", description: "To mot to, én delt ball, annenhver slår." },
{
id: "greensome",
label: "Greensome",
description: "To mot to, begge slår ut, velger så én ball.",
},
{
id: "scramble2",
label: "Scramble (2)",
description: "To mot to, velger beste slag hver gang.",
},
{
id: "scramble4",
label: "Scramble (4)",
description: "Fire mot fire, velger beste slag hver gang.",
},
{
id: "chapman",
label: "Chapman",
description: "To mot to, bytter utslag, velger så én ball som foursome fra andre slag.",
},
{
id: "copenhagen",
label: "Københavner",
description: "Nøyaktig 3 spillere 6 poeng deles per hull etter rangering.",
},
{
id: "bbb",
label: "Bingo Bango Bongo",
description: "Poeng for først på green, nærmest hull, først i hull.",
},
{
id: "flag",
label: "Flaggturnering",
description: "Fast slagbudsjett (par + HCP) flagget plantes der slagene tar slutt.",
},
{
id: "shamble",
label: "Shamble",
description: "2-4 spillere, alle slår ut, velger beste spiller så egen ball inn.",
},
{
id: "moneyball",
label: "Money Ball",
description: "Fire spillere, én roterer som 'money ball' per hull.",
},
{
id: "highlowhigh",
label: "High-low-high",
description: "To lag à to høy mot høy, lav mot lav, per hull.",
},
{
id: "scramblevssolo",
label: "Scramble mot enkeltspiller",
description:
"Et lag med delt ball (minst 2 spillere) mot én enkeltspiller — nettoscore avgjør.",
},
{
id: "scramblevssolomatch",
label: "Scramble mot enkeltspiller (matchspill)",
description: "Samme oppsett som over, men avgjort hull for hull i stedet for netto totalsum.",
},
]
export const FORMAT_MAP: Record<FormatId, FormatDef> = FORMATS.reduce(
(acc, f) => {
acc[f.id] = f
return acc
},
{} as Record<FormatId, FormatDef>,
)
/** Two-sided team formats (drive the standard step-4 A/B layout). */
export const TWO_SIDED: FormatId[] = [
"match",
"fourball",
"foursome",
"greensome",
"scramble2",
"scramble4",
"chapman",
"highlowhigh",
]
export const SCRAMBLE_VS_SOLO: FormatId[] = ["scramblevssolo", "scramblevssolomatch"]
// Samme sett som app/handicap.py sin SIDE_IS_UNIT (+ chapman-aliaset) --
// avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller
// "per_player" i allowance_override.strategy.
export const SIDE_IS_UNIT_FORMAT: FormatId[] = ["foursome", "greensome", "scramble2", "scramble4", "chapman"]
// "Bruk course handicap-justering" er skjult for disse to (ingen reell
// nytteverdi), men holdes funksjonelt PÅ uansett -- se wizard-context.tsx.
export const ORDINARY_FORMATS: FormatId[] = ["slagspill", "stableford"]
export function isTwoSided(f: FormatId): boolean {
return TWO_SIDED.includes(f)
}
export function isScrambleVsSolo(f: FormatId): boolean {
return SCRAMBLE_VS_SOLO.includes(f)
}
/** Whether the Teams & order step (4) is shown for a given format. */
export function showsTeamStep(f: FormatId): boolean {
return isTwoSided(f) || f === "moneyball" || isScrambleVsSolo(f)
}
// Wizardens FormatId -> ekte round.play_format-verdi (RoundCreate).
export const FORMAT_TO_API: Record<FormatId, string> = {
slagspill: "stroke",
stableford: "stableford",
match: "match",
skins: "skins",
fourball: "fourball",
foursome: "foursome",
greensome: "greensome",
scramble2: "scramble_2",
scramble4: "scramble_4",
chapman: "chapman",
copenhagen: "copenhagen",
bbb: "bbb",
flag: "flag",
shamble: "shamble",
moneyball: "money_ball",
highlowhigh: "high_low_high",
scramblevssolo: "scramble_solo",
scramblevssolomatch: "scramble_solo_match",
}
export const API_TO_FORMAT: Record<string, FormatId> = Object.fromEntries(
Object.entries(FORMAT_TO_API).map(([k, v]) => [v, k as FormatId]),
) as Record<string, FormatId>
export function apiStatLevel(s: StatLevelLike): "strokes_only" | "strokes_and_putts" | "full" {
return s === "score" ? "strokes_only" : s === "score_putts" ? "strokes_and_putts" : "full"
}
type StatLevelLike = "score" | "score_putts" | "full"

View file

@ -0,0 +1,110 @@
import type { ApiCourseHole, ApiCourseTee } from "@/components/course-template-editor"
export type ApiGender = "m" | "f"
export type Gender = "mann" | "kvinne" | "annet"
export type StatLevel = "score" | "score_putts" | "full"
/** Banens utslag -- ekte API-et eksponerer KUN hvilke(t) kjønn utslaget har
* rating for (`genders`), ikke selve CR/Slope-tallene til klienten (de
* brukes kun server-side til handicap-beregning). */
export interface Tee {
id: string
name: string
genders: ApiGender[]
}
export interface Course {
id: string
name: string
tees: Tee[]
/** Fullt hull-/utslagoppsett for "bruk som mal"-broen -- fraværende/tom
* når kilden (teeoff) ikke har komplette nok data til å brukes som mal. */
templateHoles?: ApiCourseHole[]
templateTees?: ApiCourseTee[]
}
export interface Facility {
slug: string
name: string
city: string | null
county: string | null
}
/** Hvilken bane-kilde+id som faktisk skal sendes til /rounds. */
export type CourseMeta =
| { source: "teeoff"; facilitySlug: string; teeoffCourseId: number }
| { source: "custom"; personalCourseId: string }
/** En bane funnet i et offisielt anlegg (fra /rounds/official-search/{slug}). */
export interface OfficialCourseOption {
teeoffCourseId: number
course: Course
}
/** En konto som kan søkes opp og lenkes inn i runden. */
export interface Account {
id: string
firstName: string
lastName: string
handicap: number | null
}
/** En gjest brukeren har registrert før (scoped til denne brukeren). */
export interface KnownGuest {
firstName: string
lastName: string
gender: ApiGender | null
hcp: number | null
}
export type PlayerKind = "owner" | "account" | "guest"
export interface Player {
key: string // stabil lokal nøkkel
kind: PlayerKind
accountId?: string
firstName: string
lastName: string
gender?: Gender // kjent for eier/gjest; ukjent for nettopp lenkede kontoer
isGuest: boolean
email?: string
hcp?: number | null // gjest/eier numerisk; null når ukjent
hcpFromProfile?: boolean
teeId?: string
statLevel: StatLevel
/** "A" | "B" for to-sidede formater, "team" | "solo" for scramble-mot-solo. */
sideId?: string
moneyBallSlot?: number
}
export type FormatId =
| "slagspill"
| "stableford"
| "match"
| "skins"
| "fourball"
| "foursome"
| "greensome"
| "scramble2"
| "scramble4"
| "chapman"
| "copenhagen"
| "bbb"
| "flag"
| "shamble"
| "moneyball"
| "highlowhigh"
| "scramblevssolo"
| "scramblevssolomatch"
export type Visibility = "private" | "friends" | "public"
export type Sub1 =
| "source"
| "official-search"
| "official-courses"
| "own-search"
| "own-create"
| "template-source"
| "fields"

File diff suppressed because one or more lines are too long