diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bde1c87..ff3c7ba 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/CLAUDE.md b/CLAUDE.md index bf96ac0..33f7993 100644 --- a/CLAUDE.md +++ b/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. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index b9c1159..979ce94 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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å. | --- diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..9b2db69 --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { Dashboard } from "@/components/dashboard" + +export default function DashboardPage() { + return +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 540a92f..86e7954 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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 ( diff --git a/frontend/app/verify/page.tsx b/frontend/app/verify/page.tsx index 708b7da..19d96b8 100644 --- a/frontend/app/verify/page.tsx +++ b/frontend/app/verify/page.tsx @@ -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() { diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx new file mode 100644 index 0000000..48e380e --- /dev/null +++ b/frontend/components/dashboard.tsx @@ -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(null) + const [loadingMe, setLoadingMe] = useState(true) + const [activeOrgId, setActiveOrgId] = useState(null) + const [tournaments, setTournaments] = useState([]) + const [loadingTournaments, setLoadingTournaments] = useState(false) + const [error, setError] = useState(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 ( +
+ + ) + } + + 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 ( +
+
+
+ + +
+
+ +
+ {error && ( +

+ {error} +

+ )} + + {hasOrg && activeOrg ? ( + + ) : ( + + )} +
+
+ ) +} + +// --- 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 ( +
+
+
+ +
+

+ Du har ingen organisasjon ennå +

+

+ En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for + å komme i gang. +

+
+ +
+
+ + setName(e.target.value)} + className="h-14 rounded-2xl text-base" + /> +
+ +
+
+ ) +} + +// --- 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 ( +
+
+ {orgs.length > 1 ? ( + + ) : ( +
+
+
+
+ + Organisasjon + + + {org.name} + +
+
+ )} + + +
+ +
+ {loadingTournaments ? ( +
+ + ) : tournaments.length > 0 ? ( + tournaments.map((t) => ) + ) : ( + + )} +
+
+ ) +} + +function OrganizationSwitcher({ + org, + orgs, + onSelectOrg, +}: { + org: MyOrg + orgs: MyOrg[] + onSelectOrg: (id: string) => void +}) { + return ( + + +
+
+
+ + Organisasjon + + + {org.name} + +
+
+ + {orgs.map((o) => ( + 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 && ( + + ))} + +
+ ) +} + +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 ( + + ) + } + + return ( +
+ 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" + /> + + +
+ ) +} + +function EmptyTournamentState({ orgName }: { orgName: string }) { + return ( +
+
+
+
+

+ Ingen turneringer ennå i {orgName} +

+

+ Trykk på «Ny turnering» for å opprette den første. +

+
+
+ ) +} diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index 955cff5..eb1ff94 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -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 ( -
-
-
-

- TeeCup -

-
- ) -} diff --git a/frontend/components/tournament-card.tsx b/frontend/components/tournament-card.tsx new file mode 100644 index 0000000..15451b0 --- /dev/null +++ b/frontend/components/tournament-card.tsx @@ -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 ( + + ) +} diff --git a/frontend/components/tournament-status-badge.tsx b/frontend/components/tournament-status-badge.tsx new file mode 100644 index 0000000..e2130f1 --- /dev/null +++ b/frontend/components/tournament-status-badge.tsx @@ -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 ( + + + ) +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -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) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9d5ebbd --- /dev/null +++ b/frontend/components/ui/dropdown-menu.tsx @@ -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 +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/components/verify-form.tsx b/frontend/components/verify-form.tsx index 5d469f7..3bd993e 100644 --- a/frontend/components/verify-form.tsx +++ b/frontend/components/verify-form.tsx @@ -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") diff --git a/frontend/components/wordmark.tsx b/frontend/components/wordmark.tsx new file mode 100644 index 0000000..afc65fa --- /dev/null +++ b/frontend/components/wordmark.tsx @@ -0,0 +1,28 @@ +import { Flag } from "lucide-react" +import { cn } from "@/lib/utils" + +export function Wordmark({ compact = false }: { compact?: boolean }) { + if (compact) { + return ( +
+
+
+ + TeeCup + +
+ ) + } + + return ( +
+
+
+

+ TeeCup +

+
+ ) +}