Kort: selve grunnfargen er et bevisst, ekte match. Hvordan overflatene BYGGES OPP (glass vs. flatt, boxed vs. flytende) er derimot forskjellig, og det er V0 sin egen tolkning, ikke noe hentet direkte fra dette bildet.
This commit is contained in:
parent
b99c9a6ae0
commit
27bff38afe
10 changed files with 927 additions and 431 deletions
|
|
@ -382,7 +382,15 @@
|
||||||
"Bash(python3 test_round_starthole_time_nearby.py)",
|
"Bash(python3 test_round_starthole_time_nearby.py)",
|
||||||
"Bash(python3 test_round_strokes_received.py)",
|
"Bash(python3 test_round_strokes_received.py)",
|
||||||
"Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)",
|
"Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)",
|
||||||
"mcp__chrome-devtools__get_network_request"
|
"mcp__chrome-devtools__get_network_request",
|
||||||
|
"Bash(curl -s -c /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/login-password -H 'Content-Type: application/json' -d '{\"email\":\"pwtest@example.com\",\"password\":\"testpassord123\"}')",
|
||||||
|
"Bash(python3 -c \"import json,sys;print\\(json.load\\(sys.stdin\\)\\)\")",
|
||||||
|
"Bash(curl -s -b /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/2fa/setup/start -H 'Content-Type: application/json' -d '{\"method\":\"totp\"}')",
|
||||||
|
"Bash(python3 -c \"import json;d=json.load\\(open\\('/tmp/totp_setup.json'\\)\\);print\\(d.get\\('status'\\), d.get\\('secret'\\)\\)\")",
|
||||||
|
"Bash(curl -s -b /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/2fa/setup/confirm -H 'Content-Type: application/json' -d '{\"method\":\"totp\",\"code\":\"682645\",\"secret\":\"H5FZIL7SO5BYHP3AQEFZPS6O7WR4ZHG5\"}')",
|
||||||
|
"Bash(curl -s -b /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/logout)",
|
||||||
|
"Bash(rm -f /opt/teecup/v0-zip22-login-*.png /opt/teecup/v0-integrated-*.png *)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"friends/on-course anonym: %{http_code}\\\\n\" https://teecup.teeoff.no/friends/on-course)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -1615,6 +1615,106 @@ async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) ->
|
||||||
return [r["friend_id"] for r in matched]
|
return [r["friend_id"] for r in matched]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# "Venner på banen" (dashbord-redesign 2026-08-01) -- venner som enten
|
||||||
|
# spiller en aktiv runde AKKURAT NÅ, eller fullførte en innen de siste 24
|
||||||
|
# timene. Motsatt retning av `_friends_who_can_see_round` (der starter vi
|
||||||
|
# fra EIEREN og finner vennene deres) -- her starter vi fra VIEWEREN og
|
||||||
|
# finner vennenes runder, filtrert gjennom SAMME synlighetsregel som
|
||||||
|
# `_can_view_round` sin 'friends'-gren (kopiert inn i SQL-en under for å
|
||||||
|
# gjøre dette til ett enkelt sett-oppslag i stedet for N kall til
|
||||||
|
# `_can_view_round` per kandidatrunde -- reelt greit på denne skalaen,
|
||||||
|
# siden en brukers vennlisteantall er lite).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FriendOnCourseEntry(BaseModel):
|
||||||
|
round_id: str
|
||||||
|
friend_user_id: str
|
||||||
|
friend_name: str
|
||||||
|
course_name: str
|
||||||
|
play_format: str
|
||||||
|
status: Literal["playing", "recently_finished"]
|
||||||
|
holes_played: int
|
||||||
|
holes_planned: int
|
||||||
|
score_to_par: int | None
|
||||||
|
started_at: str | None
|
||||||
|
completed_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/friends/on-course", response_model=list[FriendOnCourseEntry])
|
||||||
|
async def list_friends_on_course(user: CurrentUser = Depends(get_current_user)) -> list[FriendOnCourseEntry]:
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT r.id::text AS round_id, r.owner_user_id::text AS owner_user_id,
|
||||||
|
COALESCE(au.first_name || ' ' || au.last_name, au.display_name) AS friend_name,
|
||||||
|
r.course_name_snapshot AS course_name, r.play_format,
|
||||||
|
r.holes_planned, r.started_at, r.completed_at,
|
||||||
|
COUNT(rh.*) FILTER (WHERE rh.played) AS holes_played,
|
||||||
|
COALESCE(SUM(rh.score) FILTER (WHERE rh.played), 0) AS total_score,
|
||||||
|
COALESCE(SUM(rh.par) FILTER (WHERE rh.played), 0) AS total_par
|
||||||
|
FROM round r
|
||||||
|
JOIN app_user au ON au.id = r.owner_user_id
|
||||||
|
JOIN friendship f ON f.status = 'accepted'
|
||||||
|
AND ((f.requester_user_id = $1 AND f.addressee_user_id = r.owner_user_id)
|
||||||
|
OR (f.requester_user_id = r.owner_user_id AND f.addressee_user_id = $1))
|
||||||
|
JOIN round_participant rp ON rp.round_id = r.id AND rp.user_id = r.owner_user_id
|
||||||
|
LEFT JOIN round_hole rh ON rh.round_participant_id = rp.id
|
||||||
|
WHERE r.owner_user_id != $1
|
||||||
|
AND (
|
||||||
|
(r.started_at IS NOT NULL AND r.completed_at IS NULL)
|
||||||
|
OR (r.completed_at IS NOT NULL AND r.completed_at >= now() - interval '24 hours')
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
r.visibility_mode = 'public'
|
||||||
|
OR (
|
||||||
|
r.visibility_mode = 'friends'
|
||||||
|
AND EXISTS(
|
||||||
|
SELECT 1 FROM friend_categorization fc
|
||||||
|
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
||||||
|
)
|
||||||
|
AND NOT EXISTS(
|
||||||
|
SELECT 1 FROM friend_categorization fc
|
||||||
|
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
||||||
|
AND fc.category NOT IN (
|
||||||
|
SELECT category FROM round_visible_category WHERE round_id = r.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
GROUP BY r.id, r.owner_user_id, au.first_name, au.last_name, au.display_name,
|
||||||
|
r.course_name_snapshot, r.play_format, r.holes_planned, r.started_at, r.completed_at
|
||||||
|
ORDER BY COALESCE(r.started_at, r.completed_at) DESC
|
||||||
|
""",
|
||||||
|
user.user_id,
|
||||||
|
)
|
||||||
|
out: list[FriendOnCourseEntry] = []
|
||||||
|
for r in rows:
|
||||||
|
holes_played = r["holes_played"]
|
||||||
|
score_to_par = (
|
||||||
|
r["total_score"] - r["total_par"]
|
||||||
|
if holes_played > 0 and r["play_format"] in ("stroke", "stableford")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
FriendOnCourseEntry(
|
||||||
|
round_id=r["round_id"],
|
||||||
|
friend_user_id=r["owner_user_id"],
|
||||||
|
friend_name=r["friend_name"],
|
||||||
|
course_name=r["course_name"],
|
||||||
|
play_format=r["play_format"],
|
||||||
|
status="playing" if r["completed_at"] is None else "recently_finished",
|
||||||
|
holes_played=holes_played,
|
||||||
|
holes_planned=r["holes_planned"],
|
||||||
|
score_to_par=score_to_par,
|
||||||
|
started_at=r["started_at"].isoformat() if r["started_at"] else None,
|
||||||
|
completed_at=r["completed_at"].isoformat() if r["completed_at"] else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder --
|
# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder --
|
||||||
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en
|
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { cookies } from "next/headers"
|
import { cookies } from "next/headers"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { LoginForm } from "@/components/login-form"
|
import { LoginForm } from "@/components/login-form"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
|
||||||
|
|
||||||
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
|
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
|
||||||
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
|
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
|
||||||
|
|
@ -50,13 +49,10 @@ export default async function Page() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
|
||||||
<div className="flex w-full max-w-sm flex-col gap-8">
|
<div className="flex w-full max-w-sm flex-col gap-8">
|
||||||
<header className="flex flex-col items-center gap-3 text-center">
|
{/* Redesignet kort (2026-08-01, V0/Forest Green) har sin egen
|
||||||
<Wordmark />
|
innebygde header (logo + tittel/undertekst per tilstand) --
|
||||||
<p className="text-balance text-base leading-relaxed text-muted-foreground">
|
den forrige separate Wordmark+tagline-headeren over kortet er
|
||||||
Logg inn for å følge turneringen din live.
|
derfor fjernet for å unngå dobbel "TeeCup"-overskrift. */}
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
|
||||||
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">
|
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,21 @@
|
||||||
// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget
|
// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget
|
||||||
// "opprett organisasjon"-steg for det vanlige tilfellet) -- se
|
// "opprett organisasjon"-steg for det vanlige tilfellet) -- se
|
||||||
// ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A.
|
// ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A.
|
||||||
|
//
|
||||||
|
// "Forest Green"-visuell redesign (2026-08-01, V0 zip 23, samme retning som
|
||||||
|
// login-form.tsx): kun JSX/farger endret nedenfor -- all ekte datahenting,
|
||||||
|
// alle ekte handlers og alle delte komponenter (RoundCard/TournamentCard/
|
||||||
|
// InstallPrompt/NewTournamentInline/JoinByCodeInline) er UENDRET, kun
|
||||||
|
// re-stylet der de selv definerer sin egen visuelle ramme.
|
||||||
|
//
|
||||||
|
// "Venner på banen" (ny seksjon, plassert rett under hurtighandlingene per
|
||||||
|
// brukerens eksplisitte ønske fra en tidligere Stitch-designsamtale) er en
|
||||||
|
// HELT NY funksjon -- viser venner med en AKTIV runde nå, eller en runde
|
||||||
|
// fullført innen de siste 24 timene, hentet fra det nye
|
||||||
|
// GET /friends/on-course-endepunktet (app/routers/rounds.py). Synlighet
|
||||||
|
// respekterer nøyaktig samme regel som all annen rundedeling (ADR-036
|
||||||
|
// fase 2, `_can_view_round`s 'friends'-gren) -- håndhevet server-side, ikke
|
||||||
|
// duplisert her.
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
@ -21,10 +36,9 @@ import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
MapPin,
|
MapPin,
|
||||||
Plus,
|
Trophy,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Trophy,
|
|
||||||
UserCircle,
|
UserCircle,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
X,
|
X,
|
||||||
|
|
@ -38,13 +52,32 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
|
||||||
import { InstallPrompt } from "@/components/install-prompt"
|
import { InstallPrompt } from "@/components/install-prompt"
|
||||||
import { RoundCard, type Round } from "@/components/round-card"
|
import { RoundCard, type Round } from "@/components/round-card"
|
||||||
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
||||||
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Låst "Forest Green"-palett (2026-08-01) -- samme sett som login-form.tsx,
|
||||||
|
// se DESIGN_SYSTEM.md sitt notat om denne runden. ALDRI #84b089/#a4a5a5 som
|
||||||
|
// tekst på mørkegrønn (#1a4325) -- målt ~4.5:1, under vårt AAA-mål (7:1).
|
||||||
|
const C = {
|
||||||
|
bg: "#f7faf8",
|
||||||
|
card: "#ffffff",
|
||||||
|
glass: "rgba(255, 255, 255, 0.78)",
|
||||||
|
ink: "#012c11",
|
||||||
|
inkOn: "#ffffff",
|
||||||
|
accent: "#1a4325",
|
||||||
|
accentMuted: "#bfeec4",
|
||||||
|
successBg: "#91f78e",
|
||||||
|
successInk: "#00731e",
|
||||||
|
muted: "#424941",
|
||||||
|
border: "#c1c9bf",
|
||||||
|
tint: "#edf3ef",
|
||||||
|
warmBg: "#ffedd5",
|
||||||
|
warmInk: "#9a3412",
|
||||||
|
} as const
|
||||||
|
|
||||||
// --- API-typer ---------------------------------------------------------------
|
// --- API-typer ---------------------------------------------------------------
|
||||||
|
|
||||||
type MyOrg = { organization_id: string; name: string; role: string }
|
type MyOrg = { organization_id: string; name: string; role: string }
|
||||||
|
|
@ -118,6 +151,21 @@ type ApiFriendsSummary = {
|
||||||
incoming_requests: unknown[]
|
incoming_requests: unknown[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Venner på banen" -- speiler FriendOnCourseEntry i app/routers/rounds.py.
|
||||||
|
type ApiFriendOnCourse = {
|
||||||
|
round_id: string
|
||||||
|
friend_user_id: string
|
||||||
|
friend_name: string
|
||||||
|
course_name: string
|
||||||
|
play_format: string
|
||||||
|
status: "playing" | "recently_finished"
|
||||||
|
holes_played: number
|
||||||
|
holes_planned: number
|
||||||
|
score_to_par: number | null
|
||||||
|
started_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
function toTournament(t: ApiTournament): Tournament {
|
function toTournament(t: ApiTournament): Tournament {
|
||||||
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
|
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +260,17 @@ function formatDate(value: string) {
|
||||||
return dateFormatter.format(parsed)
|
return dateFormatter.format(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "for N timer/minutter siden" -- kun brukt av "Venner på banen" sin
|
||||||
|
// "nylig fullført"-status.
|
||||||
|
function timeAgoLabel(iso: string): string {
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
if (Number.isNaN(then)) return ""
|
||||||
|
const minutes = Math.max(1, Math.round((Date.now() - then) / 60000))
|
||||||
|
if (minutes < 60) return `${minutes} min siden`
|
||||||
|
const hours = Math.round(minutes / 60)
|
||||||
|
return `${hours} ${hours === 1 ? "time" : "timer"} siden`
|
||||||
|
}
|
||||||
|
|
||||||
// --- Root component ----------------------------------------------------------
|
// --- Root component ----------------------------------------------------------
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
|
@ -222,6 +281,7 @@ export function Dashboard() {
|
||||||
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
|
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
|
||||||
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
|
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
|
||||||
const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null)
|
const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null)
|
||||||
|
const [friendsOnCourse, setFriendsOnCourse] = useState<ApiFriendOnCourse[]>([])
|
||||||
const [unreadNotifications, setUnreadNotifications] = useState(0)
|
const [unreadNotifications, setUnreadNotifications] = useState(0)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -262,6 +322,10 @@ export function Dashboard() {
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data: ApiFriendsSummary | null) => setFriendsSummary(data))
|
.then((data: ApiFriendsSummary | null) => setFriendsSummary(data))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
fetch("/friends/on-course", { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiFriendOnCourse[]) => setFriendsOnCourse(data))
|
||||||
|
.catch(() => {})
|
||||||
fetch("/notifications/unread-count", { credentials: "include" })
|
fetch("/notifications/unread-count", { credentials: "include" })
|
||||||
.then((res) => (res.ok ? res.json() : { count: 0 }))
|
.then((res) => (res.ok ? res.json() : { count: 0 }))
|
||||||
.then((data: { count: number }) => setUnreadNotifications(data.count))
|
.then((data: { count: number }) => setUnreadNotifications(data.count))
|
||||||
|
|
@ -349,8 +413,8 @@ export function Dashboard() {
|
||||||
|
|
||||||
if (loadingMe) {
|
if (loadingMe) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4" style={{ backgroundColor: C.bg }}>
|
||||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -386,15 +450,26 @@ export function Dashboard() {
|
||||||
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur">
|
<header
|
||||||
|
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur-xl"
|
||||||
|
style={{ backgroundColor: C.glass, borderColor: C.border }}
|
||||||
|
>
|
||||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||||
<Wordmark compact />
|
<Link href="/dashboard" aria-label="TeeCup – til forsiden" className="flex items-center gap-2 rounded-xl">
|
||||||
|
<span className="flex size-9 items-center justify-center rounded-xl" style={{ backgroundColor: C.ink }}>
|
||||||
|
<Flag aria-hidden="true" className="size-5" fill="#f79b3d" color="#f79b3d" />
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
|
||||||
|
TeeCup
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NotificationBell unreadCount={unreadNotifications} />
|
<NotificationBell unreadCount={unreadNotifications} />
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account"
|
||||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70"
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold transition-all duration-200 ease-in-out active:opacity-70"
|
||||||
|
style={{ color: C.muted }}
|
||||||
>
|
>
|
||||||
<UserCircle aria-hidden="true" className="size-4" />
|
<UserCircle aria-hidden="true" className="size-4" />
|
||||||
Konto
|
Konto
|
||||||
|
|
@ -402,7 +477,8 @@ export function Dashboard() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70"
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold transition-all duration-200 ease-in-out active:opacity-70"
|
||||||
|
style={{ color: C.muted }}
|
||||||
>
|
>
|
||||||
<LogOut aria-hidden="true" className="size-4" />
|
<LogOut aria-hidden="true" className="size-4" />
|
||||||
Logg ut
|
Logg ut
|
||||||
|
|
@ -413,16 +489,16 @@ export function Dashboard() {
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-[max(4rem,env(safe-area-inset-bottom))] pt-6 sm:pt-8">
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-[max(4rem,env(safe-area-inset-bottom))] pt-6 sm:pt-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
<h1 className="text-2xl font-extrabold tracking-tight text-balance" style={{ color: C.ink }}>
|
||||||
God dag, {me.first_name ?? me.display_name}
|
God dag, {me.first_name ?? me.display_name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
<p className="text-base leading-relaxed text-pretty" style={{ color: C.muted }}>
|
||||||
Klar for en ny runde? Her er golfen din på ett sted.
|
Klar for en ny runde? Her er golfen din på ett sted.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mt-4 text-sm font-medium text-destructive">
|
<p role="alert" className="mt-4 text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -438,6 +514,7 @@ export function Dashboard() {
|
||||||
open={quickActionOpen}
|
open={quickActionOpen}
|
||||||
onOpenChange={setQuickActionOpen}
|
onOpenChange={setQuickActionOpen}
|
||||||
/>
|
/>
|
||||||
|
<LiveFriends friends={friendsOnCourse} />
|
||||||
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
|
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
|
||||||
<UpcomingTournaments
|
<UpcomingTournaments
|
||||||
entries={combinedTournaments}
|
entries={combinedTournaments}
|
||||||
|
|
@ -466,14 +543,15 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
|
||||||
<Link
|
<Link
|
||||||
href="/my-notifications"
|
href="/my-notifications"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="relative inline-flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="relative inline-flex size-11 items-center justify-center rounded-lg transition-all duration-200 ease-in-out active:opacity-70 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
style={{ color: C.muted, outlineColor: C.accent }}
|
||||||
>
|
>
|
||||||
<Bell aria-hidden="true" className="size-5" />
|
<Bell aria-hidden="true" className="size-5" />
|
||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full bg-brand-orange px-1 text-[11px] font-bold leading-none text-brand-orange-foreground ring-2 ring-background"
|
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
|
||||||
style={{ height: 18 }}
|
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.glass}` }}
|
||||||
>
|
>
|
||||||
{badgeText}
|
{badgeText}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -482,7 +560,7 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 1. Hurtighandlinger -----------------------------------------------------
|
// --- 4. Hurtighandlinger -----------------------------------------------------
|
||||||
|
|
||||||
function QuickActions({
|
function QuickActions({
|
||||||
organizations,
|
organizations,
|
||||||
|
|
@ -521,28 +599,37 @@ function QuickAction({
|
||||||
href?: string
|
href?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
backgroundColor: C.ink,
|
||||||
|
color: C.inkOn,
|
||||||
|
boxShadow: "0 14px 30px -20px rgba(1, 44, 17, 0.7)",
|
||||||
|
}
|
||||||
const className =
|
const className =
|
||||||
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl bg-primary px-2 py-3 text-center text-primary-foreground shadow-md shadow-primary/25 transition-all duration-200 ease-in-out hover:bg-primary/90 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 text-center transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
<Icon aria-hidden="true" className="size-5 sm:size-6" />
|
<Icon aria-hidden="true" className="size-5 sm:size-6" color={C.successBg} />
|
||||||
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
|
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<Link href={href} className={className}>
|
<Link href={href} className={className} style={style}>
|
||||||
{inner}
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} className={className}>
|
<button type="button" onClick={onClick} className={className} style={style}>
|
||||||
{inner}
|
{inner}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skjemaene under (opprett turnering / bli med med kode) beholdt UENDRET --
|
||||||
|
// egne, allerede etablerte shadcn-baserte skjemaer, ikke del av selve
|
||||||
|
// Forest Green-visuell-redesignet (Stitch/V0 designet aldri disse to).
|
||||||
|
|
||||||
function NewTournamentInline({
|
function NewTournamentInline({
|
||||||
organizations,
|
organizations,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
|
@ -709,7 +796,9 @@ function JoinByCodeInline({ onClose }: { onClose: () => void }) {
|
||||||
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
|
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2>
|
<h2 className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -719,7 +808,8 @@ function SeeAllLink({ href, label }: { href: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-primary transition-all duration-200 ease-in-out hover:bg-accent/50 active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
style={{ color: C.accent, outlineColor: C.accent }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<ChevronRight aria-hidden="true" className="size-4" />
|
<ChevronRight aria-hidden="true" className="size-4" />
|
||||||
|
|
@ -739,13 +829,20 @@ function EmptyState({
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border bg-card/50 px-6 py-8 text-center">
|
<div
|
||||||
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
className="flex flex-col items-center gap-3 rounded-2xl border border-dashed px-6 py-8 text-center"
|
||||||
<Icon aria-hidden="true" className="size-6 text-muted-foreground" />
|
style={{ borderColor: C.border, backgroundColor: "rgba(255,255,255,0.5)" }}
|
||||||
|
>
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
|
||||||
|
<Icon aria-hidden="true" className="size-6" color={C.muted} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-base font-bold text-foreground text-balance">{title}</h3>
|
<h3 className="text-base font-bold text-balance" style={{ color: C.ink }}>
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{description}</p>
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
|
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -765,10 +862,12 @@ function ShortcutButton({
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
variant?: "primary" | "outline"
|
variant?: "primary" | "outline"
|
||||||
}) {
|
}) {
|
||||||
const className = cn(
|
const style: React.CSSProperties =
|
||||||
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
variant === "primary"
|
||||||
variant === "primary" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50",
|
? { backgroundColor: C.ink, color: C.inkOn }
|
||||||
)
|
: { backgroundColor: C.card, color: C.ink, border: `1px solid ${C.border}` }
|
||||||
|
const className =
|
||||||
|
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
<Icon aria-hidden="true" className="size-4" />
|
<Icon aria-hidden="true" className="size-4" />
|
||||||
|
|
@ -777,19 +876,97 @@ function ShortcutButton({
|
||||||
)
|
)
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<Link href={href} className={className}>
|
<Link href={href} className={className} style={style}>
|
||||||
{inner}
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} className={className}>
|
<button type="button" onClick={onClick} className={className} style={style}>
|
||||||
{inner}
|
{inner}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Runder --------------------------------------------------------
|
// --- 5. Venner på banen -------------------------------------------------------
|
||||||
|
// Ny seksjon (2026-08-01), plassert rett under hurtighandlingene per
|
||||||
|
// brukerens eksplisitte ønske. Ekte data fra GET /friends/on-course --
|
||||||
|
// venner med en AKTIV runde nå, eller fullført innen 24 timer. Bevisst
|
||||||
|
// UTEN boxed empty-state (kun én rolig linje) -- skal ikke prompte en
|
||||||
|
// handling når den er tom, ulikt de andre seksjonene.
|
||||||
|
|
||||||
|
function friendOnCourseInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
const first = parts[0]?.[0] ?? ""
|
||||||
|
const last = parts.length > 1 ? parts[parts.length - 1][0] ?? "" : ""
|
||||||
|
return (first + last).toUpperCase() || "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Venner på banen">
|
||||||
|
<SectionHeader title="Venner på banen" />
|
||||||
|
{friends.length > 0 ? (
|
||||||
|
<div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
|
||||||
|
{friends.map((friend) => {
|
||||||
|
const playing = friend.status === "playing"
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={friend.round_id}
|
||||||
|
href={`/my-rounds/${friend.round_id}`}
|
||||||
|
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl p-4 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<span
|
||||||
|
className="flex size-11 items-center justify-center rounded-full text-sm font-bold tabular-nums"
|
||||||
|
style={{ backgroundColor: C.tint, color: C.ink }}
|
||||||
|
>
|
||||||
|
{friendOnCourseInitials(friend.friend_name)}
|
||||||
|
</span>
|
||||||
|
{playing && (
|
||||||
|
<span aria-hidden="true" className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full" style={{ backgroundColor: C.card }}>
|
||||||
|
<span className="absolute inline-flex size-2.5 animate-ping rounded-full opacity-75" style={{ backgroundColor: C.successBg }} />
|
||||||
|
<span className="relative inline-flex size-2.5 rounded-full" style={{ backgroundColor: C.successInk }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
|
||||||
|
{friend.friend_name}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide" style={{ color: playing ? C.successInk : C.muted }}>
|
||||||
|
{playing ? "Spiller nå" : friend.completed_at ? `Fullførte ${timeAgoLabel(friend.completed_at)}` : "Fullførte nylig"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm" style={{ color: C.muted }}>
|
||||||
|
{friend.course_name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums" style={{ backgroundColor: C.tint, color: C.ink }}>
|
||||||
|
{playing
|
||||||
|
? `Hull ${friend.holes_played} av ${friend.holes_planned}`
|
||||||
|
: friend.score_to_par !== null
|
||||||
|
? formatSigned(friend.score_to_par, 0)
|
||||||
|
: "Fullført"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm leading-relaxed" style={{ color: C.muted }}>
|
||||||
|
Ingen venner er på banen akkurat nå.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Runder --------------------------------------------------------
|
||||||
|
|
||||||
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
|
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
|
||||||
const visible = rounds.slice(0, 3)
|
const visible = rounds.slice(0, 3)
|
||||||
|
|
@ -798,7 +975,7 @@ function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean
|
||||||
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
|
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
|
||||||
</div>
|
</div>
|
||||||
) : visible.length > 0 ? (
|
) : visible.length > 0 ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
@ -808,14 +985,14 @@ function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState icon={Flag} title="Ingen runder på gang" description="Start en runde når du er på banen, så dukker den opp her mens du spiller.">
|
<EmptyState icon={Flag} title="Ingen runder på gang" description="Start en runde når du er på banen, så dukker den opp her mens du spiller.">
|
||||||
<ShortcutButton icon={Plus} label="Ny runde" href="/my-rounds/new" />
|
<ShortcutButton icon={Flag} label="Ny runde" href="/my-rounds/new" />
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3. Kommende turneringer -----------------------------------------------
|
// --- 7. Kommende turneringer -----------------------------------------------
|
||||||
|
|
||||||
function UpcomingTournaments({
|
function UpcomingTournaments({
|
||||||
entries,
|
entries,
|
||||||
|
|
@ -845,7 +1022,7 @@ function UpcomingTournaments({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. Statistikk ---------------------------------------------------------
|
// --- 8. Statistikk ---------------------------------------------------------
|
||||||
|
|
||||||
function Sparkline({ values }: { values: number[] }) {
|
function Sparkline({ values }: { values: number[] }) {
|
||||||
if (values.length < 2) return null
|
if (values.length < 2) return null
|
||||||
|
|
@ -856,7 +1033,13 @@ function Sparkline({ values }: { values: number[] }) {
|
||||||
<div aria-hidden="true" className="flex h-5 items-end gap-0.5">
|
<div aria-hidden="true" className="flex h-5 items-end gap-0.5">
|
||||||
{values.map((v, i) => {
|
{values.map((v, i) => {
|
||||||
const height = 30 + ((v - min) / range) * 70
|
const height = 30 + ((v - min) / range) * 70
|
||||||
return <div key={i} className="w-1 rounded-full bg-primary/40" style={{ height: `${height}%` }} />
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-1 rounded-full"
|
||||||
|
style={{ height: `${height}%`, backgroundColor: i === values.length - 1 ? C.successInk : C.border }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -864,9 +1047,16 @@ function Sparkline({ values }: { values: number[] }) {
|
||||||
|
|
||||||
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
|
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
<div
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
className="flex flex-col gap-1.5 rounded-2xl p-4"
|
||||||
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
|
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide" style={{ color: C.muted }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-extrabold tabular-nums sm:text-3xl" style={{ color: C.ink }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -897,15 +1087,17 @@ function StatsSection({
|
||||||
<StatTile label="HCP nå" value={hcpNow}>
|
<StatTile label="HCP nå" value={hcpNow}>
|
||||||
{hcpTrend !== null ? (
|
{hcpTrend !== null ? (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-bold text-foreground">
|
<span className="inline-flex items-center gap-1 text-xs font-bold" style={{ color: C.ink }}>
|
||||||
<TrendIcon aria-hidden="true" className="size-4 text-primary" />
|
<TrendIcon aria-hidden="true" className="size-4" color={C.successInk} />
|
||||||
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
|
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
|
||||||
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
|
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
|
||||||
</span>
|
</span>
|
||||||
<Sparkline values={hcpHistory} />
|
<Sparkline values={hcpHistory} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium text-muted-foreground">Ingen data</span>
|
<span className="text-xs font-medium" style={{ color: C.muted }}>
|
||||||
|
Ingen data
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</StatTile>
|
</StatTile>
|
||||||
<StatTile label="Snitt til par" value={avgToPar} />
|
<StatTile label="Snitt til par" value={avgToPar} />
|
||||||
|
|
@ -914,32 +1106,38 @@ function StatsSection({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 5. Spilte baner ---------------------------------------------------------
|
// --- 9. Spilte baner ---------------------------------------------------------
|
||||||
|
|
||||||
function PlayedCourses({ courses }: { courses: { name: string; visits: number; lastPlayed: string }[] }) {
|
function PlayedCourses({ courses }: { courses: { name: string; visits: number; lastPlayed: string }[] }) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Spilte baner">
|
<section aria-label="Spilte baner">
|
||||||
<SectionHeader title="Spilte baner" />
|
<SectionHeader title="Spilte baner" />
|
||||||
{courses.length > 0 ? (
|
{courses.length > 0 ? (
|
||||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
<ul
|
||||||
{courses.map((course) => {
|
className="flex flex-col overflow-hidden rounded-2xl"
|
||||||
|
style={{ border: `1px solid ${C.border}`, backgroundColor: C.card, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||||
|
>
|
||||||
|
{courses.map((course, i) => {
|
||||||
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
||||||
return (
|
return (
|
||||||
<li key={course.name}>
|
<li key={course.name} style={i > 0 ? { borderTop: `1px solid ${C.border}` } : undefined}>
|
||||||
<Link
|
<Link
|
||||||
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
|
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
|
||||||
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out hover:bg-accent/50 active:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||||
|
style={{ outlineColor: C.accent }}
|
||||||
>
|
>
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
|
||||||
<MapPin aria-hidden="true" className="size-5" />
|
<MapPin aria-hidden="true" className="size-5" color={C.ink} />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate text-base font-bold text-foreground">{course.name}</span>
|
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
|
||||||
<span className="truncate text-sm font-medium text-muted-foreground">
|
{course.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium" style={{ color: C.muted }}>
|
||||||
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0" color={C.muted} />
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
@ -952,8 +1150,9 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 6. Venner ---------------------------------------------------------------
|
// --- 10. Venner ---------------------------------------------------------------
|
||||||
// ADR-036 fase 1 (venner-kjernen) er live -- ekte data fra GET /friends.
|
// ADR-036 fase 1 -- ekte data fra GET /friends. Distinkt fra "Venner på
|
||||||
|
// banen" (seksjon 5) -- dette er den generelle vennlisten, ikke aktivitet.
|
||||||
|
|
||||||
function friendInitials(first: string | null, last: string | null): string {
|
function friendInitials(first: string | null, last: string | null): string {
|
||||||
const f = (first ?? "").trim()[0] ?? ""
|
const f = (first ?? "").trim()[0] ?? ""
|
||||||
|
|
@ -961,68 +1160,56 @@ function friendInitials(first: string | null, last: string | null): string {
|
||||||
return (f + l).toUpperCase() || "?"
|
return (f + l).toUpperCase() || "?"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fargede avatar-initialer (i stedet for alle i samme tonede grønn) --
|
|
||||||
// deterministisk per person via en enkel navne-hash, gjenbruker de
|
|
||||||
// allerede validerte statistikk-fargetokenene (chart-1..6, DESIGN_SYSTEM.md)
|
|
||||||
// i stedet for å finne opp nye, hardkodede farger. Literal klasse-strenger
|
|
||||||
// (ikke sammensatt av en variabel) siden Tailwind krever det for at JIT-en
|
|
||||||
// skal fange dem opp.
|
|
||||||
const AVATAR_PALETTE = [
|
|
||||||
"bg-chart-1/20 text-chart-1",
|
|
||||||
"bg-chart-2/20 text-chart-2",
|
|
||||||
"bg-chart-3/20 text-chart-3",
|
|
||||||
"bg-chart-4/20 text-chart-4",
|
|
||||||
"bg-chart-5/20 text-chart-5",
|
|
||||||
"bg-chart-6/20 text-chart-6",
|
|
||||||
]
|
|
||||||
|
|
||||||
function avatarColorClass(first: string | null, last: string | null): string {
|
|
||||||
const name = `${first ?? ""}${last ?? ""}`
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < name.length; i++) hash = (hash + name.charCodeAt(i)) % AVATAR_PALETTE.length
|
|
||||||
return AVATAR_PALETTE[hash]
|
|
||||||
}
|
|
||||||
|
|
||||||
function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
|
function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
|
||||||
const friends = summary?.friends ?? []
|
const friends = summary?.friends ?? []
|
||||||
const pending = summary?.incoming_requests.length ?? 0
|
const pending = summary?.incoming_requests.length ?? 0
|
||||||
|
const visible = friends.slice(0, 4)
|
||||||
|
const overflow = friends.length - visible.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Venner">
|
<section aria-label="Venner">
|
||||||
<SectionHeader title="Venner" />
|
<SectionHeader title="Venner" />
|
||||||
{friends.length > 0 ? (
|
{friends.length > 0 ? (
|
||||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-center sm:justify-between">
|
<div
|
||||||
|
className="flex flex-col gap-4 rounded-2xl p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{friends.slice(0, 4).map((friend, i) => (
|
{visible.map((friend, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className="flex size-11 items-center justify-center rounded-full text-sm font-bold"
|
||||||
"flex size-11 items-center justify-center rounded-full border-2 border-card text-sm font-bold",
|
style={{ backgroundColor: C.tint, color: C.ink, border: `2px solid ${C.card}`, marginLeft: i > 0 ? "-0.75rem" : undefined }}
|
||||||
avatarColorClass(friend.first_name, friend.last_name),
|
|
||||||
i > 0 && "-ml-3",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{friendInitials(friend.first_name, friend.last_name)}
|
{friendInitials(friend.first_name, friend.last_name)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{friends.length > 4 && (
|
{overflow > 0 && (
|
||||||
<span className="-ml-3 flex size-11 items-center justify-center rounded-full border-2 border-card bg-muted text-xs font-bold text-muted-foreground">
|
<span
|
||||||
+{friends.length - 4}
|
className="flex size-11 items-center justify-center rounded-full text-xs font-bold tabular-nums"
|
||||||
|
style={{ backgroundColor: C.accent, color: C.inkOn, border: `2px solid ${C.card}`, marginLeft: "-0.75rem" }}
|
||||||
|
>
|
||||||
|
+{overflow}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-base font-bold text-foreground">
|
<span className="text-base font-bold" style={{ color: C.ink }}>
|
||||||
{friends.length} {friends.length === 1 ? "venn" : "venner"}
|
{friends.length} {friends.length === 1 ? "venn" : "venner"}
|
||||||
</span>
|
</span>
|
||||||
{pending > 0 ? (
|
{pending > 0 ? (
|
||||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-brand-orange/15 px-2.5 py-0.5 text-xs font-bold text-foreground">
|
<span
|
||||||
<span aria-hidden="true" className="size-1.5 rounded-full bg-brand-orange" />
|
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ backgroundColor: C.warmBg, color: C.warmInk }}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="size-1.5 rounded-full" style={{ backgroundColor: C.warmInk }} />
|
||||||
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
|
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium text-muted-foreground">Ingen nye forespørsler</span>
|
<span className="text-sm font-medium" style={{ color: C.muted }}>
|
||||||
|
Ingen nye forespørsler
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1037,7 +1224,7 @@ function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 7. Organisasjoner (bevisst nedtonet) -----------------------------------
|
// --- 11. Organisasjoner (bevisst nedtonet) -----------------------------------
|
||||||
|
|
||||||
function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
|
function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -1045,7 +1232,10 @@ function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
<DropdownMenuTrigger
|
||||||
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
style={{ color: C.muted, outlineColor: C.accent }}
|
||||||
|
>
|
||||||
<Building2 aria-hidden="true" className="size-4" />
|
<Building2 aria-hidden="true" className="size-4" />
|
||||||
Dine organisasjoner ({organizations.length})
|
Dine organisasjoner ({organizations.length})
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
|
||||||
|
|
@ -88,52 +88,67 @@ export function InstallPrompt() {
|
||||||
setDismissed(true)
|
setDismissed(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Forest Green"-visuell redesign (2026-08-01) -- samme palett som
|
||||||
|
// dashboard.tsx/login-form.tsx sin C-konstant, inline-anvendt siden dette
|
||||||
|
// er den eneste komponenten som trenger den. All logikk over uendret.
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Installer TeeCup"
|
aria-label="Installer TeeCup"
|
||||||
className="flex items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
|
className="relative overflow-hidden rounded-2xl p-4 sm:p-5"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(180deg, #1a4325 0%, #123019 100%)",
|
||||||
|
boxShadow: "0 18px 40px -22px rgba(1, 44, 17, 0.5)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
|
<div className="flex items-start gap-3.5">
|
||||||
{platform === "ios" ? (
|
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: "rgba(255,255,255,0.12)" }}>
|
||||||
<Share aria-hidden="true" className="size-5" />
|
{platform === "ios" ? (
|
||||||
) : (
|
<Share aria-hidden="true" className="size-5" color="#bfeec4" />
|
||||||
<Download aria-hidden="true" className="size-5" />
|
) : (
|
||||||
)}
|
<Download aria-hidden="true" className="size-5" color="#bfeec4" />
|
||||||
</div>
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||||
<p className="text-sm font-bold text-foreground">Legg TeeCup til på hjemskjermen</p>
|
<div className="flex flex-col gap-1 pr-6">
|
||||||
{platform === "ios" ? (
|
<h2 className="text-base font-bold text-balance" style={{ color: "#ffffff" }}>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">
|
Legg TeeCup til på hjemskjermen
|
||||||
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i Safari,
|
</h2>
|
||||||
velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
|
{platform === "ios" ? (
|
||||||
«Legg til på Hjem-skjerm».
|
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
|
||||||
</p>
|
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i
|
||||||
) : (
|
Safari, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
|
||||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">
|
«Legg til på Hjem-skjerm».
|
||||||
Rask tilgang, fungerer som en egen app, og virker delvis uten nett.
|
</p>
|
||||||
</p>
|
) : (
|
||||||
)}
|
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
|
||||||
|
Rask tilgang, fungerer som en egen app, og virker delvis uten nett.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{platform === "android" && (
|
{platform === "android" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 text-sm font-bold transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||||
|
style={{ backgroundColor: "#ffffff", color: "#012c11" }}
|
||||||
|
>
|
||||||
|
<Download aria-hidden="true" className="size-4" />
|
||||||
|
Installer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInstall}
|
onClick={handleDismiss}
|
||||||
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg bg-foreground px-4 text-sm font-bold text-background transition-colors hover:opacity-90"
|
className="inline-flex min-h-[44px] items-center rounded-xl px-3 text-sm font-semibold transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||||
|
style={{ color: "#bfeec4" }}
|
||||||
>
|
>
|
||||||
<Download aria-hidden="true" className="size-4" />
|
Ikke nå
|
||||||
Installer
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDismiss}
|
|
||||||
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-3 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
Ikke nå
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -141,7 +156,8 @@ export function InstallPrompt() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
aria-label="Lukk installasjonsoppfordringen"
|
aria-label="Lukk installasjonsoppfordringen"
|
||||||
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground"
|
className="absolute right-2 top-2 flex size-9 items-center justify-center rounded-lg transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||||
|
style={{ color: "#bfeec4" }}
|
||||||
>
|
>
|
||||||
<X aria-hidden="true" className="size-4" />
|
<X aria-hidden="true" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,34 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Mail, ArrowLeft, CheckCircle2, KeyRound, Lock } from "lucide-react"
|
import {
|
||||||
import { Button } from "@/components/ui/button"
|
Mail,
|
||||||
import { Input } from "@/components/ui/input"
|
Lock,
|
||||||
import { Label } from "@/components/ui/label"
|
KeyRound,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Flag,
|
||||||
|
} from "lucide-react"
|
||||||
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
|
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
|
||||||
|
|
||||||
|
// Locked "Forest Green" palette (V0-redesign 2026-08-01, basert på Stitch --
|
||||||
|
// se DESIGN_SYSTEM.md sitt notat om denne runden). Anvendt inline med vilje,
|
||||||
|
// slik at ingenting tolkes om ved en senere V0-reeksport.
|
||||||
|
const C = {
|
||||||
|
card: "rgba(255, 255, 255, 0.72)",
|
||||||
|
ink: "#012c11", // primærtekst + primærknapp-bakgrunn
|
||||||
|
inkOn: "#ffffff",
|
||||||
|
accent: "#1a4325", // mørkegrønne aksenter (header-gradient)
|
||||||
|
accentMuted: "#bfeec4", // dempet tekst PÅ mørkegrønn -- ALDRI #84b089/#a4a5a5 her (målt ~4.5:1, under vårt AAA-mål)
|
||||||
|
successBg: "#91f78e",
|
||||||
|
successInk: "#00731e",
|
||||||
|
muted: "#424941", // dempet brødtekst på hvit bakgrunn
|
||||||
|
border: "#c1c9bf",
|
||||||
|
fieldBg: "#ffffff",
|
||||||
|
} as const
|
||||||
|
|
||||||
const RESEND_COOLDOWN = 30 // seconds
|
const RESEND_COOLDOWN = 30 // seconds
|
||||||
|
|
||||||
type SessionUser = {
|
type SessionUser = {
|
||||||
|
|
@ -24,27 +46,31 @@ type LoginResult = {
|
||||||
two_factor_method?: "totp" | "email"
|
two_factor_method?: "totp" | "email"
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidEmail(value: string) {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADR-021 Beslutning E: primær-autentisering (magic-link ELLER passord) kan
|
// ADR-021 Beslutning E: primær-autentisering (magic-link ELLER passord) kan
|
||||||
// returnere tre ulike utfall -- delt mellom denne komponenten og
|
// returnere tre ulike utfall -- delt mellom denne komponenten og
|
||||||
// verify-form.tsx sin håndtering av magic-link-svaret.
|
// verify-form.tsx sin håndtering av magic-link-svaret.
|
||||||
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
|
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
|
||||||
|
|
||||||
|
type Mode = "default" | "sent" | "password" | "join" | "verify"
|
||||||
|
|
||||||
|
function isValidEmail(value: string) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [mode, setMode] = useState<"email" | "code" | "password">("email")
|
const [mode, setMode] = useState<Mode>("default")
|
||||||
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
|
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
|
||||||
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null)
|
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null)
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [touched, setTouched] = useState(false)
|
const [emailTouched, setEmailTouched] = useState(false)
|
||||||
const [sent, setSent] = useState(false)
|
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [cooldown, setCooldown] = useState(0)
|
const [cooldown, setCooldown] = useState(0)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const emailValid = isValidEmail(email)
|
||||||
|
const emailError = emailTouched && email.length > 0 && !emailValid
|
||||||
|
|
||||||
function handleLoginResult(result: LoginResult) {
|
function handleLoginResult(result: LoginResult) {
|
||||||
if (result.status === "success") {
|
if (result.status === "success") {
|
||||||
router.replace("/dashboard")
|
router.replace("/dashboard")
|
||||||
|
|
@ -60,15 +86,10 @@ export function LoginForm() {
|
||||||
router.replace("/dashboard")
|
router.replace("/dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailValid = isValidEmail(email)
|
|
||||||
const showError = touched && email.length > 0 && !emailValid
|
|
||||||
|
|
||||||
// Countdown timer for the resend cooldown.
|
// Countdown timer for the resend cooldown.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cooldown <= 0) return
|
if (cooldown <= 0) return
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
|
||||||
setCooldown((c) => (c <= 1 ? 0 : c - 1))
|
|
||||||
}, 1000)
|
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [cooldown])
|
}, [cooldown])
|
||||||
|
|
||||||
|
|
@ -85,7 +106,7 @@ export function LoginForm() {
|
||||||
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
|
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`request-link: ${res.status}`)
|
if (!res.ok) throw new Error(`request-link: ${res.status}`)
|
||||||
setSent(true)
|
setMode("sent")
|
||||||
setCooldown(RESEND_COOLDOWN)
|
setCooldown(RESEND_COOLDOWN)
|
||||||
} catch {
|
} catch {
|
||||||
setError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
|
setError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
|
||||||
|
|
@ -94,125 +115,400 @@ export function LoginForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function submitDefault(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setTouched(true)
|
setEmailTouched(true)
|
||||||
if (!emailValid || sending) return
|
if (!emailValid || sending) return
|
||||||
void sendLink()
|
void sendLink()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResend() {
|
function resend() {
|
||||||
if (cooldown > 0 || sending) return
|
if (cooldown > 0 || sending) return
|
||||||
void sendLink()
|
void sendLink()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function backToDefault() {
|
||||||
setSent(false)
|
setMode("default")
|
||||||
setTouched(false)
|
setEmailTouched(false)
|
||||||
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
|
<div className="relative w-full">
|
||||||
{postAuth === "2fa-verify" ? (
|
{/* Soft green glow behind the card so the glass blur has something to work against */}
|
||||||
<TwoFactorVerifyForm
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-visible">
|
||||||
method={twoFactorMethod ?? "totp"}
|
<div
|
||||||
onSuccess={handleTwoFactorSuccess}
|
className="absolute -left-10 -top-12 size-40 rounded-full blur-3xl"
|
||||||
onBack={() => setPostAuth(null)}
|
style={{ backgroundColor: "rgba(26, 67, 37, 0.18)" }}
|
||||||
/>
|
/>
|
||||||
) : postAuth === "2fa-setup" ? (
|
<div
|
||||||
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
|
className="absolute -bottom-14 -right-8 size-44 rounded-full blur-3xl"
|
||||||
) : sent ? (
|
style={{ backgroundColor: "rgba(145, 247, 142, 0.28)" }}
|
||||||
<ConfirmationState
|
|
||||||
email={email}
|
|
||||||
cooldown={cooldown}
|
|
||||||
sending={sending}
|
|
||||||
error={error}
|
|
||||||
onResend={handleResend}
|
|
||||||
onReset={handleReset}
|
|
||||||
/>
|
/>
|
||||||
) : mode === "code" ? (
|
</div>
|
||||||
<JoinByCode onBack={() => setMode("email")} />
|
|
||||||
) : mode === "password" ? (
|
|
||||||
<PasswordLoginForm onResult={handleLoginResult} onBack={() => setMode("email")} />
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="email" className="text-sm font-semibold">
|
|
||||||
E-post
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
inputMode="email"
|
|
||||||
autoComplete="email"
|
|
||||||
autoFocus
|
|
||||||
placeholder="deg@epost.no"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
onBlur={() => setTouched(true)}
|
|
||||||
aria-invalid={showError}
|
|
||||||
aria-describedby={showError ? "email-error" : undefined}
|
|
||||||
className="h-14 rounded-2xl pl-11 text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showError && (
|
|
||||||
<p id="email-error" className="text-sm font-medium text-destructive">
|
|
||||||
Skriv inn en gyldig e-postadresse.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
<div
|
||||||
<p role="alert" className="text-center text-sm font-medium text-destructive">
|
className="w-full overflow-hidden rounded-3xl border backdrop-blur-xl"
|
||||||
{error}
|
style={{
|
||||||
</p>
|
backgroundColor: C.card,
|
||||||
|
borderColor: C.border,
|
||||||
|
boxShadow: "0 24px 60px -24px rgba(1, 44, 17, 0.45), 0 8px 20px -12px rgba(1, 44, 17, 0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 2FA bruker de EKSISTERENDE, sikkerhetskritiske komponentene uendret
|
||||||
|
(egen intern overskrift) -- kun logo-raden fra det nye headeret
|
||||||
|
vises over dem, ikke tittel/undertekst-blokken, for å unngå
|
||||||
|
dobbel overskrift. */}
|
||||||
|
<CardHeader mode={mode} showTitle={postAuth === null} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 p-6 sm:p-7">
|
||||||
|
{postAuth === "2fa-verify" ? (
|
||||||
|
<TwoFactorVerifyForm
|
||||||
|
method={twoFactorMethod ?? "totp"}
|
||||||
|
onSuccess={handleTwoFactorSuccess}
|
||||||
|
onBack={() => setPostAuth(null)}
|
||||||
|
/>
|
||||||
|
) : postAuth === "2fa-setup" ? (
|
||||||
|
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
|
||||||
|
) : mode === "default" ? (
|
||||||
|
<DefaultState
|
||||||
|
email={email}
|
||||||
|
setEmail={setEmail}
|
||||||
|
emailError={emailError}
|
||||||
|
onBlur={() => setEmailTouched(true)}
|
||||||
|
onSubmit={submitDefault}
|
||||||
|
sending={sending}
|
||||||
|
error={error}
|
||||||
|
onPassword={() => setMode("password")}
|
||||||
|
onJoin={() => setMode("join")}
|
||||||
|
/>
|
||||||
|
) : mode === "sent" ? (
|
||||||
|
<SentState
|
||||||
|
email={email}
|
||||||
|
cooldown={cooldown}
|
||||||
|
sending={sending}
|
||||||
|
error={error}
|
||||||
|
onResend={resend}
|
||||||
|
onBack={backToDefault}
|
||||||
|
/>
|
||||||
|
) : mode === "password" ? (
|
||||||
|
<PasswordState onResult={handleLoginResult} onBack={() => setMode("default")} />
|
||||||
|
) : (
|
||||||
|
<JoinState onBack={() => setMode("default")} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<Button
|
/* --- Shared shell -------------------------------------------------------- */
|
||||||
type="submit"
|
|
||||||
disabled={sending}
|
|
||||||
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
||||||
>
|
|
||||||
{sending ? "Sender …" : "Send innloggingslenke"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
|
const HEADINGS: Record<Mode, { title: string; subtitle: string }> = {
|
||||||
Ingen passord nødvendig. Vi sender deg en sikker lenke på e-post.
|
default: {
|
||||||
|
title: "Velkommen til TeeCup",
|
||||||
|
subtitle: "Logg inn for å følge turneringen din live.",
|
||||||
|
},
|
||||||
|
sent: {
|
||||||
|
title: "Sjekk innboksen din",
|
||||||
|
subtitle: "Vi har sendt deg en sikker innloggingslenke.",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
title: "Logg inn med passord",
|
||||||
|
subtitle: "Skriv inn e-post og passord for å fortsette.",
|
||||||
|
},
|
||||||
|
join: {
|
||||||
|
title: "Gå rett til turneringen",
|
||||||
|
subtitle: "Har du fått en invitasjonskode? Skriv den inn her.",
|
||||||
|
},
|
||||||
|
verify: {
|
||||||
|
title: "Bekreft innloggingen",
|
||||||
|
subtitle: "Vi har sendt en kode for å bekrefte at det er deg.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ mode, showTitle }: { mode: Mode; showTitle: boolean }) {
|
||||||
|
const { title, subtitle } = HEADINGS[mode]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-3 px-6 pt-8 pb-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(180deg, ${C.accent} 0%, #123019 100%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div
|
||||||
|
className="flex size-11 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: "rgba(255,255,255,0.12)" }}
|
||||||
|
>
|
||||||
|
<Flag aria-hidden="true" className="size-6 text-brand-orange" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-extrabold tracking-tight" style={{ color: C.inkOn }}>
|
||||||
|
TeeCup
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showTitle && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-balance" style={{ color: C.inkOn }}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.accentMuted }}>
|
||||||
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMode("password")}
|
|
||||||
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Lock aria-hidden="true" className="size-4" />
|
|
||||||
Logg inn med e-post og passord i stedet
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMode("code")}
|
|
||||||
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<KeyRound aria-hidden="true" className="size-4" />
|
|
||||||
Har du en invitasjonskode? Gå rett til turneringen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Passord-innlogging (ADR-021 Beslutning A) -- et sidestilt, valgfritt
|
/* --- Reusable field + button -------------------------------------------- */
|
||||||
// alternativ til magic-link, ALDRI en erstatning. -----------------------
|
|
||||||
|
|
||||||
function PasswordLoginForm({
|
function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label htmlFor={htmlFor} className="text-sm font-semibold" style={{ color: C.ink }}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextInput({
|
||||||
|
id,
|
||||||
|
icon: Icon,
|
||||||
|
invalid,
|
||||||
|
trailing,
|
||||||
|
...props
|
||||||
|
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
id: string
|
||||||
|
icon: typeof Mail
|
||||||
|
invalid?: boolean
|
||||||
|
trailing?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2"
|
||||||
|
style={{ color: C.muted }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
className="h-14 w-full rounded-2xl border pl-12 pr-4 text-base font-medium outline-none transition-shadow placeholder:font-normal focus-visible:ring-2 focus-visible:ring-[#1a4325] focus-visible:ring-offset-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: C.fieldBg,
|
||||||
|
borderColor: invalid ? "#b3261e" : C.border,
|
||||||
|
color: C.ink,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{trailing}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryButton({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex h-14 w-full items-center justify-center rounded-2xl text-base font-bold transition-opacity disabled:opacity-60"
|
||||||
|
style={{ backgroundColor: C.ink, color: C.inkOn }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl px-3 text-sm font-semibold transition-colors hover:underline"
|
||||||
|
style={{ color: C.accent }}
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryLink({
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: typeof Lock
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex min-h-12 items-center gap-2.5 rounded-xl px-2 text-left text-sm font-semibold transition-colors hover:underline"
|
||||||
|
style={{ color: C.accent }}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- State 1: Default (magic link), ekte /auth/request-link -------------- */
|
||||||
|
|
||||||
|
function DefaultState({
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
emailError,
|
||||||
|
onBlur,
|
||||||
|
onSubmit,
|
||||||
|
sending,
|
||||||
|
error,
|
||||||
|
onPassword,
|
||||||
|
onJoin,
|
||||||
|
}: {
|
||||||
|
email: string
|
||||||
|
setEmail: (v: string) => void
|
||||||
|
emailError: boolean
|
||||||
|
onBlur: () => void
|
||||||
|
onSubmit: (e: React.FormEvent) => void
|
||||||
|
sending: boolean
|
||||||
|
error: string | null
|
||||||
|
onPassword: () => void
|
||||||
|
onJoin: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-6" noValidate>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<FieldLabel htmlFor="email">E-post</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
id="email"
|
||||||
|
icon={Mail}
|
||||||
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
placeholder="deg@epost.no"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
invalid={emailError}
|
||||||
|
aria-invalid={emailError}
|
||||||
|
aria-describedby={emailError ? "email-error" : undefined}
|
||||||
|
/>
|
||||||
|
{emailError && (
|
||||||
|
<p id="email-error" className="text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||||
|
Skriv inn en gyldig e-postadresse.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PrimaryButton type="submit" disabled={sending}>
|
||||||
|
{sending ? "Sender …" : "Send innloggingslenke"}
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
|
<p className="text-center text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
|
||||||
|
Ingen passord nødvendig. Vi sender deg en sikker lenke på e-post.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 border-t pt-4" style={{ borderColor: C.border }}>
|
||||||
|
<SecondaryLink icon={Lock} onClick={onPassword}>
|
||||||
|
Logg inn med e-post og passord i stedet
|
||||||
|
</SecondaryLink>
|
||||||
|
<SecondaryLink icon={KeyRound} onClick={onJoin}>
|
||||||
|
Har du en invitasjonskode? Gå rett til turneringen
|
||||||
|
</SecondaryLink>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- State 2: Link sent, ekte resend/cooldown ----------------------------- */
|
||||||
|
|
||||||
|
function SentState({
|
||||||
|
email,
|
||||||
|
cooldown,
|
||||||
|
sending,
|
||||||
|
error,
|
||||||
|
onResend,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
email: string
|
||||||
|
cooldown: number
|
||||||
|
sending: boolean
|
||||||
|
error: string | null
|
||||||
|
onResend: () => void
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
const liveRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
liveRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-5 text-center">
|
||||||
|
<div
|
||||||
|
ref={liveRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex size-16 items-center justify-center rounded-2xl outline-none"
|
||||||
|
style={{ backgroundColor: C.successBg }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 aria-hidden="true" className="size-9" style={{ color: C.successInk }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full rounded-2xl px-4 py-3" style={{ backgroundColor: C.successBg }} aria-live="polite">
|
||||||
|
<p className="text-sm font-semibold leading-relaxed" style={{ color: C.successInk }}>
|
||||||
|
Lenke sendt til <span className="font-extrabold">{email || "din e-post"}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
|
||||||
|
Åpne lenken på denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
{cooldown > 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: C.muted }} aria-live="polite">
|
||||||
|
Fikk du ingen e-post? Send igjen om{" "}
|
||||||
|
<span className="font-bold tabular-nums" style={{ color: C.ink }}>
|
||||||
|
{cooldown}s
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResend}
|
||||||
|
disabled={sending}
|
||||||
|
className="inline-flex min-h-12 items-center justify-center rounded-xl px-4 text-sm font-bold transition-opacity hover:underline disabled:opacity-60"
|
||||||
|
style={{ color: C.ink }}
|
||||||
|
>
|
||||||
|
{sending ? "Sender …" : "Send lenken igjen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full border-t pt-4" style={{ borderColor: C.border }}>
|
||||||
|
<BackLink onClick={onBack}>Bruk en annen e-postadresse</BackLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- State 3: Password, ekte /auth/login-password ------------------------ */
|
||||||
|
|
||||||
|
function PasswordState({
|
||||||
onResult,
|
onResult,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -221,6 +517,7 @@ function PasswordLoginForm({
|
||||||
}) {
|
}) {
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -251,81 +548,65 @@ function PasswordLoginForm({
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="pw-email" className="text-sm font-semibold">
|
<FieldLabel htmlFor="pw-email">E-post</FieldLabel>
|
||||||
E-post
|
<TextInput
|
||||||
</Label>
|
id="pw-email"
|
||||||
<div className="relative">
|
icon={Mail}
|
||||||
<Mail
|
type="email"
|
||||||
aria-hidden="true"
|
inputMode="email"
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
autoComplete="email"
|
||||||
/>
|
autoFocus
|
||||||
<Input
|
placeholder="deg@epost.no"
|
||||||
id="pw-email"
|
value={email}
|
||||||
type="email"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
inputMode="email"
|
/>
|
||||||
autoComplete="email"
|
|
||||||
autoFocus
|
|
||||||
placeholder="deg@epost.no"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="h-14 rounded-2xl pl-11 text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="pw-password" className="text-sm font-semibold">
|
<FieldLabel htmlFor="pw-pass">Passord</FieldLabel>
|
||||||
Passord
|
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
|
||||||
</Label>
|
<TextInput
|
||||||
<div className="relative">
|
id="pw-pass"
|
||||||
<Lock
|
icon={Lock}
|
||||||
aria-hidden="true"
|
type={showPassword ? "text" : "password"}
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
autoComplete="current-password"
|
||||||
/>
|
placeholder="Passordet ditt"
|
||||||
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
|
value={password}
|
||||||
<Input
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
id="pw-password"
|
trailing={
|
||||||
type="password"
|
<button
|
||||||
autoComplete="current-password"
|
type="button"
|
||||||
placeholder="Passordet ditt"
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
value={password}
|
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
className="absolute right-2 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-xl transition-colors"
|
||||||
className="h-14 rounded-2xl pl-11 text-base"
|
style={{ color: C.muted }}
|
||||||
/>
|
>
|
||||||
</div>
|
{showPassword ? <EyeOff aria-hidden="true" className="size-5" /> : <Eye aria-hidden="true" className="size-5" />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-center text-sm font-medium text-destructive">
|
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<PrimaryButton type="submit" disabled={submitting || !email.trim() || !password}>
|
||||||
type="submit"
|
|
||||||
disabled={submitting || !email.trim() || !password}
|
|
||||||
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
||||||
>
|
|
||||||
{submitting ? "Logger inn …" : "Logg inn"}
|
{submitting ? "Logger inn …" : "Logg inn"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
|
|
||||||
<button
|
<div className="border-t pt-4" style={{ borderColor: C.border }}>
|
||||||
type="button"
|
<BackLink onClick={onBack}>Bruk innloggingslenke på e-post i stedet</BackLink>
|
||||||
onClick={onBack}
|
</div>
|
||||||
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
||||||
Bruk innloggingslenke på e-post i stedet
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort,
|
/* --- State 4: Join by code, ekte /public/tournaments/by-code -------------- */
|
||||||
// menneske-skrivbar kode -- FØR innlogging, og uansett turneringens
|
|
||||||
// synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). ---------
|
|
||||||
|
|
||||||
function JoinByCode({ onBack }: { onBack: () => void }) {
|
function JoinState({ onBack }: { onBack: () => void }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -356,134 +637,39 @@ function JoinByCode({ onBack }: { onBack: () => void }) {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="join-code" className="text-sm font-semibold">
|
<FieldLabel htmlFor="invite-code">Invitasjonskode</FieldLabel>
|
||||||
Invitasjonskode
|
<TextInput
|
||||||
</Label>
|
id="invite-code"
|
||||||
<div className="relative">
|
icon={KeyRound}
|
||||||
<KeyRound
|
type="text"
|
||||||
aria-hidden="true"
|
inputMode="text"
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
autoCapitalize="characters"
|
||||||
/>
|
autoComplete="off"
|
||||||
<Input
|
autoFocus
|
||||||
id="join-code"
|
placeholder="F.eks. RAPWAR"
|
||||||
type="text"
|
value={code}
|
||||||
autoCapitalize="characters"
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
autoComplete="off"
|
style={{ letterSpacing: "0.08em" }}
|
||||||
autoFocus
|
/>
|
||||||
placeholder="F.eks. RAPWAR"
|
<p className="text-sm leading-relaxed" style={{ color: C.muted }}>
|
||||||
value={code}
|
Fått en kode muntlig eller på en lapp? Skriv den inn her — du trenger ikke logge inn for å
|
||||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
se turneringen eller melde deg på.
|
||||||
className="h-14 rounded-2xl pl-11 text-base tracking-widest"
|
</p>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-center text-sm font-medium text-destructive">
|
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<PrimaryButton type="submit" disabled={loading || !code.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={loading || !code.trim()}
|
|
||||||
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
||||||
>
|
|
||||||
{loading ? "Sjekker …" : "Gå til turnering"}
|
{loading ? "Sjekker …" : "Gå til turnering"}
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
|
|
||||||
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
|
<div className="border-t pt-4" style={{ borderColor: C.border }}>
|
||||||
Fått en kode muntlig eller på en lapp? Skriv den inn her — du trenger ikke logge inn for
|
<BackLink onClick={onBack}>Tilbake til innlogging</BackLink>
|
||||||
å se turneringen eller melde deg på.
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
||||||
Tilbake til innlogging
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmationState({
|
|
||||||
email,
|
|
||||||
cooldown,
|
|
||||||
sending,
|
|
||||||
error,
|
|
||||||
onResend,
|
|
||||||
onReset,
|
|
||||||
}: {
|
|
||||||
email: string
|
|
||||||
cooldown: number
|
|
||||||
sending: boolean
|
|
||||||
error: string | null
|
|
||||||
onResend: () => void
|
|
||||||
onReset: () => void
|
|
||||||
}) {
|
|
||||||
const liveRef = useRef<HTMLHeadingElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
liveRef.current?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-5 text-center">
|
|
||||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
|
||||||
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h2
|
|
||||||
ref={liveRef}
|
|
||||||
tabIndex={-1}
|
|
||||||
className="text-2xl font-extrabold tracking-tight outline-none text-balance"
|
|
||||||
>
|
|
||||||
Sjekk innboksen din
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
||||||
Vi har sendt en innloggingslenke til{" "}
|
|
||||||
<span className="font-semibold text-foreground">{email}</span>. Åpne den på denne enheten for å logge inn.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="text-sm font-medium text-destructive">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-1 w-full">
|
|
||||||
{cooldown > 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
|
||||||
Fikk du ingen e-post? Send på nytt om{" "}
|
|
||||||
<span className="font-semibold text-foreground tabular-nums">{cooldown}s</span>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onResend}
|
|
||||||
disabled={sending}
|
|
||||||
className="h-11 rounded-xl font-semibold text-primary hover:bg-primary/10 hover:text-primary"
|
|
||||||
>
|
|
||||||
{sending ? "Sender …" : "Send lenken på nytt"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onReset}
|
|
||||||
className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
||||||
Bruk en annen e-post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Loading…
Reference in a new issue