Compare commits
4 commits
242c7a2bfc
...
0e89546dc7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e89546dc7 | |||
| fa6a1fa78d | |||
| d053a71428 | |||
| 76fe6f6c2c |
14 changed files with 2500 additions and 476 deletions
257
CHANGELOG.md
257
CHANGELOG.md
|
|
@ -6520,6 +6520,170 @@ Ferdig og verifisert:
|
||||||
spiller-rad-tilfellet (`ScorecardGrid`), og det fantes ingen
|
spiller-rad-tilfellet (`ScorecardGrid`), og det fantes ingen
|
||||||
tilsvarende referanse å bygge en "side-kort"-variant mot. Fortsatt
|
tilsvarende referanse å bygge en "side-kort"-variant mot. Fortsatt
|
||||||
det brede gridet for de formatene, uendret.
|
det brede gridet for de formatene, uendret.
|
||||||
|
- **Fem separate brukerpunkter fra samme økt, ALLE BYGGET, SCRATCH-
|
||||||
|
VERIFISERT OG LIVE (2026-08-03), samme dag som score-kortrunden
|
||||||
|
over:**
|
||||||
|
1. **Ekte bug funnet via opplastet skjermopptak, rettet:** brukeren
|
||||||
|
rapporterte at "snarveien" (Administrer-dialogens "Spillere og
|
||||||
|
runde"-lenke, `?tab=manage`) ikke virket fra Score-fanen, men
|
||||||
|
virket fra Scorekort/Leaderboard. Video analysert bilde for bilde
|
||||||
|
(ffmpeg) -- bekreftet presist: URL-en endret seg riktig til
|
||||||
|
`?tab=manage`, men selve fanen ble stående på "Score". Rotårsak:
|
||||||
|
`useState(initialTab)` i round-detail.tsx leste kun
|
||||||
|
`useSearchParams()` ved FØRSTE mount -- en navigasjon FRA Score-
|
||||||
|
siden til seg selv med kun en ny query-parameter er ingen ekte
|
||||||
|
remount i Next.js App Router, så verdien ble aldri regnet på nytt.
|
||||||
|
Fra Scorekort/Leaderboard virket det fordi det ER en remount
|
||||||
|
(annen rute). Fikset med en `useEffect` som reagerer på selve
|
||||||
|
parameter-ENDRINGEN (ensrettet -- hopper TIL "manage", tvinger
|
||||||
|
aldri tilbake til "score"). Reprodusert i scratch FØR fiksen
|
||||||
|
(bekreftet feilen), verifisert etterpå fra alle tre
|
||||||
|
inngangspunkter (Score/Scorekort/Leaderboard) + at lokal
|
||||||
|
"Score"-pille fortsatt virker uendret.
|
||||||
|
2. **`EditRoundPanel` sin spilleform-bryter utvidet fra 3 til 16
|
||||||
|
format:** var blitt hengende igjen på stroke/match/stableford fra
|
||||||
|
FØR de åtte nye formatene (ADR-039) ble bygget -- en ren
|
||||||
|
forglemmelse. Rettet BÅDE i backend (`RoundUpdate.play_format`
|
||||||
|
i rounds.py hadde samme gamle 3-verdis-Literal) OG frontend (ny
|
||||||
|
`EDITABLE_PLAY_FORMATS`-liste, samme etiketter som new-round.tsx).
|
||||||
|
Verifisert live i scratch: byttet en pågående runde fra Slagspill
|
||||||
|
til Fourball midt i runden, `SidesPanel` sin eksisterende "opprett
|
||||||
|
sider først"-melding tok over korrekt uten noen krasj.
|
||||||
|
3. **`completed_at`-feltet for å overstyre fullført-tidspunkt manuelt
|
||||||
|
bekreftet FORTSATT TIL STEDE, uendret siden 2026-07-24** -- ren
|
||||||
|
kodeverifisering, ingen endring nødvendig. Feltet vises (med
|
||||||
|
hensikt) kun ETTER at "Fullfør runde" er trykket -- forvirringen
|
||||||
|
var trolig et resultat av bug (1) over (kom seg aldri til
|
||||||
|
"Spillere og runde" for å finne knappen).
|
||||||
|
4. **`round-scorecard.tsx` sitt individuelle scorekort: kolonnetekst-
|
||||||
|
overflow rettet + 8 nye detaljrader lagt til** (Fairway/Putt/
|
||||||
|
GIR/Innspill/Chip/Bunkerslag/Straffeslag/Anywayslag), alle med
|
||||||
|
forkortede radetiketter + kompakte lucide-ikoner (bøyde piler for
|
||||||
|
Fairway, rette piler+blink for Innspill, sjekkmerke for GIR) --
|
||||||
|
samme golfscore-språk som resten av appen. Data var allerede
|
||||||
|
tilgjengelig i `RoundHoleOut` (bare ikke lest av denne siden før).
|
||||||
|
Vises kun når minst ett hull faktisk har dataen (samme "vis kun
|
||||||
|
det som finnes"-prinsipp). GIR-formelen gjenbruker EKSAKT samme
|
||||||
|
`score - putts <= par - 2`-regel som round-stats.tsx/round-
|
||||||
|
detail.tsx allerede bruker.
|
||||||
|
5. **Rundeleaderboard (V0-eksport "zip 28") -- IKKE V0 sin skyld at
|
||||||
|
forrige versjon "så rart sammenskrudd ut", presisert til bruker:
|
||||||
|
forrige leaderboard var HÅNDKODET (ikke V0), bygget 2026-07-26 da
|
||||||
|
brukeren gikk tom for V0-credits. Zip 28 dekket alle hullene i
|
||||||
|
V0-prompten (skrevet samme økt) presist -- portet MANUELT mot ekte
|
||||||
|
data, IKKE en full filerstatning: gjenbrukte eksisterende
|
||||||
|
`rankEntries`/`ValueMark`/`RankBadge`/`ModeToggle`/`HoleStrip`/
|
||||||
|
matchstatus-tug-of-war-baren (allerede nær identisk med V0s egen
|
||||||
|
visuelle idé, lavere risiko å beholde enn å skrive om). Kun TRE
|
||||||
|
genuint manglende/feil biter bygget:
|
||||||
|
- **High-low-high** falt tidligere inn i den generiske to-sidede
|
||||||
|
"X UP"-matchstatusen (bekreftet FEIL for dette formatet -- HLH
|
||||||
|
er 0-2 poeng-per-hull, ikke hull-ledelse). Ny `HighLowSection`:
|
||||||
|
lav/høy-duell-rollen per spiller per hull utledes fra
|
||||||
|
`entries[].points` (allerede MIN/MAKS av lagkameratenes
|
||||||
|
Stableford-poeng den hullet, samme regel som
|
||||||
|
`handicap_engine.py` sin `high_low_high_points_for_hole` --
|
||||||
|
ingen backend-endring trengtes). Verifisert tall-for-tall i
|
||||||
|
scratch: per-hull lav/høy-dueller summerte EKSAKT til
|
||||||
|
`hlh_points_a`/`hlh_points_b` sine totaler.
|
||||||
|
- **Bingo Bango Bongo/Københavner** sitt "Poeng"-tall var
|
||||||
|
`ApiLeaderboardEntry.total_points` -- ALLTID generisk Stableford
|
||||||
|
fra backend, uansett format (bekreftet reell bug: disse to
|
||||||
|
formatene har sine EGNE poengsystem, aldri vist noe sted).
|
||||||
|
Erstattet med faktiske `bbb_points`/`copenhagen_points` fra
|
||||||
|
format-result FØR rangeringen bygges. Fanget og rettet EN egen
|
||||||
|
bug UNDER scratch-verifiseringen: en deltaker med 0 ekte
|
||||||
|
BBB/Københavner-poeng manglet fra dict-en (ikke `0`, fraværende
|
||||||
|
nøkkel) -- første versjon falt da feilaktig tilbake til generisk
|
||||||
|
Stableford i stedet for `0`. Rettet, verifisert: Københavner-
|
||||||
|
poeng summerte EKSAKT til 6 per hull (regelen), BBB viste riktig
|
||||||
|
`0` for en spiller uten noen bingo/bango/bongo-vinst.
|
||||||
|
- **Shamble/Money Ball** sitt offisielle LAGRESULTAT (hele runden
|
||||||
|
= ett lag) lå tidligere IKKE synlig noe sted -- kun spillernes
|
||||||
|
råtall. Ny `TeamResultSection`: lagets til-par (fra
|
||||||
|
`shamble_team_score`/`money_ball_team_score` + hullenes par-sum,
|
||||||
|
begge allerede i format-result), pluss en NY flight-gruppe-
|
||||||
|
sammenligning (henter søsken-rundenes EGNE format-result via et
|
||||||
|
nytt `useFlightTeamStandings`-kall) mot andre lag i samme utgang
|
||||||
|
-- tidligere fantes INGEN slik sammenligning i det hele tatt.
|
||||||
|
Verifisert tall-for-tall i scratch (Shamble beste-2-av-3, Money
|
||||||
|
Ball sin rotasjon) mot to separate flight-koblede runder.
|
||||||
|
`RoundLeaderboardMini` (forhåndsvisningen i "Spillere og runde")
|
||||||
|
oppdatert til å skjule seg for Shamble/Money Ball også (samme
|
||||||
|
"ikke vis noe fremfor å vise en misvisende rangering"-prinsipp den
|
||||||
|
allerede fulgte for to-sidede format).
|
||||||
|
Regresjonstestet: vanlig Slagspill og Fourball uendret (identisk
|
||||||
|
visning/matchstatus som før refaktoreringen som lot
|
||||||
|
`MatchStatusSection` motta `result` som prop i stedet for å hente
|
||||||
|
selv).
|
||||||
|
**Rullet ut sammen med de fire punktene over, 2026-08-03**: BÅDE
|
||||||
|
`teecup_api` (RoundUpdate.play_format-utvidelsen) OG
|
||||||
|
`teecup_frontend` bygget og restartet, begge boot-et rent,
|
||||||
|
bekreftet via kompilert bundle-grep + en faktisk backend-
|
||||||
|
spørring mot den nye Literal-en. Zip-en slettet etter merge.
|
||||||
|
- **Rundeleaderboard (zip 28) — punkt 5 over ERSTATTET, full omskriving
|
||||||
|
(2026-08-03, samme dag):** brukeren testet en vanlig Slagspill-runde
|
||||||
|
(den klart vanligste stien) etter forrige punkts utrulling og så
|
||||||
|
NULL visuell endring, sendte skjermdump og spurte hva som hadde
|
||||||
|
skjedd. Årsak: forrige punkt gjenbrukte bevisst de gamle håndkodede
|
||||||
|
`RankBadge`/`ValueMark`/`ModeToggle`/`HoleStrip`/matchstatus-baren for
|
||||||
|
individuell/to-sidet visning fordi jeg vurderte dem som "strukturelt
|
||||||
|
nære nok" V0s tegning — akkurat den stien brukeren testet var derfor
|
||||||
|
uendret. Brukerens eksplisitte, ordrette svar (bevart for ettertiden):
|
||||||
|
*"Ja, jeg vil ABSOLUTT at du implementerer V0 sin tolkning av hvordan
|
||||||
|
Leaderboardet skal se ut. Jeg vil ikke at du skal gjøre personlige
|
||||||
|
endringer eller holde tilbake på noe i det hele tatt når det gjelder
|
||||||
|
dette."* Lagret som stående feedback-memory (`v0-full-fidelity`) —
|
||||||
|
gjelder ALL fremtidig V0-integrering i dette prosjektet, ikke bare
|
||||||
|
denne filen.
|
||||||
|
Fulgte opp med en fullstendig omskriving av `round-leaderboard.tsx`,
|
||||||
|
denne gangen med HVER V0-komponent portet manuelt mot ekte data (ikke
|
||||||
|
gjenbrukt): `Badge`/`MetricPill`/`ScoreMark`+`classify`/
|
||||||
|
`IndividualBoard`/`MetricToggle`/`HoleByHole`/`Legend` (individuelt),
|
||||||
|
`TwoSidedBoard`/`SideName`/`HoleWinners` (to-sidet), `HighLowBoard`/
|
||||||
|
`DuelRow` (High-low-high), `TeamFlightBoard` (Shamble/Money Ball) — de
|
||||||
|
gamle håndkodede visningskomponentene slettet i sin helhet, ikke
|
||||||
|
beholdt ved siden av.
|
||||||
|
Fem reelle bugs funnet og rettet UNDER omskrivingen (ikke antatt
|
||||||
|
riktig fra en ren typesjekk alene — funnet ved skjermdump-inspeksjon i
|
||||||
|
scratch):
|
||||||
|
1. `courseHcp` var meningsløs plassholder-matematikk
|
||||||
|
(`strokes_received * 18` uansett hva slagindeksen faktisk var) —
|
||||||
|
V0s mockup antok klientside-utregning av spillehandicap, men
|
||||||
|
backend returnerer allerede korrekt per-hull `strokes_received`
|
||||||
|
direkte. Rettet: `IndividualPlayer` fikk et ekte
|
||||||
|
`strokesReceived`-array lest direkte derfra, netto/Stableford
|
||||||
|
regnet fra DET, ikke gjenutledet.
|
||||||
|
2. `hcpIndex` var hardkodet `null` alltid — feltet
|
||||||
|
(`handicap_index_snapshot`) fantes allerede i det hentede
|
||||||
|
`/rounds/{id}`-svaret, bare ikke lest av filens egen, snevrere
|
||||||
|
lokale type. Utvidet typen, lest verdien.
|
||||||
|
3. `TeamFlightBoard` sin råscore-pille hadde et komma-operator-uttrykk
|
||||||
|
som alltid evaluerte til hardkodet `0` uansett faktisk score.
|
||||||
|
Rettet med den allerede korrekte `grossToPar(m, holes)`.
|
||||||
|
4. CSS-trunkeringsbug i `SideName`: det ledende laget sin flagg-
|
||||||
|
ikonrad manglet `min-w-0` på det indre flex-elementet, så navnet
|
||||||
|
("Lag Bjørk") trunkerte til nesten ingenting ("L..") mens det ikke-
|
||||||
|
ledende laget viste seg fullt, selv i like brede `flex-1`-
|
||||||
|
containere. Rettet.
|
||||||
|
5. Rotårsaken til (4) var egentlig et feil datavalg: brukte
|
||||||
|
`match_status_text` (en backend-streng som ALLEREDE inkluderer
|
||||||
|
lagnavnet, designet for en annen UI-plassering) i V0s
|
||||||
|
overskrifts-slot, som var designet for en mye kortere tekst siden
|
||||||
|
lagnavn vises separat via `SideName`. Rettet ved å utlede V0s
|
||||||
|
egen korte frase ("2 opp"/"Dormie 2 opp"/"Ferdig 2&1"/"Vant 2
|
||||||
|
opp"/"Delt"/"Alt likt") direkte fra de rå numeriske feltene
|
||||||
|
(`match_lead`/`match_holes_played`/`match_holes_remaining`/
|
||||||
|
`match_is_dormie`/`match_is_closed`) — fortsatt serverens
|
||||||
|
autoritative tall, bare riktig tekstformat.
|
||||||
|
**Scratch-verifisert på alle åtte formater** (Slagspill, BBB,
|
||||||
|
Københavner, High-low-high, Fourball, Shamble, Money Ball, pluss
|
||||||
|
`RoundLeaderboardMini`-regresjon) med tall håndverifisert mot samme
|
||||||
|
testdata som resten av ADR-039-arbeidet — se punkt 5 over for selve
|
||||||
|
tallutregningen, uendret av denne omskrivingen. **Rullet ut live
|
||||||
|
2026-08-03** (kun `teecup_frontend` bygget/restartet — backend-
|
||||||
|
endringen fra punktene over var allerede live). Scratch-miljøet
|
||||||
|
(DB/rolle/MinIO/API-/frontend-containere) ryddet opp etter bruk.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
|
|
@ -6775,14 +6939,26 @@ Neste steg:
|
||||||
14. **Åtte nye turneringsformater (Chapman/Nassau/Københavner/Bingo Bango
|
14. **Åtte nye turneringsformater (Chapman/Nassau/Københavner/Bingo Bango
|
||||||
Bongo/Flaggturnering/Shamble/Money Ball/High-low-high) — BACKEND
|
Bongo/Flaggturnering/Shamble/Money Ball/High-low-high) — BACKEND
|
||||||
FERDIG, SCRATCH-VERIFISERT OG LIVE (2026-07-30, migrasjoner 041-050),
|
FERDIG, SCRATCH-VERIFISERT OG LIVE (2026-07-30, migrasjoner 041-050),
|
||||||
se status over.** Eneste gjenstående steg: FRONTEND for samtlige åtte
|
se status over.** ~~Eneste gjenstående steg: FRONTEND for samtlige
|
||||||
— nye spilleform-valg i opprett-runde/opprett-økt (frittstående
|
åtte~~ — **RETTET 2026-08-03: dette punktet stod utdatert.** Frontend
|
||||||
runder + org-lag + org-individuell, avhengig av format), nye
|
ble faktisk bygget i sesjonene mellom 2026-07-30 og 2026-08-02 (bl.a.
|
||||||
resultatvisninger (Nassau-vinduer, Københavner/BBB-poengtabeller,
|
synlig i "Det store grepet"-rettingene 2026-08-02 over, som allerede
|
||||||
Flag-nedtelling, Shamble/Money Ball-lagvisning, High-low-high-løpende
|
forutsetter `tournament-program.tsx` sin `CreateSessionCard` med alle
|
||||||
poeng). Backend er fullt funksjonelt og testet, men ubrukelig fra
|
10 to-sidede format), men punkt 14 her ble aldri oppdatert til å si
|
||||||
selve appen inntil dette bygges — samme lagdelings-rekkefølge
|
det. Bekreftet direkte mot koden 2026-08-03 (samme dag som
|
||||||
(motor→skjema→API→frontend) som ADR-037/038/039.
|
leaderboardets V0-omskriving, se status over): spilleform-valg finnes
|
||||||
|
i `new-round.tsx` (frittstående), `tournament-program.tsx`
|
||||||
|
(org-lag), `individual-tournament-detail.tsx` (org-individuell,
|
||||||
|
`scoring_method`); resultatvisninger finnes for Nassau (`NassauPanel`/
|
||||||
|
`session-scorecard.tsx`), Københavner/BBB (`individual-tournament-
|
||||||
|
detail.tsx`), Shamble/Money Ball (`round-leaderboard.tsx`s
|
||||||
|
`TeamFlightBoard`) og High-low-high (`round-leaderboard.tsx`s
|
||||||
|
`HighLowBoard`/`session-scorecard.tsx`). Migrasjoner bekreftet live
|
||||||
|
t.o.m. 052. Samme rettelse lagt inn i FEATURE_BACKLOG.md samme dag.
|
||||||
|
~~**Reell, fortsatt åpen gap (urelatert til de åtte formatene):**
|
||||||
|
organisator-vendt opplasting av hero-/sponsorbilder for
|
||||||
|
turnering-landingssiden (ADR-018) — backend/lagring finnes, ingen
|
||||||
|
dra-og-slipp-skjerm bygget ennå.~~ — **lukket samme dag, se punkt 16.**
|
||||||
15. **Ferdig, kun for historikk:** «Det store grepet» (ADR-040) — alle
|
15. **Ferdig, kun for historikk:** «Det store grepet» (ADR-040) — alle
|
||||||
5 steg (backend/allowance_override, lag-sortering, rundeoppsett-
|
5 steg (backend/allowance_override, lag-sortering, rundeoppsett-
|
||||||
veiviseren, delt Score/Scorekort/Leaderboard-fane-rad, integrering)
|
veiviseren, delt Score/Scorekort/Leaderboard-fane-rad, integrering)
|
||||||
|
|
@ -6791,3 +6967,68 @@ Neste steg:
|
||||||
scorekortet mer enn celle-fargelegging for å skille lagene tydelig
|
scorekortet mer enn celle-fargelegging for å skille lagene tydelig
|
||||||
nok (utover selve sorteringen, som allerede er fikset) — ikke stilt
|
nok (utover selve sorteringen, som allerede er fikset) — ikke stilt
|
||||||
til V0 ennå, egen liten vurdering om ønskelig. Se FEATURE_BACKLOG.md.
|
til V0 ennå, egen liten vurdering om ønskelig. Se FEATURE_BACKLOG.md.
|
||||||
|
16. **Turnering-presentasjon: organisator-vendt hero-bilde/sponsor-
|
||||||
|
opplasting + beskrivelse/synlighet/påmeldingsinnstillinger — BYGGET,
|
||||||
|
SCRATCH-VERIFISERT OG LIVE 2026-08-03.** Brukeren spurte om
|
||||||
|
presentasjonssider var på plass; svaret avdekket at backenden for
|
||||||
|
dette (`hero_image_key`/sponsor-CRUD/`visibility`/`description`/
|
||||||
|
påmeldingsfelt) hadde vært klar og LIVE siden ADR-018 (2026-07-18),
|
||||||
|
men INGEN organisator-skjerm noensinne satte disse feltene — alt var
|
||||||
|
100% API-only. Bekreftet omfanget eksplisitt med bruker (fullt
|
||||||
|
presentasjons-panel, ikke bare bilder) før bygging.
|
||||||
|
Ny `components/tournament-presentation.tsx`: `TournamentPresentation`
|
||||||
|
(full side, egen rute `/tournaments/[id]/presentation`, brukt av
|
||||||
|
lagturneringer) + `TournamentPresentationPanel` (samme innhold uten
|
||||||
|
header/nav, bygget inn som en fjerde in-page-fane i
|
||||||
|
`individual-tournament-detail.tsx` -- denne filen har sitt eget
|
||||||
|
fanesystem, ikke egne ruter som lagturneringene). Feltene er delt
|
||||||
|
mellom lag- og individuelle turneringer (samme `tournament`-tabell,
|
||||||
|
ikke `format_type`-spesifikke), så ÉN komponent dekker begge.
|
||||||
|
"Presentasjon"-fanen lagt til i alle fire eksisterende nav-rader
|
||||||
|
(`tournament-detail.tsx`/`tournament-program.tsx`/`tournament-
|
||||||
|
leaderboard.tsx`/`individual-tournament-detail.tsx`) -- disse fire er
|
||||||
|
fortsatt hver sin duplikat (samme mønster/samme kjente ulempe som
|
||||||
|
2026-08-02-bug-runden over), ikke en delt komponent denne runden.
|
||||||
|
La samtidig til `overflow-x-auto` på alle fire nav-rader (fjerde fane
|
||||||
|
presset bredden over det tidligere 3-faners layoutet tålte på smale
|
||||||
|
mobilskjermer).
|
||||||
|
**To små, bevisst minimale backend-tillegg** (ingen migrasjon --
|
||||||
|
rene response-modell-/endepunkt-tillegg):
|
||||||
|
- `Tournament.hero_image_url`/`Sponsor.logo_url`: nye beregnede felt
|
||||||
|
(samme mønster som `auth.py` sin `avatar_url`) -- organisator-
|
||||||
|
frontend skal aldri selv måtte kjenne MinIO-bucket/base-URL.
|
||||||
|
`_tournament_from_row()`/`_sponsor_from_row()`-hjelpere lagt til,
|
||||||
|
alle 8 tidligere `Tournament(**dict(row))`/`Sponsor(**dict(row))`-
|
||||||
|
kallsteder erstattet.
|
||||||
|
- Ny `DELETE /orgs/{id}/tournaments/{id}/hero-image` (samme mønster
|
||||||
|
som eksisterende `DELETE /auth/profile/avatar`) -- fantes ikke fra
|
||||||
|
før, kun opplasting.
|
||||||
|
**Én reell bug funnet og rettet UNDER scratch-verifisering** (ikke
|
||||||
|
antatt riktig fra ren typesjekk): sponsor-radens layout (logo-
|
||||||
|
miniatyr + navn + "Last opp logo"-knapp med full tekst + slett-ikon,
|
||||||
|
alle på én rad) klemte sponsornavnet til nesten ingenting på en ekte
|
||||||
|
390px mobil-viewport ("Tjø…" for "Tjøme Rørlegger AS"). Rettet ved å
|
||||||
|
la navn/lenke ligge på egen rad, handlingsknappene på en egen rad
|
||||||
|
under (`flex-col` på mobil, `sm:flex-row` fra small breakpoint) --
|
||||||
|
samme klasse defekt (for lite bredde satt av til tekst i en trang
|
||||||
|
flex-rad) som `SideName`-trunkeringsbugen i leaderboard-omskrivingen
|
||||||
|
tidligere denne uken, men et annet konkret sted.
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-/frontend-container, ekte
|
||||||
|
nettleser-innlogging inkl. reell 2FA-e-post-oppsett): full
|
||||||
|
PATCH-rundtur (beskrivelse/synlighet/godkjenning/venteliste-
|
||||||
|
policy lagret og lest tilbake korrekt fra ekte DB), hero-bilde
|
||||||
|
lastet opp og bekreftet i MinIO (`image/avif`, riktig nøkkel i DB),
|
||||||
|
hero-bilde fjernet (nøkkel nullstilt), sponsor lagt til, sponsor-
|
||||||
|
logo lastet opp og bekreftet i MinIO, sponsor slettet -- alt testet
|
||||||
|
på BÅDE en lagturnering og en individuell turnering, pluss bekreftet
|
||||||
|
at den offentlige siden (`/t/{id}`) viser organisatorens satte
|
||||||
|
beskrivelse. **Rullet ut live 2026-08-03**, bruker bekreftet
|
||||||
|
eksplisitt: `docker compose up -d --build teecup_api teecup_frontend`,
|
||||||
|
begge containere boot-et rent, `/health`/`/dashboard` → 200,
|
||||||
|
ny `/tournaments/[id]/presentation`-rute bekreftet i build-outputen.
|
||||||
|
**Ikke klikket gjennom i selve produksjonen** -- ingen ekte turnering
|
||||||
|
finnes ennå i `teecup_db` ("Ingen turneringer ennå" på dashbordet),
|
||||||
|
så dette er samme build som scratch-verifisert, ikke i tillegg
|
||||||
|
egenhendig bekreftet mot ekte produksjonsdata. Bekreft ved neste
|
||||||
|
faktiske turnering.
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,32 @@ lagdelings-mønster som ADR-038/039: motor/skjema/API FØR frontend).
|
||||||
mot ekte `teecup_db`** — venter på eksplisitt bekreftelse fra bruker før
|
mot ekte `teecup_db`** — venter på eksplisitt bekreftelse fra bruker før
|
||||||
noen migrasjon kjøres mot produksjon, per CLAUDE.md sin ufravikelige regel.
|
noen migrasjon kjøres mot produksjon, per CLAUDE.md sin ufravikelige regel.
|
||||||
|
|
||||||
|
**RETTELSE 2026-08-03 — avsnittet over er UTDATERT, stod aldri oppdatert
|
||||||
|
etter at frontend faktisk ble bygget:** verifisert direkte mot koden
|
||||||
|
(ikke bare mot denne filen) samme dag som rundeleaderboardets
|
||||||
|
V0-omskriving (se CHANGELOG.md 2026-08-03). Reell status nå:
|
||||||
|
- **Migrasjoner 041-052 ER rullet ut mot ekte `teecup_db`** — bekreftet
|
||||||
|
live (`round.play_format`-CHECK inkluderer alle 16 verdier,
|
||||||
|
`round_bbb_hole`/`lineup_order`/`tournament_round`-familien finnes alle
|
||||||
|
i produksjonsskjemaet).
|
||||||
|
- **Frontend for formatvalg ER bygget, i alle tre arkitektoniske hjem:**
|
||||||
|
`new-round.tsx` (frittstående runder, alle 8 + BBB-sveipbonus/Shamble-
|
||||||
|
beste-N-konfig), `tournament-program.tsx` sin `CreateSessionCard`
|
||||||
|
(org-lagturneringer, `SessionFormat` med alle 10 to-sidede format inkl.
|
||||||
|
chapman/shamble/money_ball/high_low_high), `individual-tournament-
|
||||||
|
detail.tsx` (org-individuell, `scoring_method`-bryter med
|
||||||
|
copenhagen/bingo_bango_bongo/flag).
|
||||||
|
- **Nye resultatvisninger ER bygget:** Nassau-vinduer og High-low-high-
|
||||||
|
stilling i `session-scorecard.tsx` (org) og `round-detail.tsx`s
|
||||||
|
`NassauPanel` (frittstående), Københavner-/BBB-poengtabeller i
|
||||||
|
`individual-tournament-detail.tsx`, Shamble/Money Ball-lagvisning i
|
||||||
|
`round-leaderboard.tsx`s `TeamFlightBoard` (frittstående — org-siden
|
||||||
|
bruker den eksisterende to-siders matchvisningen).
|
||||||
|
- Eneste sanne, fortsatt bekreftede GAP fra denne runden er separat og
|
||||||
|
udiskutert her: organisator-vendt opplasting av hero-/sponsorbilder
|
||||||
|
(se "Landingssider"-seksjonen lenger ned) — ikke relatert til de åtte
|
||||||
|
formatene.
|
||||||
|
|
||||||
### Utvidelse 2026-07-26: individuelle turneringer, flerrunde-turneringer, og Order of Merit
|
### Utvidelse 2026-07-26: individuelle turneringer, flerrunde-turneringer, og Order of Merit
|
||||||
|
|
||||||
Reist av brukeren som svar på et spørsmål om et individuelt
|
Reist av brukeren som svar på et spørsmål om et individuelt
|
||||||
|
|
|
||||||
BIN
Video_2026-08-03_145419.mp4
Normal file
BIN
Video_2026-08-03_145419.mp4
Normal file
Binary file not shown.
|
|
@ -83,12 +83,23 @@ class Tournament(BaseModel):
|
||||||
registration_overflow_policy: str
|
registration_overflow_policy: str
|
||||||
registration_requires_approval: bool
|
registration_requires_approval: bool
|
||||||
hero_image_key: str | None
|
hero_image_key: str | None
|
||||||
|
hero_image_url: str | None
|
||||||
join_code: str
|
join_code: str
|
||||||
format_type: str
|
format_type: str
|
||||||
scoring_method: str | None
|
scoring_method: str | None
|
||||||
bbb_sweep_bonus_enabled: bool
|
bbb_sweep_bonus_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _tournament_from_row(row) -> Tournament:
|
||||||
|
"""Samme mønster som auth.py sin avatar_url: DB-kolonnen forblir
|
||||||
|
`hero_image_key` (den nøytrale, lagrings-uavhengige verdien), men
|
||||||
|
responsen får i tillegg en ferdig-konstruert URL -- organisator-
|
||||||
|
frontend skal aldri selv måtte kjenne til MinIO-bucket/base-URL."""
|
||||||
|
data = dict(row)
|
||||||
|
key = data["hero_image_key"]
|
||||||
|
return Tournament(**data, hero_image_url=storage.public_url(key) if key else None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
||||||
async def list_tournaments(
|
async def list_tournaments(
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
|
@ -121,7 +132,7 @@ async def list_tournaments(
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return [Tournament(**dict(r)) for r in rows]
|
return [_tournament_from_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
|
@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
|
||||||
|
|
@ -159,7 +170,7 @@ async def create_tournament(
|
||||||
continue
|
continue
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.")
|
raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.")
|
||||||
return Tournament(**dict(row))
|
return _tournament_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
class TournamentUpdate(BaseModel):
|
class TournamentUpdate(BaseModel):
|
||||||
|
|
@ -220,7 +231,7 @@ async def update_tournament(
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
return Tournament(**dict(row))
|
return _tournament_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -257,7 +268,28 @@ async def upload_hero_image(
|
||||||
key,
|
key,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
return Tournament(**dict(row))
|
return _tournament_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
||||||
|
response_model=Tournament,
|
||||||
|
)
|
||||||
|
async def delete_hero_image(
|
||||||
|
tournament_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Tournament:
|
||||||
|
"""Samme mønster som DELETE /auth/profile/avatar -- nullstiller kun
|
||||||
|
referansen, sletter ikke selve MinIO-objektet (samme aksepterte
|
||||||
|
forenkling som avatar-fjerning)."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE tournament SET hero_image_key = NULL WHERE id = $1 RETURNING {_TOURNAMENT_COLUMNS}",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
return _tournament_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1173,6 +1205,13 @@ class Sponsor(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
url: str | None
|
url: str | None
|
||||||
logo_key: str | None
|
logo_key: str | None
|
||||||
|
logo_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _sponsor_from_row(row) -> Sponsor:
|
||||||
|
data = dict(row)
|
||||||
|
key = data["logo_key"]
|
||||||
|
return Sponsor(**data, logo_url=storage.public_url(key) if key else None)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -1188,7 +1227,7 @@ async def list_sponsors(
|
||||||
f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
return [Sponsor(**dict(r)) for r in rows]
|
return [_sponsor_from_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -1216,7 +1255,7 @@ async def add_sponsor(
|
||||||
body.name,
|
body.name,
|
||||||
body.url,
|
body.url,
|
||||||
)
|
)
|
||||||
return Sponsor(**dict(row))
|
return _sponsor_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
class SponsorUpdate(BaseModel):
|
class SponsorUpdate(BaseModel):
|
||||||
|
|
@ -1256,7 +1295,7 @@ async def update_sponsor(
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
||||||
return Sponsor(**dict(row))
|
return _sponsor_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -1295,7 +1334,7 @@ async def upload_sponsor_logo(
|
||||||
key,
|
key,
|
||||||
sponsor_id,
|
sponsor_id,
|
||||||
)
|
)
|
||||||
return Sponsor(**dict(row))
|
return _sponsor_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
|
|
|
||||||
29
frontend/app/tournaments/[id]/presentation/page.tsx
Normal file
29
frontend/app/tournaments/[id]/presentation/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { TournamentPresentation } from "@/components/tournament-presentation"
|
||||||
|
|
||||||
|
export default async function TournamentPresentationPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ org?: string; name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { org, name } = await searchParams
|
||||||
|
const tournamentName = name?.trim() || "Turnering"
|
||||||
|
|
||||||
|
// Samme grunn som app/tournaments/[id]/page.tsx: API-et krever
|
||||||
|
// organization_id på alle turnering-endepunkter (RLS, se ADR-001/003).
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TournamentPresentation organizationId={org} tournamentId={id} tournamentName={tournamentName} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
||||||
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -181,7 +182,7 @@ export function IndividualTournamentDetail({
|
||||||
const [courses, setCourses] = useState<ApiCourse[]>([])
|
const [courses, setCourses] = useState<ApiCourse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [tab, setTab] = useState<"setup" | "score" | "leaderboard">("setup")
|
const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup")
|
||||||
|
|
||||||
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
|
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
|
||||||
|
|
||||||
|
|
@ -416,12 +417,16 @@ export function IndividualTournamentDetail({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
<nav
|
||||||
|
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||||
|
aria-label="Turneringsseksjoner"
|
||||||
|
>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["setup", "Oppsett"],
|
["setup", "Oppsett"],
|
||||||
["score", "Scorekort"],
|
["score", "Scorekort"],
|
||||||
["leaderboard", "Leaderboard"],
|
["leaderboard", "Leaderboard"],
|
||||||
|
["presentation", "Presentasjon"],
|
||||||
] as const
|
] as const
|
||||||
).map(([key, label]) => (
|
).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -430,7 +435,7 @@ export function IndividualTournamentDetail({
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
aria-current={tab === key ? "page" : undefined}
|
aria-current={tab === key ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-11 rounded-full px-4 py-2 text-sm font-bold transition-colors",
|
"min-h-11 shrink-0 rounded-full px-4 py-2 text-sm font-bold transition-colors",
|
||||||
tab === key
|
tab === key
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
|
|
@ -474,6 +479,9 @@ export function IndividualTournamentDetail({
|
||||||
{tab === "leaderboard" && (
|
{tab === "leaderboard" && (
|
||||||
<LeaderboardTab base={base} tournament={tournament} />
|
<LeaderboardTab base={base} tournament={tournament} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "presentation" && (
|
||||||
|
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -400,9 +400,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// Delt header (ADR-040) sin "Administrer"-dialog lenker hit med
|
// Delt header (ADR-040) sin "Administrer"-dialog lenker hit med
|
||||||
// ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard-
|
// ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard-
|
||||||
// siden -- lest KUN ved mount (bevisst ikke reaktiv på senere endringer,
|
// siden. Fungerte derfra fordi det er en navigasjon MELLOM to ulike
|
||||||
// matcher den eksisterende lokale pill-vekslingen som fortsatt fungerer
|
// rute-komponenter (ekte remount, `initialTab` regnes ut på nytt) --
|
||||||
// uendret for navigering INNAD på siden).
|
// men fra selve Score-siden er `/my-rounds/{id}` -> `/my-rounds/{id}
|
||||||
|
// ?tab=manage` KUN en søkeparameter-endring på SAMME rute, ingen
|
||||||
|
// remount, så `useState(initialTab)` sitt opprinnelige mount-tidspunkt-
|
||||||
|
// resultat ble stående uendret -- knappen så ut til å ikke gjøre noe
|
||||||
|
// (rapportert av bruker via video 2026-08-03, bekreftet: URL-en endret
|
||||||
|
// seg riktig til ?tab=manage, men selve fanen forble "Score"). Fikset
|
||||||
|
// med en effekt som reagerer på selve søkeparameter-ENDRINGEN, ikke
|
||||||
|
// bare mount -- ensrettet (kun til "manage"), rører aldri brukerens
|
||||||
|
// egen lokale "Score"-valg via pillen etterpå.
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
|
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
|
||||||
const [round, setRound] = useState<ApiRound | null>(null)
|
const [round, setRound] = useState<ApiRound | null>(null)
|
||||||
|
|
@ -442,6 +450,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
|
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
|
||||||
// fleste trenger under selve spillingen.
|
// fleste trenger under selve spillingen.
|
||||||
const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab)
|
const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab)
|
||||||
|
// Fikser "Administrer"-snarveien fra selve Score-siden (se begrunnelse
|
||||||
|
// ved searchParams over) -- reagerer på selve parameter-ENDRINGEN i
|
||||||
|
// stedet for kun mount-tidspunktet. Ensrettet med vilje: hopper TIL
|
||||||
|
// "manage" når URL-en ber om det, men tvinger aldri tilbake til "score"
|
||||||
|
// om parameteren mangler/fjernes (det ville kunnet rive brukeren vekk
|
||||||
|
// fra et bevisst lokalt "Score"-valg de allerede har gjort).
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("tab") === "manage") setPageTab("manage")
|
||||||
|
}, [searchParams])
|
||||||
const [completing, setCompleting] = useState(false)
|
const [completing, setCompleting] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [showEditRound, setShowEditRound] = useState(false)
|
const [showEditRound, setShowEditRound] = useState(false)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft, ArrowUp, ArrowDown, ArrowRight, ArrowUpLeft, ArrowUpRight, Check, Target } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// --- API-typer ---------------------------------------------------------------
|
// --- API-typer ---------------------------------------------------------------
|
||||||
|
|
@ -71,6 +71,18 @@ type ApiHole = {
|
||||||
score: number | null
|
score: number | null
|
||||||
picked_up: boolean
|
picked_up: boolean
|
||||||
strokes_received: number | null
|
strokes_received: number | null
|
||||||
|
// Detaljfelt (2026-08-03) -- allerede returnert av samme
|
||||||
|
// RoundHoleOut/`/rounds/.../holes`-endepunkt denne siden lenge har brukt,
|
||||||
|
// bare ikke tidligere lest her. Presentert som egne, forkortede rader i
|
||||||
|
// ScoreBlock i stedet for kun å ligge tilgjengelig i round-detail.tsx sin
|
||||||
|
// "Så langt i runden"-seksjon.
|
||||||
|
putts: number | null
|
||||||
|
tee_shot_result: "fairway" | "left" | "right" | null
|
||||||
|
approach_result: "hit" | "short" | "long" | "left" | "right" | null
|
||||||
|
chip_count: number | null
|
||||||
|
bunker_shot_count: number | null
|
||||||
|
penalty_strokes: number | null
|
||||||
|
anyway_strokes: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
||||||
|
|
@ -115,6 +127,13 @@ function stablefordPoints(h: ApiHole): number | null {
|
||||||
return Math.max(0, h.par - net + 2)
|
return Math.max(0, h.par - net + 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Samme GIR-formel som round-stats.tsx sin isGir/round-detail.tsx sin
|
||||||
|
// GIR-auto-inferens: slag minus putt garanterer green i regulering når
|
||||||
|
// resultatet er par-2 eller bedre. Krever kjent slag+putt-tall.
|
||||||
|
function isGir(h: ApiHole): boolean {
|
||||||
|
return h.score !== null && h.putts !== null && h.score - h.putts <= h.par - 2
|
||||||
|
}
|
||||||
|
|
||||||
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
||||||
|
|
||||||
function classify(gross: number, par: number): ScoreClass {
|
function classify(gross: number, par: number): ScoreClass {
|
||||||
|
|
@ -193,14 +212,53 @@ function ScoreMark({ gross, par, pickedUp }: { gross: number | null; par: number
|
||||||
|
|
||||||
// --- One nine-hole block (a real table for row/column semantics) -----------
|
// --- One nine-hole block (a real table for row/column semantics) -----------
|
||||||
|
|
||||||
|
// Kompakte retnings-/treff-ikoner for de nye detaljradene (2026-08-03) --
|
||||||
|
// samme ikonvalg som DirectionCross (round-detail.tsx sin registrerings-
|
||||||
|
// veiviser) allerede bruker, kun skalert ned for en tett tabellcelle.
|
||||||
|
// Bøyde piler for Fairway (utslaget DRIVER av til en side, ulikt et
|
||||||
|
// innspill som bommer rett til siden -- derfor ArrowUpLeft/-Right her,
|
||||||
|
// ikke de rette ArrowLeft/-Right Innspill-raden bruker).
|
||||||
|
function FairwayIcon({ result }: { result: ApiHole["tee_shot_result"] }) {
|
||||||
|
if (result === null) return null
|
||||||
|
if (result === "fairway") return <Target aria-hidden="true" className="mx-auto size-3.5 text-primary" />
|
||||||
|
if (result === "left") return <ArrowUpLeft aria-hidden="true" className="mx-auto size-3.5 text-brand-orange" />
|
||||||
|
return <ArrowUpRight aria-hidden="true" className="mx-auto size-3.5 text-brand-orange" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApproachIcon({ result }: { result: ApiHole["approach_result"] }) {
|
||||||
|
if (result === null) return null
|
||||||
|
const cls = "mx-auto size-3.5"
|
||||||
|
if (result === "hit") return <Target aria-hidden="true" className={cn(cls, "text-primary")} />
|
||||||
|
if (result === "long") return <ArrowUp aria-hidden="true" className={cn(cls, "text-brand-orange")} />
|
||||||
|
if (result === "short") return <ArrowDown aria-hidden="true" className={cn(cls, "text-brand-orange")} />
|
||||||
|
if (result === "left") return <ArrowLeft aria-hidden="true" className={cn(cls, "text-brand-orange")} />
|
||||||
|
return <ArrowRight aria-hidden="true" className={cn(cls, "text-brand-orange")} />
|
||||||
|
}
|
||||||
|
|
||||||
function ScoreBlock({
|
function ScoreBlock({
|
||||||
holes,
|
holes,
|
||||||
summaryLabel,
|
summaryLabel,
|
||||||
showStableford,
|
showStableford,
|
||||||
|
showFairway,
|
||||||
|
showPutts,
|
||||||
|
showGir,
|
||||||
|
showApproach,
|
||||||
|
showChip,
|
||||||
|
showBunker,
|
||||||
|
showPenalty,
|
||||||
|
showAnyway,
|
||||||
}: {
|
}: {
|
||||||
holes: ApiHole[]
|
holes: ApiHole[]
|
||||||
summaryLabel: string
|
summaryLabel: string
|
||||||
showStableford: boolean
|
showStableford: boolean
|
||||||
|
showFairway: boolean
|
||||||
|
showPutts: boolean
|
||||||
|
showGir: boolean
|
||||||
|
showApproach: boolean
|
||||||
|
showChip: boolean
|
||||||
|
showBunker: boolean
|
||||||
|
showPenalty: boolean
|
||||||
|
showAnyway: boolean
|
||||||
}) {
|
}) {
|
||||||
const parSum = sum(holes.map((h) => h.par))
|
const parSum = sum(holes.map((h) => h.par))
|
||||||
const playedHoles = holes.filter((h) => h.played && h.score !== null)
|
const playedHoles = holes.filter((h) => h.played && h.score !== null)
|
||||||
|
|
@ -210,10 +268,31 @@ function ScoreBlock({
|
||||||
const pointsValues = holes.map(stablefordPoints).filter((n): n is number => n !== null)
|
const pointsValues = holes.map(stablefordPoints).filter((n): n is number => n !== null)
|
||||||
const pointsSum = pointsValues.length > 0 ? sum(pointsValues) : null
|
const pointsSum = pointsValues.length > 0 ? sum(pointsValues) : null
|
||||||
|
|
||||||
|
// Fairwaytreff spores kun på ikke-par-3-hull (samme regel som
|
||||||
|
// round-detail.tsx sin "Så langt i runden"), summen teller kun "Blink".
|
||||||
|
const fairwayEligible = holes.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
|
||||||
|
const fairwayHitSum = fairwayEligible.length > 0 ? fairwayEligible.filter((h) => h.tee_shot_result === "fairway").length : null
|
||||||
|
const puttsValues = holes.filter((h) => h.putts !== null).map((h) => h.putts as number)
|
||||||
|
const puttsSum = puttsValues.length > 0 ? sum(puttsValues) : null
|
||||||
|
const girEligible = holes.filter((h) => h.score !== null && h.putts !== null)
|
||||||
|
const girSum = girEligible.length > 0 ? girEligible.filter(isGir).length : null
|
||||||
|
const chipValues = holes.filter((h) => h.chip_count !== null).map((h) => h.chip_count as number)
|
||||||
|
const chipSum = chipValues.length > 0 ? sum(chipValues) : null
|
||||||
|
const bunkerValues = holes.filter((h) => h.bunker_shot_count !== null).map((h) => h.bunker_shot_count as number)
|
||||||
|
const bunkerSum = bunkerValues.length > 0 ? sum(bunkerValues) : null
|
||||||
|
const penaltyValues = holes.filter((h) => h.penalty_strokes !== null).map((h) => h.penalty_strokes as number)
|
||||||
|
const penaltySum = penaltyValues.length > 0 ? sum(penaltyValues) : null
|
||||||
|
const anywayValues = holes.filter((h) => h.anyway_strokes !== null).map((h) => h.anyway_strokes as number)
|
||||||
|
const anywaySum = anywayValues.length > 0 ? sum(anywayValues) : null
|
||||||
|
|
||||||
const cell = "border border-border text-center tabular-nums"
|
const cell = "border border-border text-center tabular-nums"
|
||||||
const sumCell = "border border-border bg-muted text-center font-extrabold tabular-nums"
|
const sumCell = "border border-border bg-muted text-center font-extrabold tabular-nums"
|
||||||
|
// whitespace-nowrap + overflow-hidden (2026-08-03, rapportert av bruker --
|
||||||
|
// "Score"/"Netto" bløuted ut over kolonnegrensen) -- w-11 gir litt mer
|
||||||
|
// rom enn tidligere w-9, men de VIRKELIG korte etikettene (forkortelser)
|
||||||
|
// under er den egentlige fiksen, ikke bare bredden alene.
|
||||||
const rowLabel =
|
const rowLabel =
|
||||||
"border border-border px-1 text-left text-xs font-bold text-muted-foreground"
|
"border border-border px-1 text-left text-xs font-bold text-muted-foreground whitespace-nowrap overflow-hidden"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="w-full table-fixed border-collapse">
|
<table className="w-full table-fixed border-collapse">
|
||||||
|
|
@ -221,7 +300,7 @@ function ScoreBlock({
|
||||||
Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number}
|
Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number}
|
||||||
</caption>
|
</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-9" />
|
<col className="w-11" />
|
||||||
{holes.map((h) => (
|
{holes.map((h) => (
|
||||||
<col key={h.hole_number} />
|
<col key={h.hole_number} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -319,6 +398,133 @@ function ScoreBlock({
|
||||||
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{pointsSum ?? "–"}</td>
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{pointsSum ?? "–"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
{/* Fairway -- ikon per hull (bøyd pil = utslaget drev til siden,
|
||||||
|
blink = fairwaytreff), sum = antall fairwaytreff. */}
|
||||||
|
{showFairway && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Fairwaytreff">
|
||||||
|
Fw
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1")}>
|
||||||
|
{h.par !== 3 ? <FairwayIcon result={h.tee_shot_result} /> : <span aria-hidden="true">–</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{fairwayHitSum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* Putter -- antall per hull, summert. */}
|
||||||
|
{showPutts && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Putter">
|
||||||
|
Putt
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||||||
|
{h.putts ?? "–"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{puttsSum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* GIR -- sjekkmerke når hullet er nådd i regulering, summert. */}
|
||||||
|
{showGir && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Green i regulering">
|
||||||
|
GIR
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1")}>
|
||||||
|
{h.score !== null && h.putts !== null ? (
|
||||||
|
isGir(h) ? (
|
||||||
|
<Check aria-hidden="true" className="mx-auto size-3.5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true" className="text-muted-foreground">
|
||||||
|
–
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{girSum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* Innspill -- 5-veis retning, ingen meningsfull sum (retning, ikke
|
||||||
|
et tall). */}
|
||||||
|
{showApproach && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Innspill">
|
||||||
|
Appr.
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1")}>
|
||||||
|
<ApproachIcon result={h.approach_result} />
|
||||||
|
{h.approach_result === null && <span aria-hidden="true">–</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-muted-foreground")} aria-hidden="true">
|
||||||
|
–
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* Chip -- antall per hull, summert. */}
|
||||||
|
{showChip && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Chip">
|
||||||
|
Chip
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||||||
|
{h.chip_count ?? "–"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{chipSum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* Bunkerslag -- antall per hull, summert. */}
|
||||||
|
{showBunker && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Bunkerslag">
|
||||||
|
Bnk
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||||||
|
{h.bunker_shot_count ?? "–"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{bunkerSum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* Straffeslag -- antall per hull, summert. */}
|
||||||
|
{showPenalty && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Straffeslag">
|
||||||
|
Str
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||||||
|
{h.penalty_strokes ?? "–"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{penaltySum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{/* Anywayslag -- antall per hull, summert. */}
|
||||||
|
{showAnyway && (
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className={rowLabel} title="Anywayslag">
|
||||||
|
Any.
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||||||
|
{h.anyway_strokes ?? "–"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{anywaySum ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
|
|
@ -992,6 +1198,18 @@ export function RoundScorecard({
|
||||||
const back = orderedHoles.slice(9, 18)
|
const back = orderedHoles.slice(9, 18)
|
||||||
|
|
||||||
const showStableford = holes.some((h) => h.strokes_received !== null)
|
const showStableford = holes.some((h) => h.strokes_received !== null)
|
||||||
|
// Detaljrader (2026-08-03) -- vis kun rader som faktisk har data et sted
|
||||||
|
// i runden (samme "vis kun det som faktisk finnes"-prinsipp som resten
|
||||||
|
// av appen), regnet ut fra HELE runden (ikke bare front/back for seg)
|
||||||
|
// slik at begge blokkene konsekvent viser/skjuler de samme radene.
|
||||||
|
const showFairway = holes.some((h) => h.par !== 3 && h.tee_shot_result !== null)
|
||||||
|
const showPutts = holes.some((h) => h.putts !== null)
|
||||||
|
const showGir = holes.some((h) => h.score !== null && h.putts !== null)
|
||||||
|
const showApproach = holes.some((h) => h.approach_result !== null)
|
||||||
|
const showChip = holes.some((h) => h.chip_count !== null)
|
||||||
|
const showBunker = holes.some((h) => h.bunker_shot_count !== null)
|
||||||
|
const showPenalty = holes.some((h) => h.penalty_strokes !== null)
|
||||||
|
const showAnyway = holes.some((h) => h.anyway_strokes !== null)
|
||||||
const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null)
|
const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null)
|
||||||
// Full rundens par (statisk referanse, f.eks. "Par 72") -- ALDRI brukt til
|
// Full rundens par (statisk referanse, f.eks. "Par 72") -- ALDRI brukt til
|
||||||
// en til-par-utregning, kun til "Par"-feltet nederst som viser hele
|
// en til-par-utregning, kun til "Par"-feltet nederst som viser hele
|
||||||
|
|
@ -1082,11 +1300,37 @@ export function RoundScorecard({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scorecard blocks */}
|
{/* Scorecard blocks */}
|
||||||
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
|
<div className="flex flex-col gap-3 overflow-x-auto rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
|
||||||
{front.length > 0 && (
|
{front.length > 0 && (
|
||||||
<ScoreBlock holes={front} summaryLabel={back.length === 0 ? "Tot" : "Ut"} showStableford={showStableford} />
|
<ScoreBlock
|
||||||
|
holes={front}
|
||||||
|
summaryLabel={back.length === 0 ? "Tot" : "Ut"}
|
||||||
|
showStableford={showStableford}
|
||||||
|
showFairway={showFairway}
|
||||||
|
showPutts={showPutts}
|
||||||
|
showGir={showGir}
|
||||||
|
showApproach={showApproach}
|
||||||
|
showChip={showChip}
|
||||||
|
showBunker={showBunker}
|
||||||
|
showPenalty={showPenalty}
|
||||||
|
showAnyway={showAnyway}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{back.length > 0 && (
|
||||||
|
<ScoreBlock
|
||||||
|
holes={back}
|
||||||
|
summaryLabel="Inn"
|
||||||
|
showStableford={showStableford}
|
||||||
|
showFairway={showFairway}
|
||||||
|
showPutts={showPutts}
|
||||||
|
showGir={showGir}
|
||||||
|
showApproach={showApproach}
|
||||||
|
showChip={showChip}
|
||||||
|
showBunker={showBunker}
|
||||||
|
showPenalty={showPenalty}
|
||||||
|
showAnyway={showAnyway}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{back.length > 0 && <ScoreBlock holes={back} summaryLabel="Inn" showStableford={showStableford} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Combined total */}
|
{/* Combined total */}
|
||||||
|
|
|
||||||
|
|
@ -370,25 +370,34 @@ export function TournamentDetail({
|
||||||
{joinCode && <JoinCodeChip code={joinCode} />}
|
{joinCode && <JoinCodeChip code={joinCode} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
<nav
|
||||||
|
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||||
|
aria-label="Turneringsseksjoner"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Lag og spillere
|
Lag og spillere
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
href={`/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Program
|
Program
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
href={`/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Leaderboard
|
Leaderboard
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Presentasjon
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,25 +106,34 @@ export function TournamentLeaderboard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
<nav
|
||||||
|
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||||
|
aria-label="Turneringsseksjoner"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={detailHref}
|
href={detailHref}
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Lag og spillere
|
Lag og spillere
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={programHref}
|
href={programHref}
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Program
|
Program
|
||||||
</Link>
|
</Link>
|
||||||
<span
|
<span
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Leaderboard
|
Leaderboard
|
||||||
</span>
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Presentasjon
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
680
frontend/components/tournament-presentation.tsx
Normal file
680
frontend/components/tournament-presentation.tsx
Normal file
|
|
@ -0,0 +1,680 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Turnering-presentasjon (ADR-018): beskrivelse, synlighet, påmeldings-
|
||||||
|
// innstillinger, hero-bilde og sponsorer. Backend for alt dette har vært
|
||||||
|
// klart siden 2026-07-18 -- denne skjermen var det manglende siste steget
|
||||||
|
// (se FEATURE_BACKLOG.md "Landingssider"-seksjonen, rettet 2026-08-03).
|
||||||
|
// Delt mellom lag- og individuelle turneringer (samme `tournament`-tabell,
|
||||||
|
// disse feltene er ikke format_type-spesifikke) -- se tournament-router.tsx
|
||||||
|
// for hvordan format_type ellers velger skjerm.
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Camera,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type Visibility = "public" | "org" | "participants"
|
||||||
|
type OverflowPolicy = "waitlist" | "closed"
|
||||||
|
|
||||||
|
type ApiTournament = {
|
||||||
|
id: string
|
||||||
|
visibility: Visibility
|
||||||
|
description: string | null
|
||||||
|
registration_deadline: string | null
|
||||||
|
registration_capacity: number | null
|
||||||
|
registration_overflow_policy: OverflowPolicy
|
||||||
|
registration_requires_approval: boolean
|
||||||
|
hero_image_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiSponsor = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string | null
|
||||||
|
logo_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBILITY_OPTIONS: { value: Visibility; label: string; hint: string; icon: typeof Globe }[] = [
|
||||||
|
{ value: "public", label: "Offentlig", hint: "Alle kan se siden, uten innlogging.", icon: Globe },
|
||||||
|
{ value: "org", label: "Kun org", hint: "Kun medlemmer av klubben.", icon: Users },
|
||||||
|
{ value: "participants", label: "Kun deltakere", hint: "Kun rostrede/registrerte spillere.", icon: Lock },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isoToLocalInputValue(iso: string | null): string {
|
||||||
|
if (!iso) return ""
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return ""
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function localInputValueToIso(value: string): string | null {
|
||||||
|
if (!value.trim()) return null
|
||||||
|
const d = new Date(value)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Innholdet uten header/fanerad -- egen eksport slik at
|
||||||
|
// individual-tournament-detail.tsx (som har sitt eget in-page fanesystem,
|
||||||
|
// ikke egne ruter som lagturneringene) kan bygge den inn som en fjerde fane
|
||||||
|
// uten å duplisere en header. TournamentPresentation under (full side, egen
|
||||||
|
// rute) er kun en tynn header+nav-innpakning rundt denne.
|
||||||
|
export function TournamentPresentationPanel({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
}) {
|
||||||
|
const [tournament, setTournament] = useState<ApiTournament | null>(null)
|
||||||
|
const [sponsors, setSponsors] = useState<ApiSponsor[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Skjema-lokal tilstand, seedet fra `tournament` ved (ny) lasting.
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [visibility, setVisibility] = useState<Visibility>("org")
|
||||||
|
const [requiresApproval, setRequiresApproval] = useState(false)
|
||||||
|
const [overflowPolicy, setOverflowPolicy] = useState<OverflowPolicy>("waitlist")
|
||||||
|
const [capacity, setCapacity] = useState("")
|
||||||
|
const [deadline, setDeadline] = useState("")
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [uploadingHero, setUploadingHero] = useState(false)
|
||||||
|
const heroInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [sponsorName, setSponsorName] = useState("")
|
||||||
|
const [sponsorUrl, setSponsorUrl] = useState("")
|
||||||
|
const [addingSponsor, setAddingSponsor] = useState(false)
|
||||||
|
const [sponsorError, setSponsorError] = useState<string | null>(null)
|
||||||
|
const [logoTargetId, setLogoTargetId] = useState<string | null>(null)
|
||||||
|
const [uploadingLogoFor, setUploadingLogoFor] = useState<string | null>(null)
|
||||||
|
const sponsorLogoInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [tournamentsRes, sponsorsRes] = await Promise.all([
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (!tournamentsRes.ok) throw new Error("load failed")
|
||||||
|
const list: ApiTournament[] = await tournamentsRes.json()
|
||||||
|
const mine = list.find((t) => t.id === tournamentId)
|
||||||
|
if (!mine) throw new Error("not found")
|
||||||
|
if (cancelled) return
|
||||||
|
setTournament(mine)
|
||||||
|
setDescription(mine.description ?? "")
|
||||||
|
setVisibility(mine.visibility)
|
||||||
|
setRequiresApproval(mine.registration_requires_approval)
|
||||||
|
setOverflowPolicy(mine.registration_overflow_policy)
|
||||||
|
setCapacity(mine.registration_capacity != null ? String(mine.registration_capacity) : "")
|
||||||
|
setDeadline(isoToLocalInputValue(mine.registration_deadline))
|
||||||
|
if (sponsorsRes.ok) setSponsors(await sponsorsRes.json())
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setLoadError("Klarte ikke å laste turnering-presentasjonen. Prøv å laste siden på nytt.")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, tournamentId])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
setSaved(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
description: description.trim() === "" ? null : description.trim(),
|
||||||
|
visibility,
|
||||||
|
registration_requires_approval: requiresApproval,
|
||||||
|
registration_overflow_policy: overflowPolicy,
|
||||||
|
registration_capacity: capacity.trim() === "" ? null : Number.parseInt(capacity, 10),
|
||||||
|
registration_deadline: localInputValueToIso(deadline),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre.")
|
||||||
|
}
|
||||||
|
const updated: ApiTournament = await res.json()
|
||||||
|
setTournament(updated)
|
||||||
|
setSaved(true)
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : "Klarte ikke å lagre. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHeroSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadingHero(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/hero-image`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Klarte ikke å laste opp bildet.")
|
||||||
|
const updated: ApiTournament = await res.json()
|
||||||
|
setTournament(updated)
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : "Klarte ikke å laste opp bildet.")
|
||||||
|
} finally {
|
||||||
|
setUploadingHero(false)
|
||||||
|
if (heroInputRef.current) heroInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveHero() {
|
||||||
|
setUploadingHero(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/hero-image`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.ok) setTournament(await res.json())
|
||||||
|
} finally {
|
||||||
|
setUploadingHero(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddSponsor(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (sponsorName.trim() === "") return
|
||||||
|
setAddingSponsor(true)
|
||||||
|
setSponsorError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ name: sponsorName.trim(), url: sponsorUrl.trim() === "" ? null : sponsorUrl.trim() }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Klarte ikke å legge til sponsoren.")
|
||||||
|
const created: ApiSponsor = await res.json()
|
||||||
|
setSponsors((prev) => [...prev, created])
|
||||||
|
setSponsorName("")
|
||||||
|
setSponsorUrl("")
|
||||||
|
} catch (err) {
|
||||||
|
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å legge til sponsoren.")
|
||||||
|
} finally {
|
||||||
|
setAddingSponsor(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteSponsor(sponsorId: string) {
|
||||||
|
setSponsorError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors/${sponsorId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.status !== 204) throw new Error("Klarte ikke å fjerne sponsoren.")
|
||||||
|
setSponsors((prev) => prev.filter((s) => s.id !== sponsorId))
|
||||||
|
} catch (err) {
|
||||||
|
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å fjerne sponsoren.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerLogoUpload(sponsorId: string) {
|
||||||
|
setLogoTargetId(sponsorId)
|
||||||
|
sponsorLogoInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogoSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
const sponsorId = logoTargetId
|
||||||
|
if (!file || !sponsorId) return
|
||||||
|
setUploadingLogoFor(sponsorId)
|
||||||
|
setSponsorError(null)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
const res = await fetch(
|
||||||
|
`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors/${sponsorId}/logo`,
|
||||||
|
{ method: "POST", credentials: "include", body: formData },
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error("Klarte ikke å laste opp logoen.")
|
||||||
|
const updated: ApiSponsor = await res.json()
|
||||||
|
setSponsors((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
|
||||||
|
} catch (err) {
|
||||||
|
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å laste opp logoen.")
|
||||||
|
} finally {
|
||||||
|
setUploadingLogoFor(null)
|
||||||
|
setLogoTargetId(null)
|
||||||
|
if (sponsorLogoInputRef.current) sponsorLogoInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="mb-4 flex flex-col gap-1">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Hvordan turneringen vises på sin offentlige side (
|
||||||
|
<Link
|
||||||
|
href={`/t/${tournamentId}`}
|
||||||
|
target="_blank"
|
||||||
|
className="underline underline-offset-2 hover:text-foreground"
|
||||||
|
>
|
||||||
|
/t/{tournamentId}
|
||||||
|
<ExternalLink aria-hidden="true" className="ml-1 inline size-3" />
|
||||||
|
</Link>
|
||||||
|
) og klubbsiden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{loadError && (
|
||||||
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : tournament ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* --- Hero-bilde ------------------------------------------------ */}
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Hero-bilde</h3>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex h-20 w-32 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-secondary">
|
||||||
|
{tournament.hero_image_url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={tournament.hero_image_url} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Camera aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<input
|
||||||
|
ref={heroInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleHeroSelected}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploadingHero}
|
||||||
|
onClick={() => heroInputRef.current?.click()}
|
||||||
|
className="h-11 rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
<Camera aria-hidden="true" className="size-4" />
|
||||||
|
{tournament.hero_image_url ? "Bytt bilde" : "Last opp bilde"}
|
||||||
|
</Button>
|
||||||
|
{tournament.hero_image_url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveHero}
|
||||||
|
disabled={uploadingHero}
|
||||||
|
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-3" />
|
||||||
|
Fjern bilde
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Beskrivelse & synlighet ------------------------------------ */}
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Beskrivelse og synlighet</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="tournament-description">Beskrivelse</Label>
|
||||||
|
<textarea
|
||||||
|
id="tournament-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Fortell spillere og besøkende hva turneringen handler om…"
|
||||||
|
className="w-full rounded-xl border border-input bg-transparent px-3 py-2.5 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Hvem kan se siden</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
|
{VISIBILITY_OPTIONS.map((opt) => {
|
||||||
|
const Icon = opt.icon
|
||||||
|
const selected = visibility === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibility(opt.value)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 flex-col items-start gap-1 rounded-2xl border px-3 py-3 text-left transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-semibold">
|
||||||
|
<Icon aria-hidden="true" className="size-4" />
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-snug text-muted-foreground text-pretty">{opt.hint}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Påmelding --------------------------------------------------- */}
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Påmelding</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border py-1 last:border-b-0">
|
||||||
|
<span className="text-sm font-medium text-foreground text-pretty">
|
||||||
|
Krev godkjenning før bekreftet plass
|
||||||
|
</span>
|
||||||
|
<Switch checked={requiresApproval} onCheckedChange={setRequiresApproval} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Når plassene tar slutt</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOverflowPolicy("waitlist")}
|
||||||
|
aria-pressed={overflowPolicy === "waitlist"}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
|
||||||
|
overflowPolicy === "waitlist"
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Sett på venteliste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOverflowPolicy("closed")}
|
||||||
|
aria-pressed={overflowPolicy === "closed"}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
|
||||||
|
overflowPolicy === "closed"
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Steng påmelding
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="tournament-capacity">Maks antall påmeldte (valgfritt)</Label>
|
||||||
|
<Input
|
||||||
|
id="tournament-capacity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={capacity}
|
||||||
|
onChange={(e) => setCapacity(e.target.value)}
|
||||||
|
placeholder="Ingen grense"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="tournament-deadline">Påmeldingsfrist (valgfritt)</Label>
|
||||||
|
<Input
|
||||||
|
id="tournament-deadline"
|
||||||
|
type="datetime-local"
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-11 rounded-xl px-6 font-semibold"
|
||||||
|
>
|
||||||
|
{saving ? "Lagrer…" : "Lagre endringer"}
|
||||||
|
</Button>
|
||||||
|
{saved && !saving && (
|
||||||
|
<span className="flex items-center gap-1 text-sm font-semibold text-primary">
|
||||||
|
<Check aria-hidden="true" className="size-4" />
|
||||||
|
Lagret
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Sponsorer ----------------------------------------------------- */}
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Sponsorer</h3>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={sponsorLogoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleLogoSelected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sponsors.length > 0 && (
|
||||||
|
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
||||||
|
{sponsors.map((sp) => (
|
||||||
|
<li key={sp.id} className="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div className="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-secondary">
|
||||||
|
{sp.logo_url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={sp.logo_url} alt="" className="size-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<Camera aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">{sp.name}</span>
|
||||||
|
{sp.url && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{sp.url}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => triggerLogoUpload(sp.id)}
|
||||||
|
disabled={uploadingLogoFor === sp.id}
|
||||||
|
className="flex h-11 shrink-0 items-center gap-1 rounded-xl px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Camera aria-hidden="true" className="size-3.5" />
|
||||||
|
{sp.logo_url ? "Bytt logo" : "Last opp logo"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteSponsor(sp.id)}
|
||||||
|
aria-label={`Fjern ${sp.name}`}
|
||||||
|
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sponsorError && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
{sponsorError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAddSponsor} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sponsor-name">Sponsornavn</Label>
|
||||||
|
<Input
|
||||||
|
id="sponsor-name"
|
||||||
|
value={sponsorName}
|
||||||
|
onChange={(e) => setSponsorName(e.target.value)}
|
||||||
|
placeholder="F.eks. Tjøme Rørlegger AS"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sponsor-url">Lenke (valgfritt)</Label>
|
||||||
|
<Input
|
||||||
|
id="sponsor-url"
|
||||||
|
value={sponsorUrl}
|
||||||
|
onChange={(e) => setSponsorUrl(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={addingSponsor || sponsorName.trim() === ""}
|
||||||
|
className="h-11 shrink-0 rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full side (egen rute, `/tournaments/[id]/presentation`) -- header+fanerad
|
||||||
|
// rundt panelet over, samme mønster som tournament-detail.tsx/
|
||||||
|
// tournament-program.tsx/tournament-leaderboard.tsx sine respektive header-
|
||||||
|
// blokker (duplisert der også, ikke en delt komponent -- se CHANGELOG.md
|
||||||
|
// 2026-08-02 for et tidligere funnet eksempel på nettopp denne dupliseringen
|
||||||
|
// som årsak til en bug reparert flere steder).
|
||||||
|
export function TournamentPresentation({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
tournamentName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
tournamentName: string
|
||||||
|
}) {
|
||||||
|
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={detailHref}
|
||||||
|
aria-label="Tilbake til turnering"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Turnering
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||||
|
{tournamentName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||||
|
aria-label="Turneringsseksjoner"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={detailHref}
|
||||||
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Lag og spillere
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={programHref}
|
||||||
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Program
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={leaderboardHref}
|
||||||
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Leaderboard
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
aria-current="page"
|
||||||
|
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||||
|
>
|
||||||
|
Presentasjon
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
<h2 className="mb-1 text-base font-bold text-foreground">Presentasjon</h2>
|
||||||
|
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -292,26 +292,35 @@ export function TournamentProgram({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
<nav
|
||||||
|
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||||
|
aria-label="Turneringsseksjoner"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={detailHref}
|
href={detailHref}
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Lag og spillere
|
Lag og spillere
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={programHref}
|
href={programHref}
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Program
|
Program
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={leaderboardHref}
|
href={leaderboardHref}
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Leaderboard
|
Leaderboard
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Presentasjon
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue