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:
Erol Haagenrud 2026-08-01 21:15:07 +02:00
parent b99c9a6ae0
commit 27bff38afe
10 changed files with 927 additions and 431 deletions

View file

@ -382,7 +382,15 @@
"Bash(python3 test_round_starthole_time_nearby.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)",
"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": [
"/opt/teeoff/deploy",

View file

@ -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]
# ---------------------------------------------------------------------------
# "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 --
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en

View file

@ -1,7 +1,6 @@
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { LoginForm } from "@/components/login-form"
import { Wordmark } from "@/components/wordmark"
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
// next.config.mjs sin rewrites() (samme mønster som generateMetadata i
@ -50,13 +49,10 @@ export default async function Page() {
return (
<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">
<header className="flex flex-col items-center gap-3 text-center">
<Wordmark />
<p className="text-balance text-base leading-relaxed text-muted-foreground">
Logg inn for å følge turneringen din live.
</p>
</header>
{/* Redesignet kort (2026-08-01, V0/Forest Green) har sin egen
innebygde header (logo + tittel/undertekst per tilstand) --
den forrige separate Wordmark+tagline-headeren over kortet er
derfor fjernet for å unngå dobbel "TeeCup"-overskrift. */}
<LoginForm />
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">

View file

@ -7,6 +7,21 @@
// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget
// "opprett organisasjon"-steg for det vanlige tilfellet) -- se
// 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 { useCallback, useEffect, useState } from "react"
@ -21,10 +36,9 @@ import {
KeyRound,
LogOut,
MapPin,
Plus,
Trophy,
TrendingDown,
TrendingUp,
Trophy,
UserCircle,
UserPlus,
X,
@ -38,13 +52,32 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Wordmark } from "@/components/wordmark"
import { InstallPrompt } from "@/components/install-prompt"
import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
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 ---------------------------------------------------------------
type MyOrg = { organization_id: string; name: string; role: string }
@ -118,6 +151,21 @@ type ApiFriendsSummary = {
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 {
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)
}
// "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 ----------------------------------------------------------
export function Dashboard() {
@ -222,6 +281,7 @@ export function Dashboard() {
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null)
const [friendsOnCourse, setFriendsOnCourse] = useState<ApiFriendOnCourse[]>([])
const [unreadNotifications, setUnreadNotifications] = useState(0)
const [error, setError] = useState<string | null>(null)
@ -262,6 +322,10 @@ export function Dashboard() {
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFriendsSummary | null) => setFriendsSummary(data))
.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" })
.then((res) => (res.ok ? res.json() : { count: 0 }))
.then((data: { count: number }) => setUnreadNotifications(data.count))
@ -349,8 +413,8 @@ export function Dashboard() {
if (loadingMe) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<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" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
</div>
)
}
@ -386,15 +450,26 @@ export function Dashboard() {
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
<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">
<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">
<NotificationBell unreadCount={unreadNotifications} />
<Link
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" />
Konto
@ -402,7 +477,8 @@ export function Dashboard() {
<button
type="button"
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" />
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">
<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}
</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 ett sted.
</p>
</div>
{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}
</p>
)}
@ -438,6 +514,7 @@ export function Dashboard() {
open={quickActionOpen}
onOpenChange={setQuickActionOpen}
/>
<LiveFriends friends={friendsOnCourse} />
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
<UpcomingTournaments
entries={combinedTournaments}
@ -466,14 +543,15 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
<Link
href="/my-notifications"
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" />
{hasUnread && (
<span
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"
style={{ height: 18 }}
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, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.glass}` }}
>
{badgeText}
</span>
@ -482,7 +560,7 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
)
}
// --- 1. Hurtighandlinger -----------------------------------------------------
// --- 4. Hurtighandlinger -----------------------------------------------------
function QuickActions({
organizations,
@ -521,28 +599,37 @@ function QuickAction({
href?: string
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 =
"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 = (
<>
<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>
</>
)
if (href) {
return (
<Link href={href} className={className}>
<Link href={href} className={className} style={style}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className}>
<button type="button" onClick={onClick} className={className} style={style}>
{inner}
</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({
organizations,
onCreate,
@ -709,7 +796,9 @@ function JoinByCodeInline({ onClose }: { onClose: () => void }) {
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<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}
</div>
)
@ -719,7 +808,8 @@ function SeeAllLink({ href, label }: { href: string; label: string }) {
return (
<Link
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}
<ChevronRight aria-hidden="true" className="size-4" />
@ -739,13 +829,20 @@ function EmptyState({
children?: React.ReactNode
}) {
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 className="flex size-12 items-center justify-center rounded-xl bg-muted">
<Icon aria-hidden="true" className="size-6 text-muted-foreground" />
<div
className="flex flex-col items-center gap-3 rounded-2xl border border-dashed px-6 py-8 text-center"
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 className="flex flex-col gap-1">
<h3 className="text-base font-bold text-foreground text-balance">{title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{description}</p>
<h3 className="text-base font-bold text-balance" style={{ color: C.ink }}>
{title}
</h3>
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
{description}
</p>
</div>
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
</div>
@ -765,10 +862,12 @@ function ShortcutButton({
onClick?: () => void
variant?: "primary" | "outline"
}) {
const className = cn(
"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" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50",
)
const style: React.CSSProperties =
variant === "primary"
? { 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 = (
<>
<Icon aria-hidden="true" className="size-4" />
@ -777,19 +876,97 @@ function ShortcutButton({
)
if (href) {
return (
<Link href={href} className={className}>
<Link href={href} className={className} style={style}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className}>
<button type="button" onClick={onClick} className={className} style={style}>
{inner}
</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 banen akkurat .
</p>
)}
</section>
)
}
// --- 6. Runder --------------------------------------------------------
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
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} />
{loading ? (
<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>
) : visible.length > 0 ? (
<div className="flex flex-col gap-3">
@ -808,14 +985,14 @@ function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean
</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.">
<ShortcutButton icon={Plus} label="Ny runde" href="/my-rounds/new" />
<ShortcutButton icon={Flag} label="Ny runde" href="/my-rounds/new" />
</EmptyState>
)}
</section>
)
}
// --- 3. Kommende turneringer -----------------------------------------------
// --- 7. Kommende turneringer -----------------------------------------------
function UpcomingTournaments({
entries,
@ -845,7 +1022,7 @@ function UpcomingTournaments({
)
}
// --- 4. Statistikk ---------------------------------------------------------
// --- 8. Statistikk ---------------------------------------------------------
function Sparkline({ values }: { values: number[] }) {
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">
{values.map((v, i) => {
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>
)
@ -864,9 +1047,16 @@ function Sparkline({ values }: { values: number[] }) {
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
<div
className="flex flex-col gap-1.5 rounded-2xl p-4"
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}
</div>
)
@ -897,15 +1087,17 @@ function StatsSection({
<StatTile label="HCP nå" value={hcpNow}>
{hcpTrend !== null ? (
<div className="flex items-center justify-between gap-1">
<span className="inline-flex items-center gap-1 text-xs font-bold text-foreground">
<TrendIcon aria-hidden="true" className="size-4 text-primary" />
<span className="inline-flex items-center gap-1 text-xs font-bold" style={{ color: C.ink }}>
<TrendIcon aria-hidden="true" className="size-4" color={C.successInk} />
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
</span>
<Sparkline values={hcpHistory} />
</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 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 }[] }) {
return (
<section aria-label="Spilte baner">
<SectionHeader title="Spilte baner" />
{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">
{courses.map((course) => {
<ul
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"
return (
<li key={course.name}>
<li key={course.name} style={i > 0 ? { borderTop: `1px solid ${C.border}` } : undefined}>
<Link
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">
<MapPin aria-hidden="true" className="size-5" />
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
<MapPin aria-hidden="true" className="size-5" color={C.ink} />
</span>
<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-sm font-medium text-muted-foreground">
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
{course.name}
</span>
<span className="truncate text-sm font-medium" style={{ color: C.muted }}>
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
</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>
</li>
)
@ -952,8 +1150,9 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
)
}
// --- 6. Venner ---------------------------------------------------------------
// ADR-036 fase 1 (venner-kjernen) er live -- ekte data fra GET /friends.
// --- 10. Venner ---------------------------------------------------------------
// 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 {
const f = (first ?? "").trim()[0] ?? ""
@ -961,68 +1160,56 @@ function friendInitials(first: string | null, last: string | null): string {
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 }) {
const friends = summary?.friends ?? []
const pending = summary?.incoming_requests.length ?? 0
const visible = friends.slice(0, 4)
const overflow = friends.length - visible.length
return (
<section aria-label="Venner">
<SectionHeader title="Venner" />
{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">
{friends.slice(0, 4).map((friend, i) => (
{visible.map((friend, i) => (
<span
key={i}
className={cn(
"flex size-11 items-center justify-center rounded-full border-2 border-card text-sm font-bold",
avatarColorClass(friend.first_name, friend.last_name),
i > 0 && "-ml-3",
)}
className="flex size-11 items-center justify-center rounded-full text-sm font-bold"
style={{ backgroundColor: C.tint, color: C.ink, border: `2px solid ${C.card}`, marginLeft: i > 0 ? "-0.75rem" : undefined }}
>
{friendInitials(friend.first_name, friend.last_name)}
</span>
))}
{friends.length > 4 && (
<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">
+{friends.length - 4}
{overflow > 0 && (
<span
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>
)}
</div>
<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"}
</span>
{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 aria-hidden="true" className="size-1.5 rounded-full bg-brand-orange" />
<span
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"}
</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>
@ -1037,7 +1224,7 @@ function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
)
}
// --- 7. Organisasjoner (bevisst nedtonet) -----------------------------------
// --- 11. Organisasjoner (bevisst nedtonet) -----------------------------------
function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
const router = useRouter()
@ -1045,7 +1232,10 @@ function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
return (
<div className="flex justify-center pt-2">
<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" />
Dine organisasjoner ({organizations.length})
</DropdownMenuTrigger>

View file

@ -88,52 +88,67 @@ export function InstallPrompt() {
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 (
<div
role="region"
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">
{platform === "ios" ? (
<Share aria-hidden="true" className="size-5" />
) : (
<Download aria-hidden="true" className="size-5" />
)}
</div>
<div className="flex items-start gap-3.5">
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: "rgba(255,255,255,0.12)" }}>
{platform === "ios" ? (
<Share aria-hidden="true" className="size-5" color="#bfeec4" />
) : (
<Download aria-hidden="true" className="size-5" color="#bfeec4" />
)}
</span>
<div className="flex-1">
<p className="text-sm font-bold text-foreground">Legg TeeCup til hjemskjermen</p>
{platform === "ios" ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">
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" />{" "}
«Legg til Hjem-skjerm».
</p>
) : (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Rask tilgang, fungerer som en egen app, og virker delvis uten nett.
</p>
)}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-col gap-1 pr-6">
<h2 className="text-base font-bold text-balance" style={{ color: "#ffffff" }}>
Legg TeeCup til hjemskjermen
</h2>
{platform === "ios" ? (
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
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" />{" "}
«Legg til Hjem-skjerm».
</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">
{platform === "android" && (
<div className="flex items-center gap-2">
{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
type="button"
onClick={handleInstall}
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"
onClick={handleDismiss}
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" />
Installer
Ikke
</button>
)}
<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
</button>
</div>
</div>
</div>
@ -141,7 +156,8 @@ export function InstallPrompt() {
type="button"
onClick={handleDismiss}
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" />
</button>

View file

@ -3,12 +3,34 @@
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { Mail, ArrowLeft, CheckCircle2, KeyRound, Lock } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Mail,
Lock,
KeyRound,
ArrowLeft,
CheckCircle2,
Eye,
EyeOff,
Flag,
} from "lucide-react"
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
// Locked "Forest Green" palette (V0-redesign 2026-08-01, basert på Stitch --
// se DESIGN_SYSTEM.md sitt notat om denne runden). Anvendt inline med vilje,
// slik at ingenting tolkes om ved en senere V0-reeksport.
const C = {
card: "rgba(255, 255, 255, 0.72)",
ink: "#012c11", // primærtekst + primærknapp-bakgrunn
inkOn: "#ffffff",
accent: "#1a4325", // mørkegrønne aksenter (header-gradient)
accentMuted: "#bfeec4", // dempet tekst PÅ mørkegrønn -- ALDRI #84b089/#a4a5a5 her (målt ~4.5:1, under vårt AAA-mål)
successBg: "#91f78e",
successInk: "#00731e",
muted: "#424941", // dempet brødtekst på hvit bakgrunn
border: "#c1c9bf",
fieldBg: "#ffffff",
} as const
const RESEND_COOLDOWN = 30 // seconds
type SessionUser = {
@ -24,27 +46,31 @@ type LoginResult = {
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
// returnere tre ulike utfall -- delt mellom denne komponenten og
// verify-form.tsx sin håndtering av magic-link-svaret.
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
type Mode = "default" | "sent" | "password" | "join" | "verify"
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
export function LoginForm() {
const router = useRouter()
const [mode, setMode] = useState<"email" | "code" | "password">("email")
const [mode, setMode] = useState<Mode>("default")
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null)
const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false)
const [sent, setSent] = useState(false)
const [emailTouched, setEmailTouched] = useState(false)
const [sending, setSending] = useState(false)
const [cooldown, setCooldown] = useState(0)
const [error, setError] = useState<string | null>(null)
const emailValid = isValidEmail(email)
const emailError = emailTouched && email.length > 0 && !emailValid
function handleLoginResult(result: LoginResult) {
if (result.status === "success") {
router.replace("/dashboard")
@ -60,15 +86,10 @@ export function LoginForm() {
router.replace("/dashboard")
}
const emailValid = isValidEmail(email)
const showError = touched && email.length > 0 && !emailValid
// Countdown timer for the resend cooldown.
useEffect(() => {
if (cooldown <= 0) return
const id = setInterval(() => {
setCooldown((c) => (c <= 1 ? 0 : c - 1))
}, 1000)
const id = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
return () => clearInterval(id)
}, [cooldown])
@ -85,7 +106,7 @@ export function LoginForm() {
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
})
if (!res.ok) throw new Error(`request-link: ${res.status}`)
setSent(true)
setMode("sent")
setCooldown(RESEND_COOLDOWN)
} catch {
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()
setTouched(true)
setEmailTouched(true)
if (!emailValid || sending) return
void sendLink()
}
function handleResend() {
function resend() {
if (cooldown > 0 || sending) return
void sendLink()
}
function handleReset() {
setSent(false)
setTouched(false)
function backToDefault() {
setMode("default")
setEmailTouched(false)
setError(null)
}
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
{postAuth === "2fa-verify" ? (
<TwoFactorVerifyForm
method={twoFactorMethod ?? "totp"}
onSuccess={handleTwoFactorSuccess}
onBack={() => setPostAuth(null)}
<div className="relative w-full">
{/* Soft green glow behind the card so the glass blur has something to work against */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-visible">
<div
className="absolute -left-10 -top-12 size-40 rounded-full blur-3xl"
style={{ backgroundColor: "rgba(26, 67, 37, 0.18)" }}
/>
) : postAuth === "2fa-setup" ? (
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
) : sent ? (
<ConfirmationState
email={email}
cooldown={cooldown}
sending={sending}
error={error}
onResend={handleResend}
onReset={handleReset}
<div
className="absolute -bottom-14 -right-8 size-44 rounded-full blur-3xl"
style={{ backgroundColor: "rgba(145, 247, 142, 0.28)" }}
/>
) : mode === "code" ? (
<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>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
<div
className="w-full overflow-hidden rounded-3xl border backdrop-blur-xl"
style={{
backgroundColor: C.card,
borderColor: C.border,
boxShadow: "0 24px 60px -24px rgba(1, 44, 17, 0.45), 0 8px 20px -12px rgba(1, 44, 17, 0.2)",
}}
>
{/* 2FA bruker de EKSISTERENDE, sikkerhetskritiske komponentene uendret
(egen intern overskrift) -- kun logo-raden fra det nye headeret
vises over dem, ikke tittel/undertekst-blokken, for å unngå
dobbel overskrift. */}
<CardHeader mode={mode} showTitle={postAuth === null} />
<div className="flex flex-col gap-6 p-6 sm:p-7">
{postAuth === "2fa-verify" ? (
<TwoFactorVerifyForm
method={twoFactorMethod ?? "totp"}
onSuccess={handleTwoFactorSuccess}
onBack={() => setPostAuth(null)}
/>
) : postAuth === "2fa-setup" ? (
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
) : mode === "default" ? (
<DefaultState
email={email}
setEmail={setEmail}
emailError={emailError}
onBlur={() => setEmailTouched(true)}
onSubmit={submitDefault}
sending={sending}
error={error}
onPassword={() => setMode("password")}
onJoin={() => setMode("join")}
/>
) : mode === "sent" ? (
<SentState
email={email}
cooldown={cooldown}
sending={sending}
error={error}
onResend={resend}
onBack={backToDefault}
/>
) : mode === "password" ? (
<PasswordState onResult={handleLoginResult} onBack={() => setMode("default")} />
) : (
<JoinState onBack={() => setMode("default")} />
)}
</div>
</div>
</div>
)
}
<Button
type="submit"
disabled={sending}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{sending ? "Sender …" : "Send innloggingslenke"}
</Button>
/* --- Shared shell -------------------------------------------------------- */
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen passord nødvendig. Vi sender deg en sikker lenke e-post.
const HEADINGS: Record<Mode, { title: string; subtitle: string }> = {
default: {
title: "Velkommen til TeeCup",
subtitle: "Logg inn for å følge turneringen din live.",
},
sent: {
title: "Sjekk innboksen din",
subtitle: "Vi har sendt deg en sikker innloggingslenke.",
},
password: {
title: "Logg inn med passord",
subtitle: "Skriv inn e-post og passord for å fortsette.",
},
join: {
title: "Gå rett til turneringen",
subtitle: "Har du fått en invitasjonskode? Skriv den inn her.",
},
verify: {
title: "Bekreft innloggingen",
subtitle: "Vi har sendt en kode for å bekrefte at det er deg.",
},
}
function CardHeader({ mode, showTitle }: { mode: Mode; showTitle: boolean }) {
const { title, subtitle } = HEADINGS[mode]
return (
<div
className="flex flex-col items-center gap-3 px-6 pt-8 pb-6 text-center"
style={{
background: `linear-gradient(180deg, ${C.accent} 0%, #123019 100%)`,
}}
>
<div className="flex items-center gap-2.5">
<div
className="flex size-11 items-center justify-center rounded-2xl"
style={{ backgroundColor: "rgba(255,255,255,0.12)" }}
>
<Flag aria-hidden="true" className="size-6 text-brand-orange" fill="currentColor" />
</div>
<span className="text-2xl font-extrabold tracking-tight" style={{ color: C.inkOn }}>
TeeCup
</span>
</div>
{showTitle && (
<div className="flex flex-col gap-1">
<h1 className="text-xl font-bold tracking-tight text-balance" style={{ color: C.inkOn }}>
{title}
</h1>
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.accentMuted }}>
{subtitle}
</p>
<div 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? rett til turneringen
</button>
</div>
</form>
</div>
)}
</div>
)
}
// --- Passord-innlogging (ADR-021 Beslutning A) -- et sidestilt, valgfritt
// alternativ til magic-link, ALDRI en erstatning. -----------------------
/* --- Reusable field + button -------------------------------------------- */
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 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? 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 denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
</p>
{error && (
<p role="alert" className="text-sm font-medium" style={{ color: "#b3261e" }}>
{error}
</p>
)}
<div className="w-full">
{cooldown > 0 ? (
<p className="text-sm" style={{ color: C.muted }} aria-live="polite">
Fikk du ingen e-post? Send igjen om{" "}
<span className="font-bold tabular-nums" style={{ color: C.ink }}>
{cooldown}s
</span>
</p>
) : (
<button
type="button"
onClick={onResend}
disabled={sending}
className="inline-flex min-h-12 items-center justify-center rounded-xl px-4 text-sm font-bold transition-opacity hover:underline disabled:opacity-60"
style={{ color: C.ink }}
>
{sending ? "Sender …" : "Send lenken igjen"}
</button>
)}
</div>
<div className="w-full border-t pt-4" style={{ borderColor: C.border }}>
<BackLink onClick={onBack}>Bruk en annen e-postadresse</BackLink>
</div>
</div>
)
}
/* --- State 3: Password, ekte /auth/login-password ------------------------ */
function PasswordState({
onResult,
onBack,
}: {
@ -221,6 +517,7 @@ function PasswordLoginForm({
}) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -251,81 +548,65 @@ function PasswordLoginForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="pw-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="pw-email"
type="email"
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>
<FieldLabel htmlFor="pw-email">E-post</FieldLabel>
<TextInput
id="pw-email"
icon={Mail}
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pw-password" className="text-sm font-semibold">
Passord
</Label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
<Input
id="pw-password"
type="password"
autoComplete="current-password"
placeholder="Passordet ditt"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-14 rounded-2xl pl-11 text-base"
/>
</div>
<FieldLabel htmlFor="pw-pass">Passord</FieldLabel>
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
<TextInput
id="pw-pass"
icon={Lock}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Passordet ditt"
value={password}
onChange={(e) => setPassword(e.target.value)}
trailing={
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
className="absolute right-2 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-xl transition-colors"
style={{ color: C.muted }}
>
{showPassword ? <EyeOff aria-hidden="true" className="size-5" /> : <Eye aria-hidden="true" className="size-5" />}
</button>
}
/>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
<p role="alert" className="text-center text-sm font-medium" style={{ color: "#b3261e" }}>
{error}
</p>
)}
<Button
type="submit"
disabled={submitting || !email.trim() || !password}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
<PrimaryButton type="submit" disabled={submitting || !email.trim() || !password}>
{submitting ? "Logger inn …" : "Logg inn"}
</Button>
</PrimaryButton>
<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" />
Bruk innloggingslenke e-post i stedet
</button>
<div className="border-t pt-4" style={{ borderColor: C.border }}>
<BackLink onClick={onBack}>Bruk innloggingslenke e-post i stedet</BackLink>
</div>
</form>
)
}
// --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort,
// menneske-skrivbar kode -- FØR innlogging, og uansett turneringens
// synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). ---------
/* --- State 4: Join by code, ekte /public/tournaments/by-code -------------- */
function JoinByCode({ onBack }: { onBack: () => void }) {
function JoinState({ onBack }: { onBack: () => void }) {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
@ -356,134 +637,39 @@ function JoinByCode({ onBack }: { onBack: () => void }) {
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="join-code" className="text-sm font-semibold">
Invitasjonskode
</Label>
<div className="relative">
<KeyRound
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="join-code"
type="text"
autoCapitalize="characters"
autoComplete="off"
autoFocus
placeholder="F.eks. RAPWAR"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="h-14 rounded-2xl pl-11 text-base tracking-widest"
/>
</div>
<FieldLabel htmlFor="invite-code">Invitasjonskode</FieldLabel>
<TextInput
id="invite-code"
icon={KeyRound}
type="text"
inputMode="text"
autoCapitalize="characters"
autoComplete="off"
autoFocus
placeholder="F.eks. RAPWAR"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
style={{ letterSpacing: "0.08em" }}
/>
<p className="text-sm leading-relaxed" style={{ color: C.muted }}>
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for å
se turneringen eller melde deg .
</p>
</div>
{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}
</p>
)}
<Button
type="submit"
disabled={loading || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
<PrimaryButton type="submit" disabled={loading || !code.trim()}>
{loading ? "Sjekker …" : "Gå til turnering"}
</Button>
</PrimaryButton>
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for
å se turneringen eller melde deg .
</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>
<div className="border-t pt-4" style={{ borderColor: C.border }}>
<BackLink onClick={onBack}>Tilbake til innlogging</BackLink>
</div>
</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 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 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