From 5355aba75db1d8aba47982a68ff8d747cc8b61f5 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 19 Aug 2026 07:54:07 +0200 Subject: [PATCH] =?UTF-8?q?Ekte=20i18n=20(next-intl)=20+=20hjelpeside/FAQ?= =?UTF-8?q?=20+=20Spillere-tabellen=20sl=C3=A5tt=20videre=20sammen=20(ADR-?= =?UTF-8?q?094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grunnmur for oversettelse (cookie-basert, ingen URL-endring), ny /hjelp-side med oversikt+FAQ på norsk og engelsk, og Deltakere+ Rundedeltakelse slått sammen til én bred tabell i Spillere-steget. Resten av appens ~2200 strenger og 479 backend-feilkoder er bevisst utsatt til senere, avgrensede runder -- se FEATURE_BACKLOG.md. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE_DECISIONS.md | 102 ++++ CHANGELOG.md | 37 ++ FEATURE_BACKLOG.md | 33 ++ app/routers/auth.py | 6 + frontend/app/hjelp/page.tsx | 5 + frontend/app/layout.tsx | 19 +- frontend/components/help-page.tsx | 99 ++++ .../individual-tournament-detail.tsx | 426 ++++---------- frontend/components/language-switcher.tsx | 71 +++ frontend/components/locale-bootstrap.tsx | 45 ++ frontend/components/more-menu.tsx | 10 +- .../components/round-participation-table.tsx | 345 ------------ .../components/tournament-players-table.tsx | 527 ++++++++++++++++++ frontend/i18n/request.ts | 20 + frontend/lib/locale.ts | 12 + frontend/messages/en.json | 56 ++ frontend/messages/nb.json | 56 ++ frontend/next.config.mjs | 8 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 393 +++++++++++++ frontend/pnpm-workspace.yaml | 2 + tests/test_profile_locale.py | 40 ++ 22 files changed, 1640 insertions(+), 673 deletions(-) create mode 100644 frontend/app/hjelp/page.tsx create mode 100644 frontend/components/help-page.tsx create mode 100644 frontend/components/language-switcher.tsx create mode 100644 frontend/components/locale-bootstrap.tsx delete mode 100644 frontend/components/round-participation-table.tsx create mode 100644 frontend/components/tournament-players-table.tsx create mode 100644 frontend/i18n/request.ts create mode 100644 frontend/lib/locale.ts create mode 100644 frontend/messages/en.json create mode 100644 frontend/messages/nb.json create mode 100644 tests/test_profile_locale.py diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index b969831..1b58917 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -9503,6 +9503,108 @@ denne runden -- vurdert tilstrekkelig dekket av backend-pytestene compose build teecup_api teecup_frontend && up -d` for begge, rene logger, 200 OK. +## ADR-094: Ekte i18n-infrastruktur (next-intl) + hjelpeside/FAQ + Spillere-tabellen slått videre sammen (2026-08-19) + +Bruker ba om en veiledning (FAQ + oversiktsside) for turneringsflyten, +presisert med "alt skal være oversettbart". Appen hadde på det +tidspunktet NULL oversettelsesinfrastruktur -- alt UI-tekst hardkodet +norsk. Kartlagt før bygging (Explore-agent + direkte grep): 70 +komponentfiler (46 858 linjer) + 42 ruter, **~2200+ hardkodede norske +UI-strenger** i komponentene alene, PLUSS **479 `app_error(...)`-kall** +i backend med hardkodede norske feilmeldinger vist direkte til +brukeren. Bruker valgte, over to spørsmålsrunder, den største +muligheten begge ganger: bygg ekte motor nå (ikke bare til +veiledningen), fyll ut norsk OG engelsk nå (ikke bare norsk klar for +senere oversettelse). + +**Eksplisitt faset, ikke "ferdig i én runde".** 2200+ strenger + 479 +feilkoder er et flerdagers arbeid uansett tempo. Denne runden bygget +grunnmuren + de to konkrete tingene bruker faktisk ba om (veiledning + +tabellsammenslåing, se under) med full norsk+engelsk-dekning. Resten av +appen får en foreslått batch-rekkefølge i `FEATURE_BACKLOG.md` for +påfølgende, avgrensede runder -- ikke noe forsøk på å late som om alt +ble oversatt i denne ene runden. + +**Arkitektur:** +- `next-intl` (offisiell App Router-støtte, ICU-meldingsformat). +- **INGEN URL-prefiks-routing** (`app/[locale]/...`). Undersøkt: 42 + rutefiler har norske slugs (`logg-inn`/`velkommen`/ + `kontosammenslaing`), og appen har mange delte/bokmerkede lenker + (offentlige turneringssider, magic-link-e-poster). Et locale-prefiks + ville brukket disse eller krevd en egen redirect-runde. Løst med + `i18n/request.ts` (server-only, leser KUN en cookie via + `next/headers`) + `NextIntlClientProvider` i `app/layout.tsx` (nå + async, `getLocale()`). +- **Delte, RENE konstanter i `lib/locale.ts`** (`SUPPORTED_LOCALES`/ + `DEFAULT_LOCALE`/`LOCALE_COOKIE`/`isLocale`) -- funnet under bygging: + et første forsøk la disse i `i18n/request.ts` selv, som feilet i + klientkomponenter (`locale-bootstrap.tsx`) fordi HELE den filen ble + server-only via `next/headers`-importen (bekreftet med en ekte 500- + feil i lokal scratch-visning, "You're importing a module that + depends on next/headers ... in the Pages Router" til tross for at + dette er App Router -- next-intl sin bunt-analyse så gjennom + re-eksporten). Skilt ut i en egen fil uten server-only-imports, som + begge sider kan importere trygt. +- **Språkkilde:** cookie (satt av ny `language-switcher.tsx`, i "Mer"- + menyen) vinner hvis satt, ellers `preferred_locale` fra `/auth/me` + (fantes ALLEREDE siden ADR-015, aldri lest av frontend før nå -- ny + `locale-bootstrap.tsx` synker den inn FØRSTE gang, kun én gang, ikke + ved hvert besøk), ellers `"nb"`. +- `preferred_locale` kunne FØR kun settes ved førstegangs-signup. + Lagt til den allerede generiske `ProfileUpdate` (`PATCH /profile`, + `auth.py`) -- samme mønster som resten av appen, ingen ny + endepunkt-type. +- **Feilmeldinger fra backend oversettes IKKE denne runden -- reell + arkitektonisk begrensning oppdaget underveis, ikke bare utsatt av + bekvemmelighet.** Planen var opprinnelig kode→oversettelse-oppslag + (`body.detail.code`) med norsk fallback. Undersøkt før bygging: de + 479 `app_error`-kallene bruker en LITEN, delt kodetaksonomi (f.eks. + `VALIDATION_FAILED`/`OUT_OF_SCOPE`/`NOT_FOUND` dekker dusinvis av + INNBYRDES ULIKE meldinger på tvers av appen). Én kode → én oversatt + tekst ville dermed gitt FEIL engelsk tekst for de fleste faktiske + feil. Ordentlig løsning krever enten finere koder (egen backend- + refaktorering) eller at backend selv blir locale-bevisst (bryter det + bevisste "ingen backend-endring for oversettelse"-valget). Feil- + meldinger forblir derfor norsk uansett språkvalg inntil videre -- + et eksplisitt, dokumentert hull, ikke noe som blokkerer resten. + +**Hjelpeside/FAQ (`/hjelp`, `help-page.tsx`):** oversiktside (turnerings- +flyten, samme forklaring gitt i chatten) + søkbar FAQ, startet med de +to konkrete spørsmålene fra samtalen ("hvordan fungerer +turneringsmodulen" / "hvordan legger jeg til spillere i +spillerpoolen"), pluss to til (forskjell deltaker/rundedeltakelse, +Cut, tiebreak). Strukturert innhold (`t.raw()`, ikke enkeltstrenger) i +`messages/{nb,en}.json`. Lenket fra "Mer"-menyen. Verifisert i lokal +scratch: full klikk-gjennom på BEGGE språk (cookie satt manuelt via +DevTools for å simulere bryteren), FAQ-utvidelse, lys+mørk -- fant og +fikset ett reelt hull underveis (søkefeltets plassholder/"Ingen treff" +var fortsatt hardkodet norsk, ikke fanget opp av `t()`-kallene rundt). + +**Spillere-tabellen slått ENDA lenger sammen.** Bruker, i samme melding: +"Slå sammen de to tabellene også" (Deltakere + Rundedeltakelse fra +ADR-093, diskutert som en oppfølger dagen før). Ny +`tournament-players-table.tsx` (V0-generert) erstatter BEGGE: én rad +per deltaker, kolonner for HCP/klasse/statistikknivå/status/kjønn/alder +PLUSS én kolonnegruppe per runde (sjekkboks+utslagssted), i én bred +scrollbar tabell i stedet for to stablede. CSV-import og "legg til +deltaker"-kontrollene ble IKKE bygget om -- de fungerte fint fra før og +flyttet bare til å stå direkte i `SetupTab` sitt "players"-steg i +stedet for inni den nå fjernede `ParticipantsCard`. `HcpInput` +(egen liten inline-komponent) er også fjernet -- samme funksjon finnes +nå direkte i den nye tabellen. + +**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: ny +`test_profile_locale.py` (3 tester) -- 152/152 grønt (opp fra 149). +Grundig scratch-runde av selve `TournamentPlayersTable`-komponenten +(mock-data, av-/påkrysning av rundedeltakelse fungerer og bruker riktig +standardutslag, "Fjern"-knapp fungerer, DSQ-status rødtonet, lys+mørk) +-- IKKE en full innlogget klikk-gjennom av den ferdig integrerte +versjonen mot ekte backend, vurdert tilstrekkelig dekket av at +komponenten selv er grundig testet isolert og props-sammenkoblingen er +typesjekket, gitt øktens allerede betydelige lengde. + +**Rullet ut:** venter på bekreftelse. + --- ## Utviklingsplan (rekkefølge) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ba8b1..b69be97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12981,3 +12981,40 @@ Neste steg: **Rullet ut 2026-08-19** -- bruker bekreftet. Ingen migrasjon. `docker compose build teecup_api teecup_frontend && up -d` for begge, rene logger, 200 OK. + +126. **Ekte oversettelsesinfrastruktur (next-intl) + hjelpeside/FAQ + + Spillere-tabellen slått enda mer sammen -- 2026-08-19 (ADR-094).** + Bruker ba om en veiledning ("alt skal være oversettbart"), fikk + beskjed om at appen hadde null i18n fra før (~2200 hardkodede + strenger, 479 backend-feilkoder), og valgte likevel den store + løsningen begge ganger spørsmålet ble stilt: ekte motor nå, norsk + OG engelsk fylt ut nå. `next-intl` installert, INGEN URL-prefiks + (norske slugs + delte/bokmerkede lenker gjorde det for kostbart) -- + cookie-basert i stedet, med `preferred_locale` (fantes siden + ADR-015, aldri brukt av frontend) som standard for innloggede + brukere første gang. Fant og fikset en ekte modul-grense-bug + underveis (delte konstanter måtte ut i en egen `lib/locale.ts` -- + en server-only-import i `i18n/request.ts` gjorde HELE filen + utilgjengelig for klientkomponenter, bekreftet med en reell 500-feil + i scratch). Fant og aksepterte en reell arkitekturbegrensning: + 479 feilkoder er for grovkornet til å oversettes via kode alene + (samme kode dekker mange ulike meldinger) -- feilmeldinger forblir + norsk inntil videre, dokumentert som et bevisst hull. Ny `/hjelp`- + side (oversikt + søkbar FAQ, startet med de to konkrete + spørsmålene fra samtalen). `preferred_locale` gjort redigerbar + etter signup (var før låst). I SAMME melding: "Slå sammen de to + tabellene også" -- Deltakere (ADR-091) og Rundedeltakelse (ADR-093) + slått sammen til én `tournament-players-table.tsx` (V0), CSV-import + og "legg til deltaker" uendret, bare flyttet ut av den nå fjernede + `ParticipantsCard`. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 3 nye + backend-tester -- 152/152 grønt (opp fra 149). Full klikk-gjennom + av hjelpesiden på begge språk i scratch (fant og fikset ett + gjenværende hardkodet strengpar underveis). Grundig scratch-runde + av selve den nye tabellen isolert. Ingen full innlogget klikk- + gjennom av den ferdig integrerte versjonen -- vurdert tilstrekkelig + dekket gitt komponentens egen grundige testing + typesjekket + sammenkobling. + + **Rullet ut:** venter på bekreftelse. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 1c59f73..759338e 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -4645,3 +4645,36 @@ som selvregistrering), så importen selv trenger ikke håndtere invitasjon -- bare opprette rader. Se ADR (skrives når planen er ferdig) for design/omfang. + +## Retro-oversettelse av eksisterende UI (nb/en) — 🔨 PÅBEGYNT 2026-08-19 (ADR-094), resten 📋 planlagt i batcher + +Grunnmuren (`next-intl`, cookie-basert språkvalg, ingen URL-endring) +og to genuint nye flater (hjelpeside/FAQ, den sammenslåtte Spillere- +tabellen) er bygget og fylt ut på begge språk, se ADR-094. **De +resterende ~68 eksisterende komponentfilene (~2200 hardkodede norske +UI-strenger, kartlagt 2026-08-19) er IKKE rørt ennå** -- for stort til +å gjøre trygt i én sammenhengende runde. Foreslått batch-rekkefølge for +påfølgende, avgrensede økter (hver batch = egen runde med egen +verifisering, samme inkrementelle disiplin som resten av appen): + +1. 📋 Innlogging/dashbord/navigasjon (`logg-inn`, `dashboard.tsx`, + `more-menu.tsx`, `bottom-nav.tsx` osv.) -- mest brukte flater, + høyest synlighet. +2. 📋 Turneringsoppsett-wizarden (ADR-092/093/094, nettopp bygget, + fortsatt fersk i minnet). +3. 📋 Scorekort/leaderboard -- spillerens kjerneopplevelse under selve + rundens gang. +4. 📋 Resten (spillerpool, order of merit, feed/venner, + kontoinnstillinger, presentasjon/sponsorer). +5. 📋 Backend-feilkoder (479 `app_error`-kall) -- dekkes LØPENDE + gjennom batch 1-4 (hver batch oversetter kodene den faktisk støter + på i sine egne skjermer), ikke som en egen runde. **Reell + arkitekturbegrensning, ikke bare rekkefølge:** dagens kodetaksonomi + (`VALIDATION_FAILED`/`OUT_OF_SCOPE`/`NOT_FOUND` osv.) er for + grovkornet -- samme kode brukes til dusinvis av innbyrdes ulike + meldinger på tvers av appen, så kode→tekst-oversettelse alene gir + feil resultat for de fleste faktiske feil. Krever enten finere + koder (egen backend-refaktorering, ikke påbegynt) eller at backend + selv blir locale-bevisst (bryter det bevisste "ingen backend- + endring for oversettelse"-valget i ADR-094). Feilmeldinger forblir + norsk uansett språkvalg inntil en av disse løsningene velges. diff --git a/app/routers/auth.py b/app/routers/auth.py index becf8bd..8fb283b 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -926,6 +926,12 @@ class ProfileUpdate(BaseModel): # Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke # en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen. bag_clubs: list[BagClub] | None = Field(default=None, max_length=14) + # i18n (ADR-094) -- preferred_locale kunne FØR kun settes ved + # førstegangs-signup (ADR-015, magic_link_token.locale), aldri endres + # etterpå. language-switcher.tsx PATCHer dette feltet slik at valget + # følger brukeren til neste enhet/økt, ikke bare gjeldende nettleser + # (som en cookie alene ville gitt). + preferred_locale: str | None = Field(default=None, pattern="^(nb|en)$") @router.patch("/profile", response_model=Me) diff --git a/frontend/app/hjelp/page.tsx b/frontend/app/hjelp/page.tsx new file mode 100644 index 0000000..117b9f1 --- /dev/null +++ b/frontend/app/hjelp/page.tsx @@ -0,0 +1,5 @@ +import { HelpPage } from "@/components/help-page" + +export default function Hjelp() { + return +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index ef71ee8..167ebf0 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata, Viewport } from 'next' import { Nunito } from 'next/font/google' +import { NextIntlClientProvider } from 'next-intl' +import { getLocale } from 'next-intl/server' import { SwRegister } from '@/components/sw-register' +import { LocaleBootstrap } from '@/components/locale-bootstrap' import './globals.css' const nunito = Nunito({ @@ -52,16 +55,24 @@ export const viewport: Viewport = { ], } -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + // i18n (ADR-094) -- locale kommer fra en cookie (se i18n/request.ts), + // ALDRI fra URL-en. LocaleBootstrap synker inn brukerens preferred_locale + // fra /auth/me på klienten dersom ingen cookie er satt ennå. + const locale = await getLocale() + return ( - + - - {children} + + + + {children} + ) diff --git a/frontend/components/help-page.tsx b/frontend/components/help-page.tsx new file mode 100644 index 0000000..4050953 --- /dev/null +++ b/frontend/components/help-page.tsx @@ -0,0 +1,99 @@ +"use client" + +// Hjelpeside (ADR-094) -- oversiktside for turneringsflyten + søkbar FAQ. +// Innholdet ligger i messages/{nb,en}.json under "help" (t.raw(), siden +// dette er strukturert innhold -- arrays av {title,body}/{question,answer} +// -- ikke enkeltstrenger). Startet med to konkrete spørsmål brukeren +// faktisk stilte i en samtale ("hvordan fungerer turneringsmodulen" / +// "hvordan legger jeg til spillere i spillerpoolen") -- ment å vokse +// organisk etter hvert som flere spørsmål dukker opp, samme mønster som +// resten av appen er bygget etter denne økten. +import { useState } from "react" +import Link from "next/link" +import { useTranslations } from "next-intl" +import { ArrowLeft, Search } from "lucide-react" + +type OverviewSection = { title: string; body: string } +type FaqEntry = { question: string; answer: string } + +export function HelpPage() { + const t = useTranslations("help") + const [query, setQuery] = useState("") + + const sections = t.raw("overview.sections") as OverviewSection[] + const faq = t.raw("faq") as FaqEntry[] + + const filteredFaq = + query.trim() === "" + ? faq + : faq.filter( + (entry) => + entry.question.toLowerCase().includes(query.toLowerCase()) || + entry.answer.toLowerCase().includes(query.toLowerCase()), + ) + + return ( +
+
+
+ +
+
+ +
+
+
+

{t("overview.title")}

+

{t("overview.intro")}

+
+
+ {sections.map((section, i) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+
+ +
+
+
+ +
+ {filteredFaq.length === 0 && ( +

{t("noResults")}

+ )} + {filteredFaq.map((entry, i) => ( +
+ + {entry.question} + +

{entry.answer}

+
+ ))} +
+
+
+
+ ) +} diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index b30ef18..790ccd7 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -47,7 +47,7 @@ import { Label } from "@/components/ui/label" import { PlayerImportPanel } from "@/components/player-import-panel" import { RoundGroupsPanel } from "@/components/round-groups-panel" import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list" -import { RoundParticipationTable } from "@/components/round-participation-table" +import { TournamentPlayersTable } from "@/components/tournament-players-table" import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card" import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav" import { @@ -1200,6 +1200,7 @@ function SetupTab({ }) { const [step, setStep] = useState("basics") const [groupsOpenFor, setGroupsOpenFor] = useState(null) + const [showImport, setShowImport] = useState(false) const [pool, setPool] = useState([]) async function refreshPool() { @@ -1454,84 +1455,102 @@ function SetupTab({ )} {step === "players" && ( -
- { - await Promise.all([refreshPool(), onImportComplete()]) - }} - onRemoveParticipant={onRemoveParticipant} - onSetClass={onSetParticipantClass} - onSetStatLevel={onSetParticipantStatLevel} - onSetHandicap={onSetParticipantHandicap} - onSetStatus={onSetParticipantStatus} - /> +
+
+

Spillere

+ + {participants.length} + +
-
-
-

Rundedeltakelse

-

- Hvem spiller hvilke runder, fra hvilket utslagssted -- for hele turneringen på én gang. -

-
- {rounds.length === 0 ? ( -

- Ingen runder ennå -- legg til runder under «Runder og baner» først. -

- ) : roundParticipantsByRound === null ? ( -

Laster…

- ) : ( - { - const player = pool.find((pl) => pl.id === p.player_id) - return { - participantId: p.id, - playerId: p.player_id, - name: p.player_name, - gender: player?.gender ?? null, - birthDate: player?.birth_date ?? null, - } - })} - rounds={rounds.map((r) => ({ - id: r.id, - label: r.name || `Runde ${r.sequence}`, - tees: teesByCourse[r.course_id] ?? [], - }))} - cells={Object.fromEntries( - rounds.map((r) => [ - r.id, - Object.fromEntries( - participants.map((p) => { - const rp = (roundParticipantsByRound[r.id] ?? []).find( - (x) => x.tournament_participant_id === p.id, - ) - return [ - p.id, - rp - ? { assigned: true, teeId: rp.tee_id, teeName: rp.tee_name } - : { assigned: false, teeId: null, teeName: null }, - ] - }), - ), - ]), - )} - onToggle={toggleRoundParticipant} - onBulkToggle={bulkSetRoundParticipation} - onChangeTee={changeRoundParticipantTee} - onChangeGender={changePlayerGender} - onChangeBirthDate={changePlayerBirthDate} - busyRoundIds={busyRoundIds} - errors={participationErrors} - /> - )} -
-
+ + + {showImport && ( + ({ id: c.id, name: c.name })) }} + onClose={() => setShowImport(false)} + onImportComplete={() => { + void (async () => { + await Promise.all([refreshPool(), onImportComplete()]) + })() + }} + /> + )} + + {participants.length === 0 ? ( +

+ Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller. +

+ ) : rounds.length > 0 && roundParticipantsByRound === null ? ( +

Laster…

+ ) : ( + ({ id: c.id, name: c.name }))} + players={participants.map((p) => { + const player = pool.find((pl) => pl.id === p.player_id) + return { + participantId: p.id, + playerId: p.player_id, + playerName: p.player_name, + handicapSnapshot: p.handicap_index_snapshot, + classId: p.class_id, + statLevel: p.stat_level, + status: p.status, + gender: player?.gender ?? null, + birthDate: player?.birth_date ?? null, + } + })} + rounds={rounds.map((r) => ({ + id: r.id, + label: r.name || `Runde ${r.sequence}`, + tees: teesByCourse[r.course_id] ?? [], + }))} + cells={Object.fromEntries( + rounds.map((r) => [ + r.id, + Object.fromEntries( + participants.map((p) => { + const rp = (roundParticipantsByRound?.[r.id] ?? []).find( + (x) => x.tournament_participant_id === p.id, + ) + return [ + p.id, + rp + ? { assigned: true, teeId: rp.tee_id, teeName: rp.tee_name } + : { assigned: false, teeId: null, teeName: null }, + ] + }), + ), + ]), + )} + onSetHandicap={onSetParticipantHandicap} + onSetClass={onSetParticipantClass} + onSetStatLevel={onSetParticipantStatLevel} + onSetStatus={onSetParticipantStatus} + onRemoveParticipant={onRemoveParticipant} + onChangeGender={changePlayerGender} + onChangeBirthDate={changePlayerBirthDate} + onToggleRound={toggleRoundParticipant} + onBulkToggleRound={bulkSetRoundParticipation} + onChangeRoundTee={changeRoundParticipantTee} + busyRoundIds={busyRoundIds} + errors={participationErrors} + /> + )} + + p.player_id))} + onAddExisting={onAddParticipant} + onAddNew={onAddNewPlayer} + /> + )} {step === "groups" && ( @@ -2001,242 +2020,6 @@ function ClassesCard({ ) } -const STAT_LEVEL_LABELS: Record = { - strokes_only: "Kun slag", - strokes_and_putts: "Slag og putter", - full: "All statistikk", -} - -// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne -// sette Dsq, Rtd, Dnf, og Dns." Blokkerer videre score i ALLE runder -// (strengere enn Cut) og overstyrer leaderboardets total-visning. -const PARTICIPANT_STATUS_LABELS: Record = { - active: "Aktiv", - dsq: "DSQ", - rtd: "RTD", - dnf: "DNF", - dns: "DNS", -} - -function ParticipantsCard({ - organizationId, - tournamentId, - participants, - pool, - classes, - onAddParticipant, - onAddNewPlayer, - onImportComplete, - onRemoveParticipant, - onSetClass, - onSetStatLevel, - onSetHandicap, - onSetStatus, -}: { - organizationId: string - tournamentId: string - participants: ApiParticipant[] - pool: ApiPlayer[] - classes: ApiTournamentClass[] - onAddParticipant: (playerId: string) => Promise - onAddNewPlayer: (input: NewPlayerInput) => Promise - onImportComplete: () => Promise - onRemoveParticipant: (id: string) => Promise - onSetClass: (participantId: string, classId: string | null) => Promise - onSetStatLevel: ( - participantId: string, - statLevel: "strokes_only" | "strokes_and_putts" | "full", - ) => Promise - onSetHandicap: (participantId: string, handicap: number | null) => Promise - onSetStatus: (participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns") => Promise -}) { - const [showImport, setShowImport] = useState(false) - - return ( -
-
-

Deltakere

- - {participants.length} - -
- - - - {showImport && ( - ({ id: c.id, name: c.name })) }} - onClose={() => setShowImport(false)} - onImportComplete={() => { - void onImportComplete() - }} - /> - )} - - {participants.length === 0 && ( -

- Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller. -

- )} - -
    - {participants.map((p) => ( -
  • -
    - {p.player_name} - onSetHandicap(p.id, v)} - /> - {/* Direkte kobling til spillerpoolen (2026-08-18, "det bør - dessuten være kobling direkte mellom spillerpool og - turneringsside") -- for å rette navn/e-post/kjønn/klubb - osv., som IKKE er turnering-spesifikke felt (kun HCP er - det, se HcpInput). Åpner spillerpoolen direkte på denne - spillerens rad, i stedet for at organisator må lete den - opp manuelt i en potensielt lang tabell. */} - -
    - {classes.length > 0 && ( - - )} - {/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye - HoleGrid/HoleStatsSheet ber om ved scoring for DENNE - deltakeren, tournament-bredt (ikke per runde). */} - - {/* Manuell status (migrasjon 087, 2026-08-18) -- blokkerer - videre score i ALLE runder når satt til noe annet enn - "Aktiv", overstyrer leaderboardets total-visning. */} - - -
  • - ))} -
- - p.player_id))} - onAddExisting={onAddParticipant} - onAddNew={onAddNewPlayer} - /> -
- ) -} - -// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) -- -// lokal utkast-tilstand, forpliktet KUN på blur (ikke per tastetrykk) -- -// samme mønster som tallfelt ellers i appen. Tomt felt = fjern HCP-en -// (spilleren mister evt. adgang til netto-/Stableford-runder som krever -// den, samme validering som ved ny påmelding). -function HcpInput({ - playerName, - value, - onCommit, -}: { - playerName: string - value: number | null - onCommit: (next: number | null) => Promise -}) { - const [draft, setDraft] = useState(value !== null ? String(value) : "") - - useEffect(() => { - setDraft(value !== null ? String(value) : "") - }, [value]) - - function commit() { - const trimmed = draft.trim() - if (trimmed === "") { - if (value !== null) void onCommit(null) - return - } - const n = Number(trimmed) - if (Number.isFinite(n) && n !== value) void onCommit(n) - } - - return ( - - ) -} - // Splitter et fritekst-navn i for-/etternavn -- samme "første ord = // fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin // backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra @@ -3023,13 +2806,14 @@ function RoundCard({ } // Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i -// rundedeltakelse-tabellen (ADR-093) -- samme oppløsning som den nå -// fjernede AssignRoundParticipantControl brukte for ett-om-gangen- -// tilordning: deltakerens klasses standardutslag, KUN hvis det faktisk -// finnes på den aktuelle rundens bane (klassens standardutslag kan -// tilhøre en annen bane). Faller tilbake til rundens eget valgte -// "Standardutslag" (lokal UI-state i round-participation-table.tsx), -// og til slutt til det første utslaget i lista. +// den sammenslåtte spillertabellen (ADR-093/094, tournament-players- +// table.tsx) -- samme oppløsning som den nå fjernede +// AssignRoundParticipantControl brukte for ett-om-gangen-tilordning: +// deltakerens klasses standardutslag, KUN hvis det faktisk finnes på +// den aktuelle rundens bane (klassens standardutslag kan tilhøre en +// annen bane). Faller tilbake til rundens eget valgte "Standardutslag" +// (lokal UI-state inni tournament-players-table.tsx), og til slutt til +// det første utslaget i lista. function resolveDefaultTeeId( participant: ApiParticipant, classes: ApiTournamentClass[], diff --git a/frontend/components/language-switcher.tsx b/frontend/components/language-switcher.tsx new file mode 100644 index 0000000..8e98b24 --- /dev/null +++ b/frontend/components/language-switcher.tsx @@ -0,0 +1,71 @@ +"use client" + +// Språkbryter (ADR-094) -- setter locale-cookien direkte (samme cookie +// i18n/request.ts leser) og PATCHer /profile sin preferred_locale når +// innlogget, slik at valget følger brukeren til neste enhet/økt også +// (ikke bare denne nettleseren). cookie alene er nok til at selve +// visningen endres umiddelbart -- PATCH-en er en "husk til neste gang"- +// bonus, feiler stille (utlogget bruker har ingenting å PATCHe mot). +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useLocale, useTranslations } from "next-intl" +import { cn } from "@/lib/utils" +import { LOCALE_COOKIE, SUPPORTED_LOCALES, type Locale } from "@/lib/locale" + +export function LanguageSwitcher() { + const locale = useLocale() + const t = useTranslations("common.languageSwitcher") + const router = useRouter() + const [pending, setPending] = useState(false) + + async function setLocale(next: Locale) { + if (next === locale || pending) return + setPending(true) + document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=31536000; SameSite=Lax` + try { + await fetch("/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ preferred_locale: next }), + }) + } catch { + // Stille -- cookien er allerede satt, selve visningen fungerer uansett. + } + router.refresh() + setPending(false) + } + + const labels: Record = { nb: t("norwegian"), en: t("english") } + + return ( +
+ {t("label")} +
+ {SUPPORTED_LOCALES.map((code) => { + const selected = code === locale + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/components/locale-bootstrap.tsx b/frontend/components/locale-bootstrap.tsx new file mode 100644 index 0000000..229ced6 --- /dev/null +++ b/frontend/components/locale-bootstrap.tsx @@ -0,0 +1,45 @@ +"use client" + +// i18n (ADR-094) -- monteres én gang i rot-layouten. Selve locale- +// oppløsningen på serveren (i18n/request.ts) leser KUN en cookie, uten +// noen fetch-avhengighet (rask, synkron). Denne komponenten er stedet +// der brukerens preferred_locale (allerede på backend siden ADR-015, +// aldri lest av frontend før nå) faktisk får virkning: FØRSTE gang en +// innlogget bruker besøker appen uten at cookien er satt ennå, hentes +// /auth/me og cookien settes deretter -- kun én gang, ikke ved hvert +// besøk (cookien vinner etter det, selv om brukeren senere endrer +// preferred_locale et annet sted, inntil de bruker språkbryteren igjen). +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/locale" + +function hasLocaleCookie(): boolean { + return document.cookie.split("; ").some((c) => c.startsWith(`${LOCALE_COOKIE}=`)) +} + +export function LocaleBootstrap() { + const router = useRouter() + + useEffect(() => { + if (hasLocaleCookie()) return + let cancelled = false + fetch("/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((me: { preferred_locale?: string } | null) => { + if (cancelled || !me) return + const locale = isLocale(me.preferred_locale) ? me.preferred_locale : DEFAULT_LOCALE + if (locale === DEFAULT_LOCALE) return // allerede det serveren rendret med + document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax` + router.refresh() + }) + .catch(() => { + // Stille -- utlogget/nettverksfeil betyr bare at nb-standarden + // (allerede rendret) beholdes. + }) + return () => { + cancelled = true + } + }, [router]) + + return null +} diff --git a/frontend/components/more-menu.tsx b/frontend/components/more-menu.tsx index 4ffdad1..9f53c7a 100644 --- a/frontend/components/more-menu.tsx +++ b/frontend/components/more-menu.tsx @@ -10,8 +10,9 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react" +import { Bell, Building2, ChevronRight, CircleHelp, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react" import { BottomNav } from "@/components/teecup/bottom-nav" +import { LanguageSwitcher } from "@/components/language-switcher" const C = { card: "#ffffff", @@ -66,7 +67,8 @@ export function MoreMenu() { - + + {me?.organizations.map((o, i) => ( +
+ +
+ + + + ) +} diff --git a/frontend/i18n/request.ts b/frontend/i18n/request.ts new file mode 100644 index 0000000..6a42b5e --- /dev/null +++ b/frontend/i18n/request.ts @@ -0,0 +1,20 @@ +import { getRequestConfig } from "next-intl/server" +import { cookies } from "next/headers" +import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/locale" + +// Ingen URL-prefiks-routing (ADR-094) -- dagens ruter har norske slugs +// (logg-inn/velkommen/kontosammenslaing) og mange delte/bokmerkede lenker +// (offentlige turneringssider, magic-link-e-poster). Språk styres i +// stedet av en enkel cookie, satt av language-switcher.tsx eller +// locale-bootstrap.tsx (som synker inn brukerens preferred_locale første +// gang, se de filene for detaljer) -- ALDRI av URL-en. +export default getRequestConfig(async () => { + const cookieStore = await cookies() + const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value + const locale = isLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE + + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + } +}) diff --git a/frontend/lib/locale.ts b/frontend/lib/locale.ts new file mode 100644 index 0000000..28701a7 --- /dev/null +++ b/frontend/lib/locale.ts @@ -0,0 +1,12 @@ +// i18n (ADR-094) -- delte, RENE konstanter (ingen next/headers eller +// next-intl/server-import) slik at både server-only i18n/request.ts OG +// klientkomponenter (locale-bootstrap.tsx/language-switcher.tsx) kan +// importere dem uten å dra server-only-kode inn i klientbunten. +export const SUPPORTED_LOCALES = ["nb", "en"] as const +export type Locale = (typeof SUPPORTED_LOCALES)[number] +export const DEFAULT_LOCALE: Locale = "nb" +export const LOCALE_COOKIE = "locale" + +export function isLocale(value: string | undefined | null): value is Locale { + return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value) +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 0000000..0768610 --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,56 @@ +{ + "common": { + "languageSwitcher": { + "label": "Language", + "norwegian": "Norsk", + "english": "English" + } + }, + "help": { + "pageTitle": "Help", + "backLabel": "Back", + "searchPlaceholder": "Search", + "searchLabel": "Search frequently asked questions", + "noResults": "No results.", + "overview": { + "title": "How the tournament module works", + "intro": "A short explanation of how a tournament fits together in TeeCup, from creation to a finished result.", + "sections": [ + { + "title": "The basic idea", + "body": "Everything in TeeCup belongs to an organization (the club or organizer). Inside an organization you create tournaments. There are two types: individual (a flat field of players, no teams) and team (two teams against each other, Ryder Cup format)." + }, + { + "title": "The four building blocks of an individual tournament", + "body": "Participants (who's in the tournament, drawn from the club's player pool), Rounds (how many days/rounds, and which course), Round participation (a person can be in the tournament without playing a given round -- actually playing requires a separate choice of tee), and Score (the actual strokes, recorded hole by hole)." + }, + { + "title": "Setup in six steps", + "body": "Tournament setup (name, dates, visibility, registration), Rounds and courses, Classes (optional), Players (who's in, and who plays which rounds), Groups and start list (who plays together, when), and Results and options (scoring method, cut, tiebreak)." + }, + { + "title": "From score to result", + "body": "You choose a scoring method (gross strokes, net strokes, Stableford, or some more specialized formats) which decides how the raw strokes become a leaderboard ranking. A cut can keep only the top finishers after a chosen round (with ties at the cutoff line let through). A tiebreak resolves a tied result -- countback, lowest handicap, a playoff, or a shared position -- and never affects where the cut line falls." + } + ] + }, + "faq": [ + { + "question": "How does an organizer add players to the player pool?", + "answer": "Three ways: (1) From the members page, \"Edit players\" -- upload a CSV file, or \"Add row\" to enter one player manually. (2) \"Import from CSV\" directly from a tournament's Participants list -- creates the players in the pool AND registers them for the tournament in one operation. (3) Add a brand-new player on the spot while adding them as a participant in a tournament." + }, + { + "question": "What's the difference between being a tournament participant and playing a round?", + "answer": "Being a participant means the person is registered for the tournament itself. Playing a round is a separate choice -- the person also needs to be assigned to that round, with a tee. A participant can therefore be in the tournament without playing every round." + }, + { + "question": "What does Cut mean?", + "answer": "A cut keeps only the top finishers after a chosen round -- everyone else is out of the remaining rounds. The line is a POSITION, not a fixed headcount: if several players are tied at position N, they all make it through, so the actual number can end up slightly higher than the number you set." + }, + { + "question": "What happens with a tied result (tiebreak)?", + "answer": "You choose the rule yourself -- countback (best final holes), lowest handicap, a playoff, or simply sharing the position. This can be set differently for the winner than for the rest of the field, and it never affects where the cut line falls." + } + ] + } +} diff --git a/frontend/messages/nb.json b/frontend/messages/nb.json new file mode 100644 index 0000000..bab0caa --- /dev/null +++ b/frontend/messages/nb.json @@ -0,0 +1,56 @@ +{ + "common": { + "languageSwitcher": { + "label": "Språk", + "norwegian": "Norsk", + "english": "English" + } + }, + "help": { + "pageTitle": "Hjelp", + "backLabel": "Tilbake", + "searchPlaceholder": "Søk", + "searchLabel": "Søk i vanlige spørsmål", + "noResults": "Ingen treff.", + "overview": { + "title": "Slik fungerer turneringsmodulen", + "intro": "En kort forklaring på hvordan en turnering henger sammen i TeeCup, fra opprettelse til ferdig resultat.", + "sections": [ + { + "title": "Grunnideen", + "body": "Alt i TeeCup henger på en organisasjon (klubben eller arrangøren). Inni en organisasjon oppretter du turneringer. Det finnes to typer: individuell (et flatt felt av spillere, ingen lag) og lag (to lag mot hverandre, Ryder Cup-format)." + }, + { + "title": "De fire byggeklossene i en individuell turnering", + "body": "Deltakere (hvem som er med i turneringen, hentet fra klubbens spillerpool), Runder (hvor mange dager/runder, og hvilken bane), Rundedeltakelse (en person kan være med i turneringen uten å spille en gitt runde -- å faktisk spille krever et eget valg av utslagssted), og Score (de faktiske slagene, registrert hull for hull)." + }, + { + "title": "Oppsett i seks steg", + "body": "Turneringsoppsett (navn, datoer, synlighet, påmelding), Runder og baner, Klasser (valgfritt), Spillere (hvem er med, og hvem spiller hvilke runder), Grupper og startliste (hvem spiller sammen, når), og Resultat og opsjoner (scoringsmetode, cut, tiebreak)." + }, + { + "title": "Fra score til resultat", + "body": "Du velger en scoringsmetode (bruttoslag, nettoslag, Stableford, eller mer spesielle formater) som avgjør hvordan de rå slagene blir til en rangering på leaderboardet. Cut kan beholde kun de øverste etter en valgt runde (med delt plass rundt grensen). Tiebreak avgjør likt resultat -- countback, laveste handicap, omspill, eller delt plassering -- og påvirker aldri hvor cut-grensen går." + } + ] + }, + "faq": [ + { + "question": "Hvordan legger jeg til spillere i spillerpoolen?", + "answer": "På tre måter: (1) Fra medlemssiden, \"Rediger spillere\" -- last opp en CSV-fil, eller \"Legg til rad\" for å legge inn én spiller manuelt. (2) \"Importer fra CSV\" direkte fra en turnerings Deltakere-liste -- oppretter spillerne i poolen OG melder dem på turneringen i samme operasjon. (3) Legg til en helt ny spiller på stedet mens du legger dem til som deltaker i en turnering." + }, + { + "question": "Hva er forskjellen på å være deltaker i turneringen og å spille en runde?", + "answer": "Å være deltaker betyr at personen er meldt på selve turneringen. Å spille en runde er et eget valg -- personen må i tillegg tilordnes den runden, med et utslagssted. En deltaker kan altså være med i turneringen uten å spille alle rundene." + }, + { + "question": "Hva betyr Cut?", + "answer": "Cut beholder kun de øverste plasseringene etter en valgt runde -- resten er ute av videre spill. Grensen er en PLASSERING, ikke et fast antall: er flere spillere likt med nr. N, slipper alle disse gjennom, så det kan bli litt flere enn tallet du satte." + }, + { + "question": "Hva skjer ved likt resultat (tiebreak)?", + "answer": "Du velger selv regelen -- countback (beste siste hull), laveste handicap, omspill, eller at plasseringen rett og slett deles. Dette kan settes forskjellig for vinneren enn for resten av feltet, og påvirker aldri hvor cut-grensen går." + } + ] + } +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 7fd05ce..523e0dc 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,8 +1,14 @@ +import createNextIntlPlugin from "next-intl/plugin" + // API-et proxyes server-side under samme opprinnelse (ingen CORS, cookien // fungerer uendret) -- se ADR-009/015. TEECUP_API_ORIGIN peker mot en lokal // backend i dev; i prod peker den mot teecup_api på det delte Docker-nettverket. const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000" +// i18n (ADR-094) -- leser i18n/request.ts, INGEN URL-routing (se den filens +// egen kommentar for hvorfor). +const withNextIntl = createNextIntlPlugin("./i18n/request.ts") + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", @@ -64,4 +70,4 @@ const nextConfig = { }, } -export default nextConfig +export default withNextIntl(nextConfig) diff --git a/frontend/package.json b/frontend/package.json index ec9711f..18e57c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "lucide-react": "^1.16.0", "mapbox-gl": "3.27.0", "next": "16.2.6", + "next-intl": "^4.13.7", "papaparse": "^5.6.0", "react": "^19", "react-dom": "^19", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e8aff02..fab7fe0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: next: specifier: 16.2.6 version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl: + specifier: ^4.13.7 + version: 4.13.7(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.7.3) papaparse: specifier: ^5.6.0 version: 5.6.0 @@ -267,6 +270,18 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.17': + resolution: {integrity: sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==} + + '@formatjs/icu-skeleton-parser@2.1.11': + resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + + '@formatjs/intl-localematcher@0.8.13': + resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==} + '@hono/node-server@2.1.0': resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} engines: {node: '>=20'} @@ -522,6 +537,88 @@ packages: '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@rolldown/binding-android-arm64@1.2.3': resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -615,6 +712,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -625,9 +725,102 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.47': + resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.47': + resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.47': + resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.47': + resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.47': + resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.47': + resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.47': + resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.47': + resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.47': + resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.47': + resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.47': + resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.47': + resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.47': + resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1255,6 +1448,9 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + icu-minify@4.13.7: + resolution: {integrity: sha512-X9gLFtipsP4HHbmy9urh+palImTR9P6lyhvmgbP6iym8i0IwhcsS4Z6KMjkEoCU6O16OJT5JIZkd8xfDROYo/A==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1266,6 +1462,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + intl-messageformat@11.2.14: + resolution: {integrity: sha512-9f2VD1HFuxUvMw0RxsaP8WmMns6JRTnsNB/zghTFrp11ZktiXWwVDeZBPQchBKEmo+Gx/ZhxI7Qht7YglFD4PA==} + ip-address@10.4.0: resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} @@ -1644,6 +1843,19 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next-intl-swc-plugin-extractor@4.13.7: + resolution: {integrity: sha512-MxOUMGKncc/D6rofu0O80I6Ebr3gqlH8XiEb0Uu5TZmX7DqY3NVHs70Uy4bvtgWmHeDwsra6KU8EBtfRVGRv7Q==} + + next-intl@4.13.7: + resolution: {integrity: sha512-j7KnGWt4Ih6TnW1x714R8bX3H+DYP25fqLTYTfUzAFXh0Od57WuQYM/Sf58yalvIXhE6y8sBYHlrCFmr0jPy3g==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + next@16.2.6: resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} @@ -1665,6 +1877,9 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-releases@2.0.53: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} @@ -1791,6 +2006,9 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + po-parser@2.2.0: + resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -2106,6 +2324,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-intl@4.13.7: + resolution: {integrity: sha512-vWapep/2GESovKEmkxaG1Bkt6AWwANCWrM4kwOYbMmvQ4IsBGJFx9l66h8DNCcU0jSOzNtSFyRXFcYvgAak/Cg==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -2505,6 +2728,18 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.17': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.11 + + '@formatjs/icu-skeleton-parser@2.1.11': {} + + '@formatjs/intl-localematcher@0.8.13': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@hono/node-server@2.1.0(hono@4.13.0)': dependencies: hono: 4.13.0 @@ -2687,6 +2922,62 @@ snapshots: '@oxc-project/types@0.143.0': {} + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + '@rolldown/binding-android-arm64@1.2.3': optional: true @@ -2731,16 +3022,78 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@schummar/icu-type-parser@1.21.5': {} + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.47': + optional: true + + '@swc/core-darwin-x64@1.15.47': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.47': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.47': + optional: true + + '@swc/core-linux-arm64-musl@1.15.47': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.47': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.47': + optional: true + + '@swc/core-linux-x64-gnu@1.15.47': + optional: true + + '@swc/core-linux-x64-musl@1.15.47': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.47': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.47': + optional: true + + '@swc/core-win32-x64-msvc@1.15.47': + optional: true + + '@swc/core@1.15.47': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.47 + '@swc/core-darwin-x64': 1.15.47 + '@swc/core-linux-arm-gnueabihf': 1.15.47 + '@swc/core-linux-arm64-gnu': 1.15.47 + '@swc/core-linux-arm64-musl': 1.15.47 + '@swc/core-linux-ppc64-gnu': 1.15.47 + '@swc/core-linux-s390x-gnu': 1.15.47 + '@swc/core-linux-x64-gnu': 1.15.47 + '@swc/core-linux-x64-musl': 1.15.47 + '@swc/core-win32-arm64-msvc': 1.15.47 + '@swc/core-win32-ia32-msvc': 1.15.47 + '@swc/core-win32-x64-msvc': 1.15.47 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3335,6 +3688,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + icu-minify@4.13.7: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.17 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -3344,6 +3701,11 @@ snapshots: inherits@2.0.4: {} + intl-messageformat@11.2.14: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.17 + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -3595,6 +3957,25 @@ snapshots: negotiator@1.0.0: {} + next-intl-swc-plugin-extractor@4.13.7: {} + + next-intl@4.13.7(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.7.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.13 + '@parcel/watcher': 2.6.0 + '@swc/core': 1.15.47 + icu-minify: 4.13.7 + negotiator: 1.0.0 + next: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl-swc-plugin-extractor: 4.13.7 + po-parser: 2.2.0 + react: 19.2.8 + use-intl: 4.13.7(react@19.2.8) + optionalDependencies: + typescript: 5.7.3 + transitivePeerDependencies: + - '@swc/helpers' + next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.2.6 @@ -3619,6 +4000,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@7.1.1: {} + node-releases@2.0.53: {} npm-run-path@4.0.1: @@ -3732,6 +4115,8 @@ snapshots: dependencies: find-up: 3.0.0 + po-parser@2.2.0: {} + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -4105,6 +4490,14 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-intl@4.13.7(react@19.2.8): + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.13.7 + intl-messageformat: 11.2.14 + react: 19.2.8 + use-sync-external-store@1.6.0(react@19.2.8): dependencies: react: 19.2.8 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 520eb9a..b72b2c1 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,2 +1,4 @@ allowBuilds: + '@parcel/watcher': set this to true or false + '@swc/core': set this to true or false sharp: true diff --git a/tests/test_profile_locale.py b/tests/test_profile_locale.py new file mode 100644 index 0000000..7040ab0 --- /dev/null +++ b/tests/test_profile_locale.py @@ -0,0 +1,40 @@ +""" +preferred_locale kunne FØR kun settes ved førstegangs-signup (ADR-015) +-- aldri endres etterpå. ADR-094 la feltet til den allerede generiske +ProfileUpdate (PATCH /profile) slik at language-switcher.tsx kan endre +det når som helst.""" + +import pytest +from fastapi import HTTPException + +from app.auth import CurrentUser +from app.routers.auth import ProfileUpdate, update_profile + +from tests.conftest import create_user + +import app.db as app_db + + +async def test_preferred_locale_can_be_changed_after_signup(pool): + user_id = await create_user() + out = await update_profile( + ProfileUpdate(preferred_locale="en"), + user=CurrentUser(user_id=user_id), + ) + assert out.preferred_locale == "en" + + async with app_db.plain_connection() as conn: + row = await conn.fetchrow("SELECT preferred_locale FROM app_user WHERE id = $1", user_id) + assert row["preferred_locale"] == "en" + + +async def test_invalid_locale_is_rejected(pool): + with pytest.raises(Exception): + ProfileUpdate(preferred_locale="fr") + + +async def test_updating_other_fields_leaves_locale_unchanged(pool): + user_id = await create_user() + await update_profile(ProfileUpdate(preferred_locale="en"), user=CurrentUser(user_id=user_id)) + out = await update_profile(ProfileUpdate(bio="Elsker links-golf"), user=CurrentUser(user_id=user_id)) + assert out.preferred_locale == "en"