Update Todos
Skriv migrasjon 019 (app_user.country + bio) Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx Scratch-verifiser hele flyten Be om bekreftelse og rull ut live Scratch-verifisert, alt grønt: Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret. 16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer. Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet. Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account. Klar for ekte teecup_db. Dette krever: Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt). docker compose up -d --build teecup_api teecup_frontend. Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
This commit is contained in:
parent
0dd1ccde8d
commit
79c4406723
6 changed files with 345 additions and 5 deletions
|
|
@ -341,7 +341,8 @@
|
||||||
"Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)",
|
"Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)",
|
||||||
"Bash(rm -f /opt/requirements.txt)",
|
"Bash(rm -f /opt/requirements.txt)",
|
||||||
"Bash(python3 -m py_compile app/routers/*.py app/*.py)",
|
"Bash(python3 -m py_compile app/routers/*.py app/*.py)",
|
||||||
"Bash(python3 -m py_compile app/routers/auth.py app/email.py)"
|
"Bash(python3 -m py_compile app/routers/auth.py app/email.py)",
|
||||||
|
"Bash(sort -t_ -k1 -n)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
18
019_profile_country_bio.sql
Normal file
18
019_profile_country_bio.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — land + beskrivelse på personlig profil (migrasjon 019)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Oppfølging av ADR-031 sin personlige profil (migrasjon 015): brukeren
|
||||||
|
-- ba om et obligatorisk "land"-felt og et valgfritt "beskrivelse"-felt
|
||||||
|
-- som del av den obligatoriske profil-fullføringen ved første innlogging
|
||||||
|
-- (se CLAUDE.md-status). Samme mønster som resten av `app_user`-profilen
|
||||||
|
-- -- nullable kolonne, "obligatorisk" håndheves i app-laget
|
||||||
|
-- (`profile_complete` i /auth/me), ikke som en DB NOT NULL-constraint
|
||||||
|
-- (unngår å bryte eksisterende kontoer som ennå ikke har fylt inn feltet).
|
||||||
|
-- `country` er fri tekst, samme design som det allerede eksisterende
|
||||||
|
-- org-scopede `player.country` (migrasjon 007) -- ingen fast landliste.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE app_user ADD COLUMN country text;
|
||||||
|
ALTER TABLE app_user ADD COLUMN bio text;
|
||||||
|
|
@ -632,9 +632,17 @@ class Me(BaseModel):
|
||||||
gender: str | None
|
gender: str | None
|
||||||
handicap_index: float | None
|
handicap_index: float | None
|
||||||
home_club: str | None
|
home_club: str | None
|
||||||
|
country: str | None
|
||||||
|
bio: str | None
|
||||||
mobile_country_code: str | None
|
mobile_country_code: str | None
|
||||||
mobile_number: str | None
|
mobile_number: str | None
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
|
# Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske
|
||||||
|
# profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/
|
||||||
|
# hjemmeklubb/land) -- beskrivelse og bilde er bevisst IKKE med, de er
|
||||||
|
# valgfrie. Frontend bruker denne til å tvinge en ny/ufullstendig
|
||||||
|
# bruker gjennom profil-fullføring FØR resten av appen vises.
|
||||||
|
profile_complete: bool
|
||||||
my_tournaments: list[MyTournament]
|
my_tournaments: list[MyTournament]
|
||||||
secondary_emails: list[SecondaryEmailOut]
|
secondary_emails: list[SecondaryEmailOut]
|
||||||
|
|
||||||
|
|
@ -662,7 +670,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
|
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
|
||||||
(password_hash IS NOT NULL) AS has_password, two_factor_method,
|
(password_hash IS NOT NULL) AS has_password, two_factor_method,
|
||||||
first_name, last_name, birth_date, gender,
|
first_name, last_name, birth_date, gender,
|
||||||
handicap_index::float AS handicap_index, home_club,
|
handicap_index::float AS handicap_index, home_club, country, bio,
|
||||||
mobile_country_code, mobile_number, avatar_key
|
mobile_country_code, mobile_number, avatar_key
|
||||||
FROM app_user WHERE id = $1
|
FROM app_user WHERE id = $1
|
||||||
""",
|
""",
|
||||||
|
|
@ -724,6 +732,11 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
|
MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
profile_complete = all(
|
||||||
|
user_row[field] is not None
|
||||||
|
for field in ("first_name", "last_name", "birth_date", "gender", "handicap_index", "home_club", "country")
|
||||||
|
)
|
||||||
|
|
||||||
return Me(
|
return Me(
|
||||||
id=user_row["id"],
|
id=user_row["id"],
|
||||||
email=user_row["email"],
|
email=user_row["email"],
|
||||||
|
|
@ -738,9 +751,12 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
gender=user_row["gender"],
|
gender=user_row["gender"],
|
||||||
handicap_index=user_row["handicap_index"],
|
handicap_index=user_row["handicap_index"],
|
||||||
home_club=user_row["home_club"],
|
home_club=user_row["home_club"],
|
||||||
|
country=user_row["country"],
|
||||||
|
bio=user_row["bio"],
|
||||||
mobile_country_code=user_row["mobile_country_code"],
|
mobile_country_code=user_row["mobile_country_code"],
|
||||||
mobile_number=user_row["mobile_number"],
|
mobile_number=user_row["mobile_number"],
|
||||||
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
||||||
|
profile_complete=profile_complete,
|
||||||
my_tournaments=my_tournaments,
|
my_tournaments=my_tournaments,
|
||||||
secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
|
secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
|
||||||
)
|
)
|
||||||
|
|
@ -757,8 +773,14 @@ class ProfileUpdate(BaseModel):
|
||||||
last_name: str | None = Field(default=None, max_length=100)
|
last_name: str | None = Field(default=None, max_length=100)
|
||||||
birth_date: date | None = None
|
birth_date: date | None = None
|
||||||
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
||||||
handicap_index: float | None = None
|
# WHS-maksimum er 54.0 -- en fersk spiller uten offisiell HCP ennå skal
|
||||||
|
# bruke nettopp dette som utgangspunkt (frontend forhåndsutfyller
|
||||||
|
# skjemaet med 54), ikke la feltet stå tomt. Nedre grense er romslig
|
||||||
|
# nok til å dekke reelle plusshandicap.
|
||||||
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
home_club: str | None = Field(default=None, max_length=200)
|
home_club: str | None = Field(default=None, max_length=200)
|
||||||
|
country: str | None = Field(default=None, max_length=100)
|
||||||
|
bio: str | None = Field(default=None, max_length=500)
|
||||||
# Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve
|
# Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve
|
||||||
# nummeret -- lar frontend tilby en egen landsnummer-velger uten å
|
# nummeret -- lar frontend tilby en egen landsnummer-velger uten å
|
||||||
# måtte parse/splitte en fritekststreng i etterkant.
|
# måtte parse/splitte en fritekststreng i etterkant.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export default async function Page() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const session = cookieStore.get(SESSION_COOKIE_NAME)
|
const session = cookieStore.get(SESSION_COOKIE_NAME)
|
||||||
let authenticated = false
|
let authenticated = false
|
||||||
|
let profileComplete = true
|
||||||
if (session) {
|
if (session) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_ORIGIN}/auth/me`, {
|
const res = await fetch(`${API_ORIGIN}/auth/me`, {
|
||||||
|
|
@ -29,13 +30,21 @@ export default async function Page() {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
})
|
})
|
||||||
authenticated = res.ok
|
authenticated = res.ok
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
profileComplete = Boolean(data.profile_complete)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// API utilgjengelig -- vis innloggingsskjemaet i stedet for å henge.
|
// API utilgjengelig -- vis innloggingsskjemaet i stedet for å henge.
|
||||||
authenticated = false
|
authenticated = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
redirect("/dashboard")
|
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
|
||||||
|
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
|
||||||
|
// /account (som selv viser en fokusert fullførings-visning) i stedet
|
||||||
|
// for dashbordet -- se AccountSettings sin profile_complete-gren.
|
||||||
|
redirect(profileComplete ? "/dashboard" : "/account")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,25 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Camera, KeyRound, Lock, Mail, Plus, ShieldCheck, ShieldOff, Trash2, User, X } from "lucide-react"
|
import { useRouter } from "next/navigation"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Camera,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldOff,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
||||||
|
|
||||||
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
||||||
|
|
@ -28,13 +43,19 @@ type Me = {
|
||||||
gender: "m" | "f" | "x" | null
|
gender: "m" | "f" | "x" | null
|
||||||
handicap_index: number | null
|
handicap_index: number | null
|
||||||
home_club: string | null
|
home_club: string | null
|
||||||
|
country: string | null
|
||||||
|
bio: string | null
|
||||||
mobile_country_code: string | null
|
mobile_country_code: string | null
|
||||||
mobile_number: string | null
|
mobile_number: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
// 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse
|
||||||
|
// teller ikke). Se AccountSettings sin bruk under.
|
||||||
|
profile_complete: boolean
|
||||||
secondary_emails: { id: string; email: string }[]
|
secondary_emails: { id: string; email: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings() {
|
export function AccountSettings() {
|
||||||
|
const router = useRouter()
|
||||||
const [me, setMe] = useState<Me | null>(null)
|
const [me, setMe] = useState<Me | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [settingUp2fa, setSettingUp2fa] = useState(false)
|
const [settingUp2fa, setSettingUp2fa] = useState(false)
|
||||||
|
|
@ -71,6 +92,19 @@ export function AccountSettings() {
|
||||||
|
|
||||||
if (!me) return null
|
if (!me) return null
|
||||||
|
|
||||||
|
// Obligatorisk profil-fullføring (2026-07-22): en ny/ufullstendig konto
|
||||||
|
// ser KUN dette skjemaet -- ingen tilgang til resten av kontosidene eller
|
||||||
|
// dashbordet før alle obligatoriske felt er utfylt. Se page.tsx og
|
||||||
|
// Dashboard for de to inngangspunktene som sender brukeren hit.
|
||||||
|
if (!me.profile_complete) {
|
||||||
|
return (
|
||||||
|
<ProfileOnboarding
|
||||||
|
me={me}
|
||||||
|
onComplete={() => router.replace("/dashboard")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
<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">
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
|
@ -161,6 +195,225 @@ export function AccountSettings() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Obligatorisk profil-fullføring (2026-07-22) ----------------------------
|
||||||
|
// Vises i STEDET for resten av appen (dashbord, øvrige kontoseksjoner) helt
|
||||||
|
// til alle obligatoriske felt er utfylt -- se AccountSettings sin
|
||||||
|
// profile_complete-gren. Kun bilde og beskrivelse er valgfrie her.
|
||||||
|
|
||||||
|
function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [firstName, setFirstName] = useState(me.first_name ?? "")
|
||||||
|
const [lastName, setLastName] = useState(me.last_name ?? "")
|
||||||
|
const [birthDate, setBirthDate] = useState(me.birth_date ?? "")
|
||||||
|
const [gender, setGender] = useState(me.gender ?? "")
|
||||||
|
// WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell
|
||||||
|
// HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske.
|
||||||
|
const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index))
|
||||||
|
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
||||||
|
const [country, setCountry] = useState(me.country ?? "")
|
||||||
|
const [bio, setBio] = useState(me.bio ?? "")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const valid =
|
||||||
|
firstName.trim() !== "" &&
|
||||||
|
lastName.trim() !== "" &&
|
||||||
|
birthDate !== "" &&
|
||||||
|
gender !== "" &&
|
||||||
|
hcp.trim() !== "" &&
|
||||||
|
homeClub.trim() !== "" &&
|
||||||
|
country.trim() !== ""
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!valid || saving) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/auth/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
birth_date: birthDate,
|
||||||
|
gender,
|
||||||
|
handicap_index: Number(hcp.replace(",", ".")),
|
||||||
|
home_club: homeClub.trim(),
|
||||||
|
country: country.trim(),
|
||||||
|
bio: bio.trim() === "" ? null : bio.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
|
||||||
|
}
|
||||||
|
const updated: Me = await res.json()
|
||||||
|
if (updated.profile_complete) {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
||||||
|
} finally {
|
||||||
|
router.replace("/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-2xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<Wordmark compact />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<LogOut aria-hidden="true" className="size-4" />
|
||||||
|
Logg ut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
|
||||||
|
<div className="flex flex-col gap-2 pb-6 text-center sm:text-left">
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
Fullfør profilen din
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Før du kan komme videre trenger vi noen opplysninger om deg. Alt utenom beskrivelse
|
||||||
|
er obligatorisk. Du kan redigere alt dette senere under «Konto».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-first-name" className="text-sm font-semibold">
|
||||||
|
Fornavn
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ob-first-name"
|
||||||
|
autoFocus
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-last-name" className="text-sm font-semibold">
|
||||||
|
Etternavn
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ob-last-name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-birth-date" className="text-sm font-semibold">
|
||||||
|
Fødselsdato
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ob-birth-date"
|
||||||
|
type="date"
|
||||||
|
value={birthDate}
|
||||||
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-gender" className="text-sm font-semibold">
|
||||||
|
Kjønn
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="ob-gender"
|
||||||
|
value={gender}
|
||||||
|
onChange={(e) => setGender(e.target.value)}
|
||||||
|
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Velg …</option>
|
||||||
|
<option value="f">Dame</option>
|
||||||
|
<option value="m">Herre</option>
|
||||||
|
<option value="x">Annet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-hcp" className="text-sm font-semibold">
|
||||||
|
HCP
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ob-hcp"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={hcp}
|
||||||
|
onChange={(e) => setHcp(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-home-club" className="text-sm font-semibold">
|
||||||
|
Hjemmeklubb
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ob-home-club"
|
||||||
|
value={homeClub}
|
||||||
|
onChange={(e) => setHomeClub(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ob-country" className="text-sm font-semibold">
|
||||||
|
Land
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ob-country"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="ob-bio" className="text-sm font-semibold">
|
||||||
|
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="ob-bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="F.eks. Golf nut, running teeoff.no"
|
||||||
|
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={!valid || saving} className="h-12 w-fit rounded-xl font-semibold">
|
||||||
|
{saving ? "Lagrer …" : "Fortsett"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Personlig profil (ADR-031) ---------------------------------------------
|
// --- Personlig profil (ADR-031) ---------------------------------------------
|
||||||
|
|
||||||
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
|
|
@ -170,6 +423,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
const [gender, setGender] = useState(me.gender ?? "")
|
const [gender, setGender] = useState(me.gender ?? "")
|
||||||
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
|
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
|
||||||
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
||||||
|
const [country, setCountry] = useState(me.country ?? "")
|
||||||
|
const [bio, setBio] = useState(me.bio ?? "")
|
||||||
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
|
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
|
||||||
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
|
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -195,6 +450,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
gender: gender === "" ? null : gender,
|
gender: gender === "" ? null : gender,
|
||||||
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
|
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
|
||||||
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
|
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
|
||||||
|
country: country.trim() === "" ? null : country.trim(),
|
||||||
|
bio: bio.trim() === "" ? null : bio.trim(),
|
||||||
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
|
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
|
||||||
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
|
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
|
||||||
}),
|
}),
|
||||||
|
|
@ -371,6 +628,30 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
className="h-11 rounded-xl"
|
className="h-11 rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="country" className="text-sm font-semibold">
|
||||||
|
Land
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="country"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="bio" className="text-sm font-semibold">
|
||||||
|
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="F.eks. Golf nut, running teeoff.no"
|
||||||
|
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||||
<Label htmlFor="mobile-number" className="text-sm font-semibold">
|
<Label htmlFor="mobile-number" className="text-sm font-semibold">
|
||||||
Mobil
|
Mobil
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ type Me = {
|
||||||
email: string
|
email: string
|
||||||
display_name: string
|
display_name: string
|
||||||
preferred_locale: string
|
preferred_locale: string
|
||||||
|
profile_complete: boolean
|
||||||
organizations: MyOrg[]
|
organizations: MyOrg[]
|
||||||
my_tournaments: MyTournament[]
|
my_tournaments: MyTournament[]
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +105,14 @@ export function Dashboard() {
|
||||||
}
|
}
|
||||||
const data: Me = await res.json()
|
const data: Me = await res.json()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
// Obligatorisk profil-fullføring (2026-07-22): dekker enhver vei
|
||||||
|
// INN til dashbordet (magic-link/passord/2FA-verifisering lander
|
||||||
|
// her direkte) -- /account viser selv fullførings-skjemaet så
|
||||||
|
// lenge profil_complete er false.
|
||||||
|
if (!data.profile_complete) {
|
||||||
|
router.replace("/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
setMe(data)
|
setMe(data)
|
||||||
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
|
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue