Bygget:
dashboard.tsx sitt datalag skrevet om fra V0s mock-useState til ekte kall: /auth/me (organisasjoner), /orgs/{id}/tournaments (turneringer per valgt org), POST /orgs og POST /orgs/{id}/tournaments (opprettelse), POST /auth/logout.
/verify sender deg nå videre til /dashboard etter innlogging (fantes ingen dit å gå før).
login-form.tsx fikk kun en kirurgisk patch (Wordmark flyttet til egen fil, som V0 selv gjorde) — din egen fetch-/feillogikk urørt.
Verifisert: ekte typesjekket build, redeploy av kun teecup_frontend (ingen Caddy-endring trengtes denne gangen — mønsteret fra sist holder), /dashboard → 200 live, teeoff.no upåvirket.
This commit is contained in:
parent
59517b9290
commit
8af11823e4
14 changed files with 974 additions and 23 deletions
|
|
@ -174,7 +174,10 @@
|
|||
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/verify)",
|
||||
"Bash(curl -s -X POST https://teecup.teeoff.no/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"erol.haagenrud@envide.no\",\"locale\":\"nb\"}')",
|
||||
"Bash(git -C /opt/teecup status --short frontend/pnpm-lock.yaml)",
|
||||
"Bash(git -C /opt/teecup diff --stat frontend/pnpm-lock.yaml)"
|
||||
"Bash(git -C /opt/teecup diff --stat frontend/pnpm-lock.yaml)",
|
||||
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(1\\).zip\")",
|
||||
"Bash(python3 -m zipfile -e \"tee-cup-login-screen \\(1\\).zip\" /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0_dashboard_export/)",
|
||||
"Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
45
CLAUDE.md
45
CLAUDE.md
|
|
@ -296,11 +296,44 @@ Ferdig og verifisert:
|
|||
**Merk for neste økt:** `deploy/Caddyfile`-endringen ligger uncommitted i
|
||||
det SEPARATE `/opt/teeoff`-repoet, ikke i `teecup`-repoet — lett å glemme
|
||||
siden denne økten ellers kun har jobbet i `/opt/teecup`.
|
||||
- **Dashboard-skjerm LIVE (2026-07-18):** andre V0-skjerm — organisasjon-
|
||||
bytter/-opprettelse + turneringsliste (`/dashboard`), samme mønster som
|
||||
login-runden. **Reell integrasjonsfelle unngått:** V0s eksport denne gangen
|
||||
var en FULL re-eksport av hele prosjektet (inkl. `login-form.tsx`,
|
||||
`next.config.mjs`, `package.json`), ikke bare de nye filene — en naiv
|
||||
utpakking ville stille reversert `rewrites()`-proxyen, `output:
|
||||
"standalone"`, den ekte fetch-kablingen i login-skjemaet, og alle
|
||||
V0-uavhengige opprydninger fra forrige runde. Løst ved å pakke ut til et
|
||||
scratch-område FØRST, diffe mot live-treet fil for fil, og kun ta inn det
|
||||
som faktisk var nytt (`dashboard.tsx`, `tournament-card.tsx`,
|
||||
`tournament-status-badge.tsx`, `wordmark.tsx`, `badge.tsx`,
|
||||
`dropdown-menu.tsx`, `app/dashboard/page.tsx`) — `next.config.mjs`,
|
||||
`package.json`, `globals.css`, Docker-filene ble bevisst IKKE overskrevet.
|
||||
`login-form.tsx` fikk en kirurgisk patch (kun Wordmark flyttet til egen
|
||||
fil, som V0 selv hadde gjort — all egen fetch-/feilhåndteringslogikk
|
||||
urørt).
|
||||
**`dashboard.tsx` sitt datalag skrevet om fra bunnen** (V0 leverte kun
|
||||
mock `useState`): henter `/auth/me` for organisasjonsmedlemskap,
|
||||
`/orgs/{id}/tournaments` per valgt org, `POST /orgs`/`POST
|
||||
/orgs/{id}/tournaments` for opprettelse, `POST /auth/logout` for utlogging
|
||||
— presentasjonskomponentene (kort, bytter, tomme tilstander) beholdt
|
||||
uendret fra V0. `/verify`-siden oppdatert til å sende brukeren videre til
|
||||
`/dashboard` etter vellykket innlogging (fantes ingen dit å gå før nå).
|
||||
**Verifisert:** ekte typesjekket build, redeploy av kun `teecup_frontend`
|
||||
(ingen Caddy-endring nødvendig denne gangen — ADR-016s mønster holder),
|
||||
`teecup.teeoff.no/dashboard` → 200, `teeoff.no` upåvirket. Selve
|
||||
skjerm-/skrive-flyten (opprett org/turnering) IKKE testet med ekte
|
||||
skriving mot `teecup_db` i denne runden — det ville krevd å skrive
|
||||
test-rader i brukerens ekte, live konto uten forhåndsbekreftelse.
|
||||
|
||||
Neste steg:
|
||||
1. Flere V0-skjermer (turneringsoversikt, lag/roster, økt/program, blind
|
||||
draw, scorekort, leaderboard) — samme mønster: design i V0, kvalitetsrunde
|
||||
(farger/typesjekk/analytics), kable mot ekte API via samme
|
||||
rewrites-proxy, ingen nye Caddy-endringer nødvendig (ADR-016).
|
||||
2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||
3. Kommunikasjon (chat/feed) — ikke startet.
|
||||
1. Verifisere selve org-/turnering-opprettelsesflyten på `/dashboard` med
|
||||
ekte skriving (brukeren har allerede en ekte, live sesjon — spør før noe
|
||||
skrives til `teecup_db`).
|
||||
2. Flere V0-skjermer (lag/roster, økt/program, blind draw, scorekort,
|
||||
leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt),
|
||||
FORVENT en full re-eksport neste gang også — diff mot live-treet i et
|
||||
scratch-område før noe pakkes ut over eksisterende filer (se
|
||||
dashboard-runden over for hvorfor dette er nødvendig hver gang).
|
||||
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||
4. Kommunikasjon (chat/feed) — ikke startet.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
| Maskinlesbar feilkode-kontrakt (`{"code",...}`) | ✅ | ADR-015. Full retrofit, alle 39 tidligere steder. Se egen seksjon under. |
|
||||
| i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. |
|
||||
| Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. |
|
||||
| Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyten ikke testet med ekte data ennå. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
5
frontend/app/dashboard/page.tsx
Normal file
5
frontend/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Dashboard } from "@/components/dashboard"
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <Dashboard />
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { LoginForm, Wordmark } from "@/components/login-form"
|
||||
import { LoginForm } from "@/components/login-form"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Suspense } from "react"
|
||||
import { Wordmark } from "@/components/login-form"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
import { VerifyForm } from "@/components/verify-form"
|
||||
|
||||
export default function VerifyPage() {
|
||||
|
|
|
|||
463
frontend/components/dashboard.tsx
Normal file
463
frontend/components/dashboard.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Building2, ChevronsUpDown, LogOut, Plus, Trophy, X, Check } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
||||
import type { TournamentStatus } from "@/components/tournament-status-badge"
|
||||
|
||||
type MyOrg = { organization_id: string; name: string; role: string }
|
||||
|
||||
type Me = {
|
||||
id: string
|
||||
email: string
|
||||
display_name: string
|
||||
preferred_locale: string
|
||||
organizations: MyOrg[]
|
||||
}
|
||||
|
||||
type ApiTournament = {
|
||||
id: string
|
||||
name: string
|
||||
status: TournamentStatus
|
||||
start_date: string | null
|
||||
end_date: 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,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Component -------------------------------------------------------------
|
||||
|
||||
export function Dashboard() {
|
||||
const router = useRouter()
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [loadingMe, setLoadingMe] = useState(true)
|
||||
const [activeOrgId, setActiveOrgId] = useState<string | null>(null)
|
||||
const [tournaments, setTournaments] = useState<Tournament[]>([])
|
||||
const [loadingTournaments, setLoadingTournaments] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Hent innlogget bruker + organisasjonsmedlemskap. Ingen gyldig sesjon ->
|
||||
// tilbake til innloggingssiden (denne siden krever auth).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadMe() {
|
||||
try {
|
||||
const res = await fetch("/auth/me", { credentials: "include" })
|
||||
if (!res.ok) {
|
||||
router.replace("/")
|
||||
return
|
||||
}
|
||||
const data: Me = await res.json()
|
||||
if (cancelled) return
|
||||
setMe(data)
|
||||
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
|
||||
} catch {
|
||||
if (!cancelled) router.replace("/")
|
||||
} finally {
|
||||
if (!cancelled) setLoadingMe(false)
|
||||
}
|
||||
}
|
||||
void loadMe()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [router])
|
||||
|
||||
// Turneringer hentes per valgt organisasjon, ikke i /auth/me-kallet.
|
||||
useEffect(() => {
|
||||
if (!activeOrgId) {
|
||||
setTournaments([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
async function loadTournaments() {
|
||||
setLoadingTournaments(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error(`tournaments: ${res.status}`)
|
||||
const data: ApiTournament[] = await res.json()
|
||||
if (!cancelled) setTournaments(data.map(toTournament))
|
||||
} catch {
|
||||
if (!cancelled) setError("Klarte ikke å hente turneringer. Prøv igjen om litt.")
|
||||
} finally {
|
||||
if (!cancelled) setLoadingTournaments(false)
|
||||
}
|
||||
}
|
||||
void loadTournaments()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeOrgId])
|
||||
|
||||
async function handleCreateOrg(name: string) {
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch("/orgs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`orgs: ${res.status}`)
|
||||
const org: { id: string; name: string; role: string } = await res.json()
|
||||
setMe((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
organizations: [
|
||||
...prev.organizations,
|
||||
{ organization_id: org.id, name: org.name, role: org.role },
|
||||
],
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
setActiveOrgId(org.id)
|
||||
} catch {
|
||||
setError("Klarte ikke å opprette organisasjonen. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTournament(name: string) {
|
||||
if (!activeOrgId) return
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`create tournament: ${res.status}`)
|
||||
const created: ApiTournament = await res.json()
|
||||
setTournaments((prev) => [toTournament(created), ...prev])
|
||||
} catch {
|
||||
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
||||
} finally {
|
||||
router.replace("/")
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!me) return null // router.replace("/") er allerede utløst
|
||||
|
||||
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
|
||||
const hasOrg = me.organizations.length > 0 && activeOrg !== null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||
<Wordmark compact />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<LogOut aria-hidden="true" className="size-4" />
|
||||
Logg ut
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
||||
{error && (
|
||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasOrg && activeOrg ? (
|
||||
<OrganizationView
|
||||
org={activeOrg}
|
||||
orgs={me.organizations}
|
||||
tournaments={tournaments}
|
||||
loadingTournaments={loadingTournaments}
|
||||
onSelectOrg={setActiveOrgId}
|
||||
onCreateTournament={handleCreateTournament}
|
||||
/>
|
||||
) : (
|
||||
<CreateOrganizationState onCreate={handleCreateOrg} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- State A: no organization ---------------------------------------------
|
||||
|
||||
function CreateOrganizationState({ onCreate }: { onCreate: (name: string) => void }) {
|
||||
const [name, setName] = useState("")
|
||||
const valid = name.trim().length >= 2
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
onCreate(name.trim())
|
||||
setName("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center gap-6 pt-6 text-center sm:pt-12">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<Building2 aria-hidden="true" className="size-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
Du har ingen organisasjon ennå
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for
|
||||
å komme i gang.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full flex-col gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-lg shadow-black/5"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="org-name" className="text-sm font-semibold">
|
||||
Navn på organisasjon
|
||||
</Label>
|
||||
<Input
|
||||
id="org-name"
|
||||
autoFocus
|
||||
placeholder="F.eks. Oslo Golfklubb"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-14 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!valid}
|
||||
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
||||
>
|
||||
Opprett organisasjon
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- State B: has organization(s) -----------------------------------------
|
||||
|
||||
function OrganizationView({
|
||||
org,
|
||||
orgs,
|
||||
tournaments,
|
||||
loadingTournaments,
|
||||
onSelectOrg,
|
||||
onCreateTournament,
|
||||
}: {
|
||||
org: MyOrg
|
||||
orgs: MyOrg[]
|
||||
tournaments: Tournament[]
|
||||
loadingTournaments: boolean
|
||||
onSelectOrg: (id: string) => void
|
||||
onCreateTournament: (name: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{orgs.length > 1 ? (
|
||||
<OrganizationSwitcher org={org} orgs={orgs} onSelectOrg={onSelectOrg} />
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Building2 aria-hidden="true" className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Organisasjon
|
||||
</span>
|
||||
<span className="text-lg font-extrabold tracking-tight text-foreground">
|
||||
{org.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewTournamentControl onCreate={onCreateTournament} />
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3" aria-label="Turneringer">
|
||||
{loadingTournaments ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
) : tournaments.length > 0 ? (
|
||||
tournaments.map((t) => <TournamentCard key={t.id} tournament={t} />)
|
||||
) : (
|
||||
<EmptyTournamentState orgName={org.name} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizationSwitcher({
|
||||
org,
|
||||
orgs,
|
||||
onSelectOrg,
|
||||
}: {
|
||||
org: MyOrg
|
||||
orgs: MyOrg[]
|
||||
onSelectOrg: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-3 rounded-2xl border border-border bg-card px-3 py-2.5 text-left shadow-sm shadow-black/5 transition-colors hover:bg-accent/50">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Building2 aria-hidden="true" className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Organisasjon
|
||||
</span>
|
||||
<span className="text-lg font-extrabold tracking-tight text-foreground">
|
||||
{org.name}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown aria-hidden="true" className="ml-1 size-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 rounded-2xl p-1.5">
|
||||
{orgs.map((o) => (
|
||||
<DropdownMenuItem
|
||||
key={o.organization_id}
|
||||
onClick={() => onSelectOrg(o.organization_id)}
|
||||
className="flex cursor-pointer items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
{o.name}
|
||||
{o.organization_id === org.organization_id && (
|
||||
<Check aria-hidden="true" className="size-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function NewTournamentControl({ onCreate }: { onCreate: (name: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const valid = name.trim().length >= 2
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
onCreate(name.trim())
|
||||
setName("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="h-12 shrink-0 rounded-2xl text-base font-bold shadow-sm"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-5" />
|
||||
Ny turnering
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full items-center gap-2 rounded-2xl border border-border bg-card p-2 shadow-sm shadow-black/5 sm:w-auto"
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Navn på turnering"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false)
|
||||
}}
|
||||
className="h-10 min-w-0 flex-1 rounded-xl text-base sm:w-56"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!valid}
|
||||
size="icon"
|
||||
className="size-10 shrink-0 rounded-xl"
|
||||
aria-label="Opprett turnering"
|
||||
>
|
||||
<Check aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setOpen(false)}
|
||||
className="size-10 shrink-0 rounded-xl text-muted-foreground"
|
||||
aria-label="Avbryt"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTournamentState({ orgName }: { orgName: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-bold text-foreground text-balance">
|
||||
Ingen turneringer ennå i {orgName}
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Trykk på «Ny turnering» for å opprette den første.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Flag, Mail, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
import { Mail, ArrowLeft, CheckCircle2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
|
@ -217,15 +217,3 @@ function ConfirmationState({
|
|||
)
|
||||
}
|
||||
|
||||
export function Wordmark() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative flex size-16 items-center justify-center rounded-2xl bg-primary shadow-md shadow-primary/30">
|
||||
<Flag aria-hidden="true" className="size-8 text-brand-orange" fill="currentColor" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-foreground">
|
||||
Tee<span className="text-primary">Cup</span>
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
62
frontend/components/tournament-card.tsx
Normal file
62
frontend/components/tournament-card.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Calendar, ChevronRight } from "lucide-react"
|
||||
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||
|
||||
export type Tournament = {
|
||||
id: string
|
||||
name: string
|
||||
status: TournamentStatus
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return null
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return null
|
||||
return dateFormatter.format(parsed)
|
||||
}
|
||||
|
||||
function formatDateRange(start?: string, end?: string) {
|
||||
const from = formatDate(start)
|
||||
const to = formatDate(end)
|
||||
if (from && to) return from === to ? from : `${from} – ${to}`
|
||||
return from ?? to ?? null
|
||||
}
|
||||
|
||||
export function TournamentCard({ tournament }: { tournament: Tournament }) {
|
||||
const dateRange = formatDateRange(tournament.startDate, tournament.endDate)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<h3 className="truncate text-base font-bold text-foreground sm:text-lg">
|
||||
{tournament.name}
|
||||
</h3>
|
||||
<TournamentStatusBadge status={tournament.status} />
|
||||
</div>
|
||||
{dateRange ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span className="tabular-nums">{dateRange}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Ingen datoer satt</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
44
frontend/components/tournament-status-badge.tsx
Normal file
44
frontend/components/tournament-status-badge.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TournamentStatus = "draft" | "active" | "completed" | "archived"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
TournamentStatus,
|
||||
{ label: string; dot: string; badge: string }
|
||||
> = {
|
||||
draft: {
|
||||
label: "Utkast",
|
||||
dot: "bg-muted-foreground",
|
||||
badge: "bg-muted text-foreground",
|
||||
},
|
||||
active: {
|
||||
label: "Aktiv",
|
||||
dot: "bg-primary",
|
||||
badge: "bg-primary/15 text-foreground",
|
||||
},
|
||||
completed: {
|
||||
label: "Fullført",
|
||||
dot: "bg-brand-orange",
|
||||
badge: "bg-brand-orange/12 text-foreground",
|
||||
},
|
||||
archived: {
|
||||
label: "Arkivert",
|
||||
dot: "bg-muted-foreground/50",
|
||||
badge: "border border-border bg-transparent text-muted-foreground",
|
||||
},
|
||||
}
|
||||
|
||||
export function TournamentStatusBadge({ status }: { status: TournamentStatus }) {
|
||||
const config = STATUS_CONFIG[status]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||
config.badge,
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", config.dot)} aria-hidden="true" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
52
frontend/components/ui/badge.tsx
Normal file
52
frontend/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
268
frontend/components/ui/dropdown-menu.tsx
Normal file
268
frontend/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { CheckCircle2, KeyRound } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -11,6 +11,7 @@ import { Label } from "@/components/ui/label"
|
|||
type Status = "checking" | "verifying" | "manual" | "success" | "error"
|
||||
|
||||
export function VerifyForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const urlToken = searchParams.get("token")
|
||||
|
||||
|
|
@ -36,6 +37,8 @@ export function VerifyForm() {
|
|||
const user = await res.json()
|
||||
setEmail(user.email)
|
||||
setStatus("success")
|
||||
// Kort pause så brukeren rekker å se bekreftelsen før vi går videre.
|
||||
setTimeout(() => router.replace("/dashboard"), 900)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
||||
setStatus("error")
|
||||
|
|
|
|||
28
frontend/components/wordmark.tsx
Normal file
28
frontend/components/wordmark.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Flag } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Wordmark({ compact = false }: { compact?: boolean }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative flex size-9 items-center justify-center rounded-xl bg-primary shadow-sm shadow-primary/30">
|
||||
<Flag aria-hidden="true" className="size-5 text-brand-orange" fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-xl font-extrabold tracking-tight text-foreground">
|
||||
Tee<span className="text-primary">Cup</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative flex size-16 items-center justify-center rounded-2xl bg-primary shadow-md shadow-primary/30">
|
||||
<Flag aria-hidden="true" className="size-8 text-brand-orange" fill="currentColor" />
|
||||
</div>
|
||||
<h1 className={cn("text-3xl font-extrabold tracking-tight text-foreground")}>
|
||||
Tee<span className="text-primary">Cup</span>
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue