Compare commits
No commits in common. "2d1d0ba1d098c06270d944b24159ea795f7e53d0" and "12f10d92a3836bb1be69338c50384c4b23d4bc83" have entirely different histories.
2d1d0ba1d0
...
12f10d92a3
43 changed files with 4361 additions and 5560 deletions
|
|
@ -3207,37 +3207,6 @@ søk-UI i `round-detail.tsx`, "Gjest" omdøpt til "Medspiller" gjennomgående,
|
||||||
samme viewer-relative "Deg"-fiks portert til `round-stats.tsx`/
|
samme viewer-relative "Deg"-fiks portert til `round-stats.tsx`/
|
||||||
`round-scorecard.tsx`, som hadde samme latente bug).
|
`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
|
## ADR-037: Individuelle turneringer, flerrunde-turneringer — grunnstruktur
|
||||||
|
|
|
||||||
432
CHANGELOG.md
432
CHANGELOG.md
|
|
@ -8683,10 +8683,9 @@ Neste steg:
|
||||||
eksplisitt bekreftet at ekte `teecup_db` sin `datacl` var uendret
|
eksplisitt bekreftet at ekte `teecup_db` sin `datacl` var uendret
|
||||||
etterpå).
|
etterpå).
|
||||||
|
|
||||||
**Rullet ut 2026-08-08** sammen med punkt 40 og 41 (`docker compose
|
**Ikke rullet ut ennå** -- venter på samlet bekreftelse sammen med
|
||||||
build teecup_frontend` + `up -d` -- ingen migrasjon, kun ny
|
resten av det som gjenstår i denne økten (slagmåling-frontend,
|
||||||
frontend-image). Bekreftet ingen konsollfeil på
|
dashbord-V0-beslutning).
|
||||||
`https://teecup.golf/` etter omstart.
|
|
||||||
|
|
||||||
39. **Dashbordet reskinnet til "clubhouse"-paletten — 2026-08-07, bruker
|
39. **Dashbordet reskinnet til "clubhouse"-paletten — 2026-08-07, bruker
|
||||||
bekreftet eksplisitt "full overtagelse, også bakgrunnen".** Egen,
|
bekreftet eksplisitt "full overtagelse, også bakgrunnen".** Egen,
|
||||||
|
|
@ -8746,424 +8745,7 @@ Neste steg:
|
||||||
utvidet korrekt og det eksisterende skjemaet (uendret) fungerte som før.
|
utvidet korrekt og det eksisterende skjemaet (uendret) fungerte som før.
|
||||||
Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig.
|
Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig.
|
||||||
|
|
||||||
**Rullet ut 2026-08-08** sammen med BottomNav (punkt 40) og
|
**Ikke rullet ut ennå** -- venter på BottomTabBar-V0-eksporten (så
|
||||||
ny-runde-veiviseren (punkt 41), som planlagt -- se punkt 41 for
|
hele dashbordet, inkludert navigasjonen, kan rulles ut samlet i én
|
||||||
utrullingsdetaljer.
|
omgang i stedet for i to synlig usammenhengende steg) og på samlet
|
||||||
|
bekreftelse sammen med resten av det som gjenstår denne økten.
|
||||||
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` på `<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).
|
|
||||||
|
|
|
||||||
|
|
@ -511,7 +511,11 @@ async def get_feed(
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM friend_categorization fc
|
SELECT 1 FROM friend_categorization fc
|
||||||
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
||||||
AND fc.category IN (
|
)
|
||||||
|
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 (
|
||||||
SELECT category FROM round_visible_category WHERE round_id = r.id
|
SELECT category FROM round_visible_category WHERE round_id = r.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2031,15 +2031,13 @@ async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bo
|
||||||
# kategorisering av viewer (ikke omvendt -- det er eieren som
|
# kategorisering av viewer (ikke omvendt -- det er eieren som
|
||||||
# begrenser, basert på egen gruppering, ADR-036 Beslutning B).
|
# begrenser, basert på egen gruppering, ADR-036 Beslutning B).
|
||||||
#
|
#
|
||||||
# Regel: MINST ÉN av vennens kategorier må være i rundens synlige sett
|
# Presisert av bruker 2026-07-29: "ikke vise" overstyrer "vise" -- er
|
||||||
# (ADR-036 Beslutning E, 2026-08-08). En strengere "ALLE kategorier må
|
# vennen satt i FLERE kategorier, holder det IKKE at én av dem er
|
||||||
# være synlige"-variant gjaldt 2026-07-29--2026-08-08, men ga et reelt
|
# synlig for runden; ALLE vennens kategorier må være i rundens synlige
|
||||||
# forvirrende utfall i praksis (bruker delte en runde med "Make"
|
# sett. En venn med INGEN kategorier i det hele tatt vises ALDRI (samme
|
||||||
# huket av, men vedkommendes ektefelle -- ALSO tagget "Storfamilie" --
|
# runde: kategorisering er nå obligatorisk fra vennskapet inngås, se
|
||||||
# fikk ikke se runden fordi den kategorien ikke var huket av). Reversert
|
# friends.py -- en ukategorisert venn er derfor alltid en igjenværende
|
||||||
# til den opprinnelige ADR-036 Beslutning B-regelen (som alltid sa
|
# unntakstilstand fra FØR den regelen, ikke en normal, forventet sti).
|
||||||
# "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:
|
if viewer_user_id is None:
|
||||||
return False
|
return False
|
||||||
owner_id = row["owner_user_id"]
|
owner_id = row["owner_user_id"]
|
||||||
|
|
@ -2062,7 +2060,11 @@ async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bo
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM friend_categorization
|
SELECT 1 FROM friend_categorization
|
||||||
WHERE owner_user_id = $1 AND friend_user_id = $2
|
WHERE owner_user_id = $1 AND friend_user_id = $2
|
||||||
AND category IN (SELECT category FROM round_visible_category WHERE round_id = $3)
|
)
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
owner_id,
|
owner_id,
|
||||||
|
|
@ -2102,14 +2104,19 @@ async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) ->
|
||||||
return []
|
return []
|
||||||
if row["visibility_mode"] == "public":
|
if row["visibility_mode"] == "public":
|
||||||
return friend_ids
|
return friend_ids
|
||||||
# Samme "minst én kategori er nok"-regel som _can_view_round (ADR-036
|
# Samme "alle kategorier må være synlige, ikke bare én"-regel som
|
||||||
# Beslutning E).
|
# _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.
|
||||||
matched = await conn.fetch(
|
matched = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT fc.friend_user_id::text AS friend_id
|
SELECT fc.friend_user_id::text AS friend_id
|
||||||
FROM friend_categorization fc
|
FROM friend_categorization fc
|
||||||
WHERE fc.owner_user_id = $1 AND fc.friend_user_id = ANY($2::uuid[])
|
WHERE fc.owner_user_id = $1 AND fc.friend_user_id = ANY($2::uuid[])
|
||||||
AND fc.category IN (SELECT category FROM round_visible_category WHERE round_id = $3)
|
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
|
||||||
""",
|
""",
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
friend_ids,
|
friend_ids,
|
||||||
|
|
@ -2176,7 +2183,11 @@ async def list_friends_on_course(user: CurrentUser = Depends(get_current_user))
|
||||||
AND EXISTS(
|
AND EXISTS(
|
||||||
SELECT 1 FROM friend_categorization fc
|
SELECT 1 FROM friend_categorization fc
|
||||||
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
||||||
AND fc.category IN (
|
)
|
||||||
|
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 (
|
||||||
SELECT category FROM round_visible_category WHERE round_id = r.id
|
SELECT category FROM round_visible_category WHERE round_id = r.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -237,34 +237,3 @@
|
||||||
--color-clubhouse-border: var(--clubhouse-border);
|
--color-clubhouse-border: var(--clubhouse-border);
|
||||||
--color-clubhouse-field: var(--clubhouse-field);
|
--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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
||||||
import { TeeCupAuth } from "@/components/teecup/teecup-auth"
|
import { TeeCupAuth } from "@/components/teecup/teecup-auth"
|
||||||
|
|
||||||
|
|
@ -9,48 +7,7 @@ export const metadata: Metadata = {
|
||||||
description: "Logg inn på TeeCup for live-scoring og golfturneringer med vennegjengen, klubben eller bedriften.",
|
description: "Logg inn på TeeCup for live-scoring og golfturneringer med vennegjengen, klubben eller bedriften.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
|
export default function LoggInnPage() {
|
||||||
// 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 (
|
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">
|
<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. */}
|
{/* Quiet turf-horizon backdrop — the one bit of atmosphere, kept subtle. */}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { NewRoundWizard } from "@/components/ny-runde/wizard-shell"
|
import { NewRound } from "@/components/new-round"
|
||||||
|
|
||||||
export default function NewRoundPage() {
|
export default function NewRoundPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-dvh items-center justify-center bg-[var(--nr-bg)]">
|
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-10 w-10 animate-spin rounded-full border-4 border-[var(--nr-accent-ring)] border-t-[var(--nr-accent)]"
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NewRoundWizard />
|
<NewRound />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { cookies } from "next/headers"
|
import { cookies } from "next/headers"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
import { LoginForm } from "@/components/login-form"
|
||||||
|
|
||||||
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
|
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
|
||||||
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
|
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
|
||||||
|
|
@ -37,15 +38,27 @@ export default async function Page() {
|
||||||
authenticated = false
|
authenticated = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
|
if (authenticated) {
|
||||||
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
|
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
|
||||||
// /account (som selv viser en fokusert fullførings-visning) i stedet
|
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
|
||||||
// for dashbordet -- se AccountSettings sin profile_complete-gren.
|
// /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
|
redirect(profileComplete ? "/dashboard" : "/account")
|
||||||
// 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
|
return (
|
||||||
// av TeeCupAuth (clubhouse-palett) på /logg-inn.
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
|
||||||
redirect(authenticated ? (profileComplete ? "/dashboard" : "/account") : "/logg-inn")
|
<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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
||||||
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
|
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
||||||
// org-scopede `player`-radene organisatorer administrerer.
|
// org-scopede `player`-radene organisatorer administrerer.
|
||||||
|
|
@ -339,7 +339,7 @@ export function AccountSettings() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="profile" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -416,7 +416,7 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
|
||||||
try {
|
try {
|
||||||
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
||||||
} finally {
|
} finally {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export function CourseRounds({ courseName }: { courseName: string }) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/rounds", { credentials: "include" })
|
const res = await fetch("/rounds", { credentials: "include" })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
|
|
|
||||||
|
|
@ -36,18 +36,11 @@
|
||||||
// visningsfonten (samme utelatelse som `/logg-inn` selv gjorde) -- Nunito
|
// visningsfonten (samme utelatelse som `/logg-inn` selv gjorde) -- Nunito
|
||||||
// beholdt for å unngå en ekstra font-avhengighet for kun to skjermer.
|
// beholdt for å unngå en ekstra font-avhengighet for kun to skjermer.
|
||||||
//
|
//
|
||||||
// Omfangsgrense (delt komponent IKKE re-stylet her, se CHANGELOG.md):
|
// Omfangsgrenser (delte komponenter IKKE re-stylet her, se CHANGELOG.md):
|
||||||
// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) beholder sin
|
// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) og `BottomTabBar`
|
||||||
// eksisterende styling inntil videre -- kun selve dashbord-skjermens
|
// (delt med de fem sidene som nettopp fikk den, venter på egen V0-prompt)
|
||||||
// EGNE, ikke-delte JSX er re-stylet i denne runden.
|
// 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 type React from "react"
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
@ -57,14 +50,18 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bell,
|
Bell,
|
||||||
Building2,
|
Building2,
|
||||||
|
CalendarDays,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Flag,
|
Flag,
|
||||||
|
Home,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
MoreHorizontal,
|
||||||
Trophy,
|
Trophy,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
User,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
X,
|
X,
|
||||||
|
|
@ -80,12 +77,36 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { InstallPrompt } from "@/components/install-prompt"
|
import { InstallPrompt } from "@/components/install-prompt"
|
||||||
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
|
||||||
import { RoundCard, type Round } from "@/components/round-card"
|
import { RoundCard, type Round } from "@/components/round-card"
|
||||||
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
||||||
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
import { cn } from "@/lib/utils"
|
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
|
// 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,
|
// stedet for én ensfarget tone. Hver par en lys bakgrunn med en mørk,
|
||||||
// kontrastrik tekst-tone for initialene.
|
// kontrastrik tekst-tone for initialene.
|
||||||
|
|
@ -307,7 +328,7 @@ export function Dashboard() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/auth/me", { credentials: "include" })
|
const res = await fetch("/auth/me", { credentials: "include" })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data: Me = await res.json()
|
const data: Me = await res.json()
|
||||||
|
|
@ -317,7 +338,7 @@ export function Dashboard() {
|
||||||
}
|
}
|
||||||
setMe(data)
|
setMe(data)
|
||||||
} catch {
|
} catch {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMe(false)
|
setLoadingMe(false)
|
||||||
}
|
}
|
||||||
|
|
@ -425,7 +446,7 @@ export function Dashboard() {
|
||||||
try {
|
try {
|
||||||
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
||||||
} finally {
|
} finally {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,7 +458,7 @@ export function Dashboard() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!me) return null // router.replace("/logg-inn") er allerede utløst
|
if (!me) return null // router.replace("/") er allerede utløst
|
||||||
|
|
||||||
const rows = rounds ?? []
|
const rows = rounds ?? []
|
||||||
const activeRounds = rows.filter((r) => !r.completed_at)
|
const activeRounds = rows.filter((r) => !r.completed_at)
|
||||||
|
|
@ -544,11 +565,57 @@ export function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="home" />
|
||||||
</div>
|
</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,
|
// V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications,
|
||||||
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som
|
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import Link from "next/link"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
/** Rå feed-oppføring fra backend. */
|
/** Rå feed-oppføring fra backend. */
|
||||||
export type ApiFeedEntry = {
|
export type ApiFeedEntry = {
|
||||||
|
|
@ -185,7 +185,7 @@ export function Feed() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="more" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
// --- Kategorier --------------------------------------------------------------
|
// --- Kategorier --------------------------------------------------------------
|
||||||
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
|
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
|
||||||
|
|
@ -130,7 +130,7 @@ export function Friends() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/friends", { credentials: "include" })
|
const res = await fetch("/friends", { credentials: "include" })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`friends: ${res.status}`)
|
if (!res.ok) throw new Error(`friends: ${res.status}`)
|
||||||
|
|
@ -186,7 +186,7 @@ export function Friends() {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="more" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
675
frontend/components/login-form.tsx
Normal file
675
frontend/components/login-form.tsx
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
"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 på 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? Gå 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 på 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 på 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 på en lapp? Skriv den inn her — du trenger ikke logge inn for å
|
||||||
|
se turneringen eller melde deg på.
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
// "Mer"-siden (2026-08-01) -- den femte fanen i den faste bunn-
|
// "Mer"-siden (2026-08-01) -- den femte fanen i den nye bunn-navigasjonen
|
||||||
// navigasjonen (`components/teecup/bottom-nav.tsx`, ADR/V0 2026-08-08)
|
// (dashboard.tsx sin BottomTabBar) trengte et reelt mål, ikke en lenke til
|
||||||
// trengte et reelt mål, ikke en lenke til en side som ikke fantes.
|
// en side som ikke fantes. Samler destinasjoner som allerede finnes andre
|
||||||
// Samler destinasjoner som allerede finnes andre steder i appen (header,
|
// steder i appen (header, dashbordets organisasjon-fot) på ett sted, i
|
||||||
// dashbordets organisasjon-fot) på ett sted, i stedet for å bygge noe
|
// stedet for å bygge noe nytt -- en ren navigasjons-samleside.
|
||||||
// nytt -- en ren navigasjons-samleside.
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
|
import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
const C = {
|
const C = {
|
||||||
card: "#ffffff",
|
card: "#ffffff",
|
||||||
|
|
@ -39,7 +38,7 @@ export function MoreMenu() {
|
||||||
try {
|
try {
|
||||||
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
||||||
} finally {
|
} finally {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +88,7 @@ export function MoreMenu() {
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="more" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3364
frontend/components/new-round.tsx
Normal file
3364
frontend/components/new-round.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
|
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
|
||||||
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
|
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
|
||||||
|
|
@ -74,7 +74,7 @@ export function Notifications() {
|
||||||
fetch("/notifications", { credentials: "include" })
|
fetch("/notifications", { credentials: "include" })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return res.ok ? res.json() : []
|
return res.ok ? res.json() : []
|
||||||
|
|
@ -159,7 +159,7 @@ export function Notifications() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="more" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
"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 (1–18) 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 1–18 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) må 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,455 +0,0 @@
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,603 +0,0 @@
|
||||||
"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 nå. 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 på 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
"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 (0–100 %).</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,576 +0,0 @@
|
||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
"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 (0–3).</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» på 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
"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 }
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
"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">
|
|
||||||
Gå 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, så 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 på
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { RoundCard, type Round } from "@/components/round-card"
|
import { RoundCard, type Round } from "@/components/round-card"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomTabBar } from "@/components/dashboard"
|
||||||
|
|
||||||
type ApiRoundParticipant = {
|
type ApiRoundParticipant = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -105,7 +105,7 @@ export function OwnRounds() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/rounds", { credentials: "include" })
|
const res = await fetch("/rounds", { credentials: "include" })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
|
|
@ -181,7 +181,7 @@ export function OwnRounds() {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomTabBar active="rounds" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
Minus,
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
Target,
|
Target,
|
||||||
|
|
@ -46,8 +45,6 @@ import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/sc
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { cn } from "@/lib/utils"
|
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"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
// --- Types -----------------------------------------------------------------
|
// --- Types -----------------------------------------------------------------
|
||||||
|
|
@ -1641,7 +1638,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
{wizardPlayer && hole && (
|
{wizardPlayer && hole && (
|
||||||
<ScoringWizard
|
<ScoringWizard
|
||||||
roundId={roundId}
|
|
||||||
players={players}
|
players={players}
|
||||||
player={wizardPlayer}
|
player={wizardPlayer}
|
||||||
hole={hole}
|
hole={hole}
|
||||||
|
|
@ -1698,7 +1694,6 @@ function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPutt
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoringWizard({
|
function ScoringWizard({
|
||||||
roundId,
|
|
||||||
players,
|
players,
|
||||||
player,
|
player,
|
||||||
hole,
|
hole,
|
||||||
|
|
@ -1712,7 +1707,6 @@ function ScoringWizard({
|
||||||
playFormat,
|
playFormat,
|
||||||
strokesReceived,
|
strokesReceived,
|
||||||
}: {
|
}: {
|
||||||
roundId: string
|
|
||||||
players: Player[]
|
players: Player[]
|
||||||
player: Player
|
player: Player
|
||||||
hole: Hole
|
hole: Hole
|
||||||
|
|
@ -1738,71 +1732,6 @@ function ScoringWizard({
|
||||||
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
||||||
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
|
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 steps = wizardStepsFor(player.statLevel)
|
||||||
const [stepIndex, setStepIndex] = useState(0)
|
const [stepIndex, setStepIndex] = useState(0)
|
||||||
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
|
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
|
||||||
|
|
@ -2007,17 +1936,6 @@ function ScoringWizard({
|
||||||
readOnly={readOnly}
|
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 && (
|
{hole.par !== 3 && (
|
||||||
<DirectionCross
|
<DirectionCross
|
||||||
label="Utslag"
|
label="Utslag"
|
||||||
|
|
@ -2080,16 +1998,6 @@ function ScoringWizard({
|
||||||
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
||||||
</Button>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{shotSheetOpen && (
|
|
||||||
<ShotMeasurementSheet
|
|
||||||
holeNumber={hole.holeNumber}
|
|
||||||
existingShotCount={shotCount}
|
|
||||||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
|
||||||
onSubmit={submitShot}
|
|
||||||
onCancel={() => setShotSheetOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -6347,6 +6255,63 @@ 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) ----------------------------------------
|
// --- Choice row (segmented buttons) ----------------------------------------
|
||||||
|
|
||||||
function ChoiceRow({
|
function ChoiceRow({
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ export function RoundsStatsSummary() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/rounds/stats/summary?window=${window}`, { credentials: "include" })
|
const res = await fetch(`/rounds/stats/summary?window=${window}`, { credentials: "include" })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.replace("/logg-inn")
|
router.replace("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`stats: ${res.status}`)
|
if (!res.ok) throw new Error(`stats: ${res.status}`)
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"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 på 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
|
|
||||||
|
|
@ -1,446 +0,0 @@
|
||||||
"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 nå</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 på kart</span>
|
|
||||||
<span className="text-sm text-muted-foreground">Trykk på 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 på, 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
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useId, useState } from "react"
|
import { useEffect, useId, useRef, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Icons — inline SVG (currentColor), so every action can pair an */
|
/* Icons — inline SVG (currentColor), so every action can pair an */
|
||||||
|
|
@ -125,23 +123,40 @@ const backBtn = cn(
|
||||||
focusRing,
|
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@]+$/
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
const RESEND_SECONDS = 30
|
|
||||||
|
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.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Mode = "email" | "sent" | "password" | "code"
|
type Mode = "email" | "sent" | "password" | "code"
|
||||||
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
|
const RESEND_SECONDS = 30
|
||||||
|
|
||||||
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 */
|
/* Small building blocks */
|
||||||
|
|
@ -171,10 +186,7 @@ function FormAlert({ children }: { children: React.ReactNode }) {
|
||||||
/* Main component */
|
/* Main component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
export function TeeCupAuth() {
|
export function TeeCupAuth() {
|
||||||
const router = useRouter()
|
|
||||||
const [mode, setMode] = useState<Mode>("email")
|
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)
|
// shared field state (email persists across email <-> password)
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
|
|
@ -188,50 +200,25 @@ export function TeeCupAuth() {
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
function handleLoginResult(result: LoginResult) {
|
/* ---- State 1: magic link ---- */
|
||||||
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) {
|
async function handleSendLink(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setEmailTouched(true)
|
setEmailTouched(true)
|
||||||
if (!EMAIL_RE.test(email) || loading) return
|
if (!EMAIL_RE.test(email)) return
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
const res = await apiSendMagicLink(email)
|
||||||
// Alltid samme suksess-respons uansett om e-posten finnes (anti-
|
setLoading(false)
|
||||||
// enumerering, se ADR-009) -- kun nettverks-/serverfeil havner i catch.
|
if (res.ok) setMode("sent")
|
||||||
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 ---- */
|
/* ---- State 2: cooldown ---- */
|
||||||
const [cooldown, setCooldown] = useState(0)
|
const [cooldown, setCooldown] = useState(0)
|
||||||
const [resentNote, setResentNote] = useState(false)
|
const [resentNote, setResentNote] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "sent") return
|
||||||
|
setCooldown(RESEND_SECONDS)
|
||||||
|
}, [mode])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cooldown <= 0) return
|
if (cooldown <= 0) return
|
||||||
const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
|
const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
|
||||||
|
|
@ -241,80 +228,55 @@ export function TeeCupAuth() {
|
||||||
async function handleResend() {
|
async function handleResend() {
|
||||||
if (cooldown > 0 || loading) return
|
if (cooldown > 0 || loading) return
|
||||||
setResentNote(false)
|
setResentNote(false)
|
||||||
setFormError(null)
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
await apiSendMagicLink(email)
|
||||||
const res = await fetch("/auth/request-link", {
|
setLoading(false)
|
||||||
method: "POST",
|
setCooldown(RESEND_SECONDS)
|
||||||
headers: { "Content-Type": "application/json" },
|
setResentNote(true)
|
||||||
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, ekte /auth/login-password ---- */
|
/* ---- State 3: password ---- */
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const pwIds = useId()
|
const pwIds = useId()
|
||||||
|
|
||||||
async function handlePasswordLogin(e: React.FormEvent) {
|
async function handlePasswordLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!email.trim() || !password || loading) return
|
if (!email || !password) return
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
const res = await apiPasswordLogin(email, password)
|
||||||
const res = await fetch("/auth/login-password", {
|
setLoading(false)
|
||||||
method: "POST",
|
if (res.ok) {
|
||||||
headers: { "Content-Type": "application/json" },
|
setSuccess("Logget inn! Neste steg er en engangskode på e-post (2FA).")
|
||||||
credentials: "include",
|
} else {
|
||||||
body: JSON.stringify({ email: email.trim(), password }),
|
setFormError(res.error)
|
||||||
})
|
|
||||||
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, ekte /public/tournaments/by-code ---- */
|
/* ---- State 4: join by code ---- */
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
async function handleJoin(e: React.FormEvent) {
|
async function handleJoin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = code.trim()
|
if (!code.trim()) return
|
||||||
if (!trimmed || loading) return
|
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
const res = await apiJoinByCode(code)
|
||||||
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
|
setLoading(false)
|
||||||
if (!res.ok) {
|
if (res.ok) {
|
||||||
const body = await res.json().catch(() => null)
|
setSuccess(`Fant turneringen «${res.name}». Sender deg dit …`)
|
||||||
throw new Error(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
|
} else {
|
||||||
}
|
setFormError(res.error)
|
||||||
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.
|
// Reset transient per-mode state when switching modes.
|
||||||
function switchMode(next: Mode, opts?: { clearEmail?: boolean }) {
|
function switchMode(next: Mode, opts?: { clearEmail?: boolean }) {
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
|
setSuccess(null)
|
||||||
setPassword("")
|
setPassword("")
|
||||||
setShowPassword(false)
|
setShowPassword(false)
|
||||||
setResentNote(false)
|
setResentNote(false)
|
||||||
|
|
@ -326,19 +288,22 @@ export function TeeCupAuth() {
|
||||||
setMode(next)
|
setMode(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 2FA continuation (shared with password login) --- */
|
/* ---------------- success view (shared continuation) ------------- */
|
||||||
if (postAuth === "2fa-verify") {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<TwoFactorVerifyForm
|
<div className="text-center">
|
||||||
method={twoFactorMethod}
|
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-tee/20 text-tee-strong">
|
||||||
onSuccess={handleTwoFactorSuccess}
|
<CheckIcon className="size-8" />
|
||||||
onBack={() => setPostAuth(null)}
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (postAuth === "2fa-setup") {
|
|
||||||
return <TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -373,8 +338,6 @@ export function TeeCupAuth() {
|
||||||
{emailInvalid && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
|
{emailInvalid && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
|
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
|
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
|
||||||
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
|
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
|
||||||
{loading ? "Sender …" : "Send innloggingslenke"}
|
{loading ? "Sender …" : "Send innloggingslenke"}
|
||||||
|
|
@ -412,9 +375,7 @@ export function TeeCupAuth() {
|
||||||
Åpne lenken på denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
|
Åpne lenken på denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
|
{resentNote && (
|
||||||
|
|
||||||
{resentNote && !formError && (
|
|
||||||
<p role="status" className="mt-4 flex items-center gap-2 text-[0.9375rem] font-medium text-tee-strong">
|
<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" />
|
<CheckIcon className="size-5 shrink-0" />
|
||||||
Lenken er sendt på nytt.
|
Lenken er sendt på nytt.
|
||||||
|
|
@ -493,7 +454,7 @@ export function TeeCupAuth() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={loading || !email.trim() || !password} className={cn(primaryBtn, "mt-6")}>
|
<button type="submit" disabled={loading || !email || !password} className={cn(primaryBtn, "mt-6")}>
|
||||||
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
|
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
|
||||||
{loading ? "Logger inn …" : "Logg inn"}
|
{loading ? "Logger inn …" : "Logg inn"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -533,8 +494,12 @@ export function TeeCupAuth() {
|
||||||
placeholder="F.EKS. RYDER-25"
|
placeholder="F.EKS. RYDER-25"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
aria-describedby="invite-hint"
|
||||||
className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")}
|
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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>
|
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
// Delt mellom teecup-auth.tsx (passord-modus, /logg-inn) og verify-form.tsx
|
// Delt mellom login-form.tsx (passord-modus) og verify-form.tsx (magic-link)
|
||||||
// (magic-link) -- begge kan returnere status "2fa_required"/
|
// -- begge kan returnere status "2fa_required"/"2fa_setup_required" fra
|
||||||
// "2fa_setup_required" fra backend (ADR-021 Beslutning E), og trenger
|
// backend (ADR-021 Beslutning E), og trenger identisk oppfølging.
|
||||||
// identisk oppfølging.
|
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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 }
|
|
||||||
|
|
@ -171,7 +171,7 @@ export function VerifyForm() {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/logg-inn"
|
href="/"
|
||||||
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Be om en ny lenke
|
Be om en ny lenke
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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
Loading…
Reference in a new issue