POL-125: Frontend visual upgrade — dark luxury pole dance theme

- New dark theme with rose/purple/gold accent palette
- Premium typography: Cormorant Garamond (display) + Outfit (body)
- Glassmorphism cards, gradient mesh backgrounds, glow effects
- CSS split into theme.css, utilities.css, animations.css
- Staggered fade-in animations on list pages
- Redesigned all pages: auth, championships, registrations, profile, admin
- Lucide icons replace emoji throughout
- Responsive mobile nav with hamburger menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dianaka123
2026-02-27 13:56:45 +03:00
parent cf4104069e
commit 6fbd0326fa
24 changed files with 882 additions and 342 deletions

View File

@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useUsers, useUserActions } from "@/hooks/useUsers";
import { useAuth } from "@/hooks/useAuth";
import { UserCard } from "@/components/admin/UserCard";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Shield } from "lucide-react";
type Filter = "pending" | "all";
@@ -20,47 +20,63 @@ export default function AdminPage() {
if (user && user.role !== "admin") router.replace("/championships");
}, [user, router]);
if (isLoading) return <div className="flex justify-center py-20 text-gray-400">Loading</div>;
if (error) return <p className="text-center text-red-500 py-20">Failed to load users.</p>;
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error) return <p className="text-center text-destructive py-20">Failed to load users.</p>;
const pending = data?.filter((u) => u.status === "pending") ?? [];
const shown = filter === "pending" ? pending : (data ?? []);
return (
<div>
<h1 className="mb-6 text-2xl font-bold text-gray-900">User Management</h1>
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="font-display text-4xl font-bold tracking-wide">User Management</h1>
<p className="mt-1 text-muted-foreground">Review and manage user accounts</p>
</div>
<div className="mb-4 flex gap-2">
<div className="mb-5 flex gap-2">
{(["pending", "all"] as const).map((f) => (
<button
onClick={() => setFilter("pending")}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
filter === "pending" ? "bg-violet-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
key={f}
onClick={() => setFilter(f)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200 ${
filter === f
? "bg-rose-accent text-white glow-rose"
: "bg-surface-elevated border border-border/40 text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
Pending ({pending.length})
</button>
<button
onClick={() => setFilter("all")}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
filter === "all" ? "bg-violet-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
All users ({data?.length ?? 0})
{f === "pending" ? `Pending (${pending.length})` : `All users (${data?.length ?? 0})`}
</button>
))}
</div>
{shown.length === 0 ? (
<p className="text-center text-gray-400 py-12">No users in this category.</p>
<div className="text-center py-12">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface-elevated border border-border/40">
<Shield className="h-6 w-6 text-dim" />
</div>
<p className="text-muted-foreground">No users in this category.</p>
</div>
) : (
<div className="space-y-3 max-w-2xl">
{shown.map((u) => (
{shown.map((u, i) => (
<div key={u.id} className={`animate-fade-in-up stagger-${Math.min(i + 1, 9)}`}>
<UserCard
key={u.id}
user={u}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => reject.mutate(id)}
isActing={approve.isPending || reject.isPending}
/>
</div>
))}
</div>
)}

View File

@@ -4,22 +4,30 @@ import { use } from "react";
import { useChampionship } from "@/hooks/useChampionships";
import { useMyRegistrations } from "@/hooks/useRegistrations";
import { useRegisterForChampionship } from "@/hooks/useRegistrations";
import { useAuth } from "@/hooks/useAuth";
import { RegistrationTimeline } from "@/components/registrations/RegistrationTimeline";
import { StatusBadge } from "@/components/shared/StatusBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MapPin, Building2, Calendar, CreditCard, Film, ExternalLink } from "lucide-react";
export default function ChampionshipDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const user = useAuth((s) => s.user);
const { data: championship, isLoading, error } = useChampionship(id);
const { data: myRegs } = useMyRegistrations();
const registerMutation = useRegisterForChampionship(id);
if (isLoading) return <div className="flex justify-center py-20 text-gray-400">Loading</div>;
if (error || !championship) return <p className="text-center text-red-500 py-20">Championship not found.</p>;
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error || !championship) return <p className="text-center text-destructive py-20">Championship not found.</p>;
const myReg = myRegs?.find((r) => r.championship_id === id);
const canRegister = championship.status === "open" && !myReg;
@@ -29,51 +37,77 @@ export default function ChampionshipDetailPage({ params }: { params: Promise<{ i
: null;
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
{/* Header image */}
<div className="relative overflow-hidden rounded-2xl">
{championship.image_url ? (
<img src={championship.image_url} alt={championship.title} className="w-full h-56 object-cover rounded-xl" />
<img src={championship.image_url} alt={championship.title} className="w-full h-56 object-cover" />
) : (
<div className="w-full h-56 rounded-xl bg-gradient-to-br from-violet-400 to-purple-600 flex items-center justify-center text-6xl">
🏆
<div className="w-full h-56 bg-gradient-to-br from-rose-accent/20 via-purple-accent/15 to-gold-accent/10 flex items-center justify-center">
<span className="text-6xl opacity-30">💃</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent" />
</div>
{/* Title + status */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">{championship.title}</h1>
{championship.subtitle && <p className="text-gray-500 mt-1">{championship.subtitle}</p>}
<h1 className="font-display text-3xl font-bold tracking-wide">{championship.title}</h1>
{championship.subtitle && <p className="text-muted-foreground mt-1">{championship.subtitle}</p>}
</div>
<StatusBadge status={championship.status} />
</div>
{/* Details */}
<div className="space-y-2 text-sm text-gray-600">
{championship.location && <p>📍 {championship.location}</p>}
{championship.venue && <p>🏛 {championship.venue}</p>}
{eventDate && <p>📅 {eventDate}</p>}
{championship.entry_fee != null && <p>💳 Entry fee: <strong>{championship.entry_fee} </strong></p>}
{/* Details grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{championship.location && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<MapPin size={16} className="text-rose-accent shrink-0" />
<span className="text-muted-foreground">{championship.location}</span>
</div>
)}
{championship.venue && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Building2 size={16} className="text-purple-accent shrink-0" />
<span className="text-muted-foreground">{championship.venue}</span>
</div>
)}
{eventDate && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Calendar size={16} className="text-gold-accent shrink-0" />
<span className="text-muted-foreground">{eventDate}</span>
</div>
)}
{championship.entry_fee != null && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<CreditCard size={16} className="text-gold-accent shrink-0" />
<span className="text-muted-foreground">Entry fee: <strong className="text-foreground">{championship.entry_fee} </strong></span>
</div>
)}
{championship.video_max_duration != null && (
<p>🎬 Max video: <strong>{Math.floor(championship.video_max_duration / 60)}m {championship.video_max_duration % 60}s</strong></p>
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Film size={16} className="text-purple-accent shrink-0" />
<span className="text-muted-foreground">Max video: <strong className="text-foreground">{Math.floor(championship.video_max_duration / 60)}m {championship.video_max_duration % 60}s</strong></span>
</div>
)}
</div>
{championship.description && (
<>
<Separator />
<p className="text-gray-700 whitespace-pre-line">{championship.description}</p>
<Separator className="bg-border/30" />
<p className="text-muted-foreground whitespace-pre-line leading-relaxed">{championship.description}</p>
</>
)}
<Separator />
<Separator className="bg-border/30" />
{/* Registration section */}
{myReg && <RegistrationTimeline registration={myReg} />}
{canRegister && (
<Button
className="w-full bg-violet-600 hover:bg-violet-700"
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide h-11"
disabled={registerMutation.isPending}
onClick={() => registerMutation.mutate()}
>
@@ -82,7 +116,7 @@ export default function ChampionshipDetailPage({ params }: { params: Promise<{ i
)}
{championship.status !== "open" && !myReg && (
<p className="text-center text-sm text-gray-400">Registration is not open.</p>
<p className="text-center text-sm text-dim">Registration is not open.</p>
)}
{championship.form_url && (
@@ -90,9 +124,10 @@ export default function ChampionshipDetailPage({ params }: { params: Promise<{ i
href={championship.form_url}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-sm text-violet-600 hover:underline"
className="flex items-center justify-center gap-1.5 text-sm text-rose-accent hover:text-rose-accent/80 transition-colors"
>
Open registration form
Open registration form
<ExternalLink size={14} />
</a>
)}
</div>

View File

@@ -2,20 +2,46 @@
import { useChampionships } from "@/hooks/useChampionships";
import { ChampionshipCard } from "@/components/championships/ChampionshipCard";
import { Trophy } from "lucide-react";
export default function ChampionshipsPage() {
const { data, isLoading, error } = useChampionships();
if (isLoading) return <div className="flex justify-center py-20 text-gray-400">Loading</div>;
if (error) return <p className="text-center text-red-500 py-20">Failed to load championships.</p>;
if (!data?.length) return <p className="text-center text-gray-400 py-20">No championships yet.</p>;
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error) return <p className="text-center text-destructive py-20">Failed to load championships.</p>;
if (!data?.length) {
return (
<div className="text-center py-20 animate-fade-in">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-elevated border border-border/40">
<Trophy className="h-7 w-7 text-dim" />
</div>
<p className="text-muted-foreground">No championships yet.</p>
</div>
);
}
return (
<div>
<h1 className="mb-6 text-2xl font-bold text-gray-900">Championships</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.map((c) => (
<ChampionshipCard key={c.id} championship={c} />
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="font-display text-4xl font-bold tracking-wide">Championships</h1>
<p className="mt-1 text-muted-foreground">Browse upcoming competitions</p>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{data.map((c, i) => (
<div key={c.id} className={`animate-fade-in-up stagger-${Math.min(i + 1, 9)}`}>
<ChampionshipCard championship={c} />
</div>
))}
</div>
</div>

View File

@@ -2,9 +2,9 @@ import { Navbar } from "@/components/layout/Navbar";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="min-h-screen bg-mesh">
<Navbar />
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
</>
</div>
);
}

View File

@@ -6,11 +6,12 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Phone, Building2, AtSign, CalendarDays, LogOut } from "lucide-react";
const ROLE_COLORS: Record<string, string> = {
admin: "bg-red-100 text-red-700",
organizer: "bg-violet-100 text-violet-700",
member: "bg-green-100 text-green-700",
admin: "bg-destructive/15 text-destructive border-destructive/20",
organizer: "bg-purple-soft text-purple-accent border-purple-accent/20",
member: "bg-rose-soft text-rose-accent border-rose-accent/20",
};
export default function ProfilePage() {
@@ -29,52 +30,73 @@ export default function ProfilePage() {
}
return (
<div className="max-w-md mx-auto space-y-6">
<div className="flex flex-col items-center gap-3 pt-4">
<Avatar className="h-20 w-20">
<AvatarFallback className="bg-violet-100 text-violet-700 text-2xl font-bold">
<div className="max-w-md mx-auto space-y-6 animate-fade-in">
<div className="flex flex-col items-center gap-4 pt-4">
{/* Avatar with gradient ring */}
<div className="relative">
<div className="absolute -inset-1 rounded-full bg-gradient-to-br from-rose-accent via-purple-accent to-gold-accent opacity-50 blur-sm" />
<Avatar className="relative h-20 w-20 border-2 border-background">
<AvatarFallback className="bg-surface-elevated text-rose-accent text-2xl font-bold">
{initials}
</AvatarFallback>
</Avatar>
<div className="text-center">
<p className="text-xl font-bold text-gray-900">{user.full_name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<Badge className={`${ROLE_COLORS[user.role] ?? "bg-gray-100"} border-0 capitalize`}>
<div className="text-center">
<p className="font-display text-2xl font-bold tracking-wide">{user.full_name}</p>
<p className="text-sm text-muted-foreground mt-1">{user.email}</p>
</div>
<Badge className={`${ROLE_COLORS[user.role] ?? "bg-surface-elevated text-muted-foreground"} border capitalize`}>
{user.role}
</Badge>
</div>
<Separator />
<Separator className="bg-border/30" />
<div className="space-y-3 text-sm text-gray-700">
<div className="space-y-1 rounded-xl bg-surface-elevated border border-border/30 p-4">
{user.phone && (
<div className="flex justify-between">
<span className="text-gray-400">Phone</span>
<span>{user.phone}</span>
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<Phone size={14} />
Phone
</span>
<span className="text-sm text-foreground">{user.phone}</span>
</div>
)}
{user.organization_name && (
<div className="flex justify-between">
<span className="text-gray-400">Organization</span>
<span>{user.organization_name}</span>
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<Building2 size={14} />
Organization
</span>
<span className="text-sm text-foreground">{user.organization_name}</span>
</div>
)}
{user.instagram_handle && (
<div className="flex justify-between">
<span className="text-gray-400">Instagram</span>
<span>{user.instagram_handle}</span>
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<AtSign size={14} />
AtSign
</span>
<span className="text-sm text-foreground">{user.instagram_handle}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-400">Member since</span>
<span>{joinedDate}</span>
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<CalendarDays size={14} />
Member since
</span>
<span className="text-sm text-foreground">{joinedDate}</span>
</div>
</div>
<Separator />
<Button variant="destructive" className="w-full" onClick={handleLogout}>
<Button
variant="outline"
className="w-full border-destructive/30 text-destructive hover:bg-destructive/10 hover:border-destructive/50"
onClick={handleLogout}
>
<LogOut size={16} />
Sign out
</Button>
</div>

View File

@@ -2,25 +2,46 @@
import { useMyRegistrations } from "@/hooks/useRegistrations";
import { RegistrationCard } from "@/components/registrations/RegistrationCard";
import { ListChecks } from "lucide-react";
export default function RegistrationsPage() {
const { data, isLoading, error } = useMyRegistrations();
if (isLoading) return <div className="flex justify-center py-20 text-gray-400">Loading</div>;
if (error) return <p className="text-center text-red-500 py-20">Failed to load registrations.</p>;
if (!data?.length) return (
<div className="text-center py-20 text-gray-400">
<p className="text-4xl mb-3">📋</p>
<p>No registrations yet.</p>
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error) return <p className="text-center text-destructive py-20">Failed to load registrations.</p>;
if (!data?.length) {
return (
<div className="text-center py-20 animate-fade-in">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-elevated border border-border/40">
<ListChecks className="h-7 w-7 text-dim" />
</div>
<p className="text-muted-foreground">No registrations yet.</p>
</div>
);
}
return (
<div>
<h1 className="mb-6 text-2xl font-bold text-gray-900">My Registrations</h1>
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="font-display text-4xl font-bold tracking-wide">My Registrations</h1>
<p className="mt-1 text-muted-foreground">Track your championship progress</p>
</div>
<div className="space-y-3 max-w-2xl">
{data.map((r) => (
<RegistrationCard key={r.id} registration={r} />
{data.map((r, i) => (
<div key={r.id} className={`animate-fade-in-up stagger-${Math.min(i + 1, 9)}`}>
<RegistrationCard registration={r} />
</div>
))}
</div>
</div>

View File

@@ -1,7 +1,38 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-violet-50 to-purple-100 p-4">
<div className="w-full max-w-md">{children}</div>
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
{/* Gradient mesh background */}
<div className="fixed inset-0 bg-background bg-mesh-strong" />
{/* Decorative flowing lines — pole dance silhouette abstraction */}
<svg
className="fixed inset-0 h-full w-full opacity-[0.04]"
viewBox="0 0 1200 800"
fill="none"
preserveAspectRatio="xMidYMid slice"
>
<path
d="M-100,400 C100,200 300,600 500,350 C700,100 900,500 1100,300 C1300,100 1400,400 1400,400"
stroke="url(#line-grad)"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M-100,500 C200,300 400,700 600,450 C800,200 1000,600 1300,350"
stroke="url(#line-grad)"
strokeWidth="1.5"
strokeLinecap="round"
/>
<defs>
<linearGradient id="line-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#E91E63" />
<stop offset="50%" stopColor="#9C27B0" />
<stop offset="100%" stopColor="#D4A843" />
</linearGradient>
</defs>
</svg>
<div className="relative z-10 w-full max-w-md animate-fade-in-up">{children}</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { Eye, EyeOff } from "lucide-react";
import { useLoginForm } from "@/hooks/useAuthForms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,37 +9,77 @@ import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
const { email, setEmail, password, setPassword, error, isLoading, submit } = useLoginForm();
const { email, setEmail, password, setPassword, showPassword, setShowPassword, error, isLoading, submit } = useLoginForm();
return (
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-2 text-4xl">🏆</div>
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
<Card className="glass-strong glow-rose overflow-hidden">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-px w-16 bg-gradient-to-r from-transparent via-rose-accent to-transparent" />
<CardTitle className="font-display text-3xl font-semibold tracking-wide">
Welcome back
</CardTitle>
<CardDescription className="text-muted-foreground">
Sign in to your account
</CardDescription>
</CardHeader>
<form onSubmit={submit}>
<CardContent className="space-y-4">
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
{error && (
<p className="rounded-lg bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<div className="space-y-2">
<Label htmlFor="email" className="text-xs uppercase tracking-widest text-dim">
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30 placeholder:text-dim"
placeholder="your@email.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-xs uppercase tracking-widest text-dim">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-surface border-border/60 pr-10 focus:border-rose-accent focus:ring-rose-accent/30"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dim hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<div className="space-y-1">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button type="submit" className="w-full bg-violet-600 hover:bg-violet-700" disabled={isLoading}>
<CardFooter className="flex flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide"
disabled={isLoading}
>
{isLoading ? "Signing in…" : "Sign in"}
</Button>
<p className="text-center text-sm text-gray-500">
<p className="text-center text-sm text-muted-foreground">
No account?{" "}
<Link href="/register" className="font-medium text-violet-600 hover:underline">
<Link href="/register" className="font-medium text-rose-accent hover:text-rose-accent/80 transition-colors">
Register
</Link>
</p>

View File

@@ -1,22 +1,27 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Clock } from "lucide-react";
export default function PendingPage() {
return (
<Card className="text-center">
<Card className="glass-strong glow-purple text-center overflow-hidden">
<CardHeader>
<div className="mx-auto mb-2 text-5xl"></div>
<CardTitle className="text-2xl">Awaiting approval</CardTitle>
<CardDescription>
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-purple-accent/10 border border-purple-accent/20">
<Clock className="h-7 w-7 text-purple-accent" />
</div>
<CardTitle className="font-display text-3xl font-semibold tracking-wide">
Awaiting approval
</CardTitle>
<CardDescription className="text-muted-foreground">
Your organizer account has been submitted. An admin will review it shortly.
</CardDescription>
</CardHeader>
<CardContent>
<p className="mb-6 text-sm text-gray-500">
<p className="mb-6 text-sm text-dim">
Once approved you can log in and start creating championships.
</p>
<Button asChild variant="outline" className="w-full">
<Button asChild variant="outline" className="w-full border-border/60 hover:bg-surface-hover">
<Link href="/login">Back to login</Link>
</Button>
</CardContent>

View File

@@ -11,16 +11,24 @@ export default function RegisterPage() {
const { role, setRole, form, update, error, isLoading, submit } = useRegisterForm();
return (
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-2 text-4xl">🏅</div>
<CardTitle className="text-2xl">Create account</CardTitle>
<CardDescription>Join the pole dance community</CardDescription>
<Card className="glass-strong glow-rose overflow-hidden">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-px w-16 bg-gradient-to-r from-transparent via-purple-accent to-transparent" />
<CardTitle className="font-display text-3xl font-semibold tracking-wide">
Create account
</CardTitle>
<CardDescription className="text-muted-foreground">
Join the pole dance community
</CardDescription>
</CardHeader>
<form onSubmit={submit}>
<CardContent className="space-y-4">
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
{error && (
<p className="rounded-lg bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="grid grid-cols-2 gap-2">
{(["member", "organizer"] as const).map((r) => (
@@ -28,11 +36,13 @@ export default function RegisterPage() {
key={r}
type="button"
onClick={() => setRole(r)}
className={`rounded-lg border-2 p-3 text-sm font-medium transition-colors ${
role === r ? "border-violet-600 bg-violet-50 text-violet-700" : "border-gray-200 text-gray-600 hover:border-gray-300"
className={`rounded-xl border-2 p-3 text-sm font-medium transition-all duration-200 ${
role === r
? "border-rose-accent bg-rose-accent/10 text-foreground glow-rose"
: "border-border/40 text-muted-foreground hover:border-border hover:text-foreground"
}`}
>
{r === "member" ? "🏅 Athlete" : "🏆 Organizer"}
{r === "member" ? "Athlete" : "Organizer"}
</button>
))}
</div>
@@ -54,13 +64,17 @@ export default function RegisterPage() {
)}
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button type="submit" className="w-full bg-violet-600 hover:bg-violet-700" disabled={isLoading}>
<CardFooter className="flex flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide"
disabled={isLoading}
>
{isLoading ? "Creating…" : role === "member" ? "Create account" : "Submit for approval"}
</Button>
<p className="text-center text-sm text-gray-500">
<p className="text-center text-sm text-muted-foreground">
Have an account?{" "}
<Link href="/login" className="font-medium text-violet-600 hover:underline">
<Link href="/login" className="font-medium text-rose-accent hover:text-rose-accent/80 transition-colors">
Sign in
</Link>
</p>

View File

@@ -1,13 +1,17 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "../styles/theme.css";
@import "../styles/utilities.css";
@import "../styles/animations.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-outfit);
--font-display: var(--font-cormorant);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@@ -45,75 +49,17 @@
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--color-rose-accent: #E91E63;
--color-rose-soft: rgba(233, 30, 99, 0.15);
--color-rose-glow: rgba(233, 30, 99, 0.25);
--color-purple-accent: #9C27B0;
--color-purple-soft: rgba(156, 39, 176, 0.15);
--color-gold-accent: #D4A843;
--color-gold-soft: rgba(212, 168, 67, 0.15);
--color-surface: #0E0D18;
--color-surface-elevated: #15142A;
--color-surface-hover: #1C1B35;
--color-dim: #5C5880;
}
@layer base {

View File

@@ -1,9 +1,21 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { Cormorant_Garamond, Outfit } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
const cormorant = Cormorant_Garamond({
subsets: ["latin", "cyrillic"],
weight: ["400", "600", "700"],
variable: "--font-cormorant",
display: "swap",
});
const outfit = Outfit({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-outfit",
display: "swap",
});
export const metadata: Metadata = {
title: "Pole Dance Championships",
@@ -13,7 +25,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`${geist.variable} font-sans antialiased bg-gray-50`}>
<body className={`${cormorant.variable} ${outfit.variable} font-sans antialiased`}>
<Providers>{children}</Providers>
</body>
</html>

View File

@@ -14,8 +14,14 @@ function AuthInitializer({ children }: { children: React.ReactNode }) {
if (!isInitialized) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-violet-600 border-t-transparent" />
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="relative h-10 w-10">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
<p className="text-sm text-muted-foreground tracking-wide">Loading</p>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { UserOut } from "@/types/user";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Building2, Phone, AtSign, CheckCircle, XCircle } from "lucide-react";
interface Props {
user: UserOut;
@@ -11,47 +12,69 @@ interface Props {
}
const STATUS_DOT: Record<string, string> = {
pending: "bg-orange-400",
approved: "bg-green-500",
rejected: "bg-red-500",
pending: "bg-gold-accent",
approved: "bg-emerald-500",
rejected: "bg-destructive",
};
export function UserCard({ user, onApprove, onReject, isActing }: Props) {
const initials = user.full_name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
return (
<Card>
<Card className="border-border/40 bg-surface-elevated">
<CardContent className="p-4 flex gap-4 items-start">
<Avatar className="h-10 w-10 shrink-0">
<AvatarFallback className="bg-violet-100 text-violet-700 text-sm font-semibold">
<Avatar className="h-10 w-10 shrink-0 border border-border/40">
<AvatarFallback className="bg-rose-accent/10 text-rose-accent text-sm font-semibold">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-gray-900">{user.full_name}</p>
<span className={`h-2 w-2 rounded-full shrink-0 ${STATUS_DOT[user.status] ?? "bg-gray-400"}`} />
<p className="font-semibold text-foreground">{user.full_name}</p>
<span className={`h-2 w-2 rounded-full shrink-0 ${STATUS_DOT[user.status] ?? "bg-dim"}`} />
</div>
<p className="text-sm text-gray-500">{user.email}</p>
{user.organization_name && <p className="text-sm text-gray-500">🏢 {user.organization_name}</p>}
{user.phone && <p className="text-sm text-gray-500">📞 {user.phone}</p>}
{user.instagram_handle && <p className="text-sm text-gray-500">📸 {user.instagram_handle}</p>}
<p className="text-sm text-muted-foreground">{user.email}</p>
{user.organization_name && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Building2 size={12} className="text-dim" />
{user.organization_name}
</p>
)}
{user.phone && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Phone size={12} className="text-dim" />
{user.phone}
</p>
)}
{user.instagram_handle && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<AtSign size={12} className="text-dim" />
{user.instagram_handle}
</p>
)}
</div>
{user.status === "pending" && onApprove && onReject && (
<div className="flex gap-2 shrink-0">
<Button size="sm" className="bg-green-600 hover:bg-green-700" disabled={isActing} onClick={() => onApprove(user.id)}>
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-500 text-white"
disabled={isActing}
onClick={() => onApprove(user.id)}
>
<CheckCircle size={14} />
Approve
</Button>
<Button size="sm" variant="destructive" disabled={isActing} onClick={() => onReject(user.id)}>
<XCircle size={14} />
Reject
</Button>
</div>
)}
{user.status !== "pending" && (
<span className="text-xs text-gray-400 capitalize shrink-0">{user.status}</span>
<span className="text-xs text-dim capitalize shrink-0">{user.status}</span>
)}
</CardContent>
</Card>

View File

@@ -12,21 +12,21 @@ interface Props {
export function MemberFields({ full_name, email, password, phone, onChange }: Props) {
return (
<>
<div className="space-y-1">
<Label htmlFor="full_name">Full name</Label>
<Input id="full_name" value={full_name} onChange={(e) => onChange("full_name", e.target.value)} required />
<div className="space-y-2">
<Label htmlFor="full_name" className="text-xs uppercase tracking-widest text-dim">Full name</Label>
<Input id="full_name" value={full_name} onChange={(e) => onChange("full_name", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(e) => onChange("email", e.target.value)} required />
<div className="space-y-2">
<Label htmlFor="email" className="text-xs uppercase tracking-widest text-dim">Email</Label>
<Input id="email" type="email" value={email} onChange={(e) => onChange("email", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-1">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={password} onChange={(e) => onChange("password", e.target.value)} required />
<div className="space-y-2">
<Label htmlFor="password" className="text-xs uppercase tracking-widest text-dim">Password</Label>
<Input id="password" type="password" value={password} onChange={(e) => onChange("password", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-1">
<Label htmlFor="phone">Phone (optional)</Label>
<Input id="phone" type="tel" value={phone} onChange={(e) => onChange("phone", e.target.value)} />
<div className="space-y-2">
<Label htmlFor="phone" className="text-xs uppercase tracking-widest text-dim">Phone (optional)</Label>
<Input id="phone" type="tel" value={phone} onChange={(e) => onChange("phone", e.target.value)} className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
</>
);

View File

@@ -1,5 +1,6 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertTriangle } from "lucide-react";
interface Props {
organization_name: string;
@@ -10,16 +11,17 @@ interface Props {
export function OrganizerFields({ organization_name, instagram_handle, onChange }: Props) {
return (
<>
<p className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-700">
<div className="flex items-center gap-2 rounded-lg bg-gold-soft border border-gold-accent/20 px-3 py-2 text-xs text-gold-accent">
<AlertTriangle size={14} className="shrink-0" />
Organizer accounts require admin approval before you can log in.
</p>
<div className="space-y-1">
<Label htmlFor="org">Organization name</Label>
<Input id="org" value={organization_name} onChange={(e) => onChange("organization_name", e.target.value)} required />
</div>
<div className="space-y-1">
<Label htmlFor="ig">Instagram (optional)</Label>
<Input id="ig" placeholder="@yourstudio" value={instagram_handle} onChange={(e) => onChange("instagram_handle", e.target.value)} />
<div className="space-y-2">
<Label htmlFor="org" className="text-xs uppercase tracking-widest text-dim">Organization name</Label>
<Input id="org" value={organization_name} onChange={(e) => onChange("organization_name", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-2">
<Label htmlFor="ig" className="text-xs uppercase tracking-widest text-dim">Instagram (optional)</Label>
<Input id="ig" placeholder="@yourstudio" value={instagram_handle} onChange={(e) => onChange("instagram_handle", e.target.value)} className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30 placeholder:text-dim" />
</div>
</>
);

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { Championship } from "@/types/championship";
import { StatusBadge } from "@/components/shared/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { MapPin, Calendar, CreditCard } from "lucide-react";
interface Props {
championship: Championship;
@@ -14,22 +15,54 @@ export function ChampionshipCard({ championship: c }: Props) {
return (
<Link href={`/championships/${c.id}`}>
<Card className="overflow-hidden transition-shadow hover:shadow-md cursor-pointer h-full">
<Card className="group overflow-hidden border-border/40 bg-surface-elevated hover:border-rose-accent/30 transition-all duration-300 cursor-pointer h-full hover:glow-rose">
{/* Image / gradient header */}
<div className="relative overflow-hidden">
{c.image_url ? (
<img src={c.image_url} alt={c.title} className="h-40 w-full object-cover" />
<img
src={c.image_url}
alt={c.title}
className="h-44 w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="h-40 w-full bg-gradient-to-br from-violet-400 to-purple-600 flex items-center justify-center text-4xl">
🏆
<div className="h-44 w-full bg-gradient-to-br from-rose-accent/30 via-purple-accent/20 to-gold-accent/10 flex items-center justify-center">
<div className="text-5xl opacity-40 group-hover:opacity-60 transition-opacity duration-300 group-hover:animate-float">
💃
</div>
</div>
)}
<CardContent className="p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<h2 className="font-semibold text-gray-900 leading-tight">{c.title}</h2>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-surface-elevated via-transparent to-transparent" />
{/* Status badge floating on image */}
<div className="absolute top-3 right-3">
<StatusBadge status={c.status} />
</div>
{c.location && <p className="text-sm text-gray-500">📍 {c.location}</p>}
{date && <p className="text-sm text-gray-500">📅 {date}</p>}
{c.entry_fee != null && <p className="text-sm font-medium text-violet-700">💳 {c.entry_fee} </p>}
</div>
<CardContent className="p-4 space-y-2.5">
<h2 className="font-semibold text-foreground leading-tight group-hover:text-rose-accent transition-colors">
{c.title}
</h2>
<div className="space-y-1.5">
{c.location && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin size={13} className="text-dim shrink-0" />
{c.location}
</p>
)}
{date && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar size={13} className="text-dim shrink-0" />
{date}
</p>
)}
{c.entry_fee != null && (
<p className="flex items-center gap-1.5 text-sm font-medium text-gold-accent">
<CreditCard size={13} className="shrink-0" />
{c.entry_fee}
</p>
)}
</div>
</CardContent>
</Card>
</Link>

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
@@ -11,16 +11,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Trophy, ListChecks, Shield, Menu } from "lucide-react";
import { useState } from "react";
const NAV_LINKS = [
{ href: "/championships", label: "Championships" },
{ href: "/registrations", label: "My Registrations" },
{ href: "/championships", label: "Championships", icon: Trophy },
{ href: "/registrations", label: "My Registrations", icon: ListChecks },
];
export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const user = useAuth((s) => s.user);
const logout = useAuth((s) => s.logout);
const [mobileOpen, setMobileOpen] = useState(false);
async function handleLogout() {
await logout();
@@ -35,47 +39,115 @@ export function Navbar() {
.slice(0, 2) ?? "?";
return (
<header className="sticky top-0 z-50 border-b bg-white">
<header className="sticky top-0 z-50 glass-strong">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link href="/championships" className="text-lg font-bold text-violet-700">
🏆 DanceChamp
<Link href="/championships" className="flex items-center gap-2 group">
<span className="text-gradient-rose font-display text-xl font-bold tracking-wide">
DanceChamp
</span>
</Link>
<nav className="hidden gap-4 text-sm font-medium text-gray-600 sm:flex">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} className="hover:text-violet-700 transition-colors">
{/* Desktop nav */}
<nav className="hidden gap-1 sm:flex">
{NAV_LINKS.map((link) => {
const active = pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
active
? "bg-rose-accent/10 text-rose-accent"
: "text-muted-foreground hover:text-foreground hover:bg-surface-hover"
}`}
>
<link.icon size={15} />
{link.label}
</Link>
))}
);
})}
{user?.role === "admin" && (
<Link href="/admin" className="hover:text-violet-700 transition-colors">
<Link
href="/admin"
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
pathname.startsWith("/admin")
? "bg-rose-accent/10 text-rose-accent"
: "text-muted-foreground hover:text-foreground hover:bg-surface-hover"
}`}
>
<Shield size={15} />
Admin
</Link>
)}
</nav>
</div>
<div className="flex items-center gap-2">
{/* Mobile menu toggle */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="rounded-lg p-1.5 text-muted-foreground hover:text-foreground hover:bg-surface-hover transition-colors sm:hidden"
>
<Menu size={20} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rounded-full focus:outline-none focus:ring-2 focus:ring-violet-500">
<Avatar className="h-8 w-8 cursor-pointer">
<AvatarFallback className="bg-violet-100 text-violet-700 text-xs font-semibold">
<button className="rounded-full focus:outline-none focus:ring-2 focus:ring-rose-accent/50">
<Avatar className="h-8 w-8 cursor-pointer border border-rose-accent/30">
<AvatarFallback className="bg-rose-accent/10 text-rose-accent text-xs font-semibold">
{initials}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-44 glass-strong border-border/60">
<DropdownMenuItem asChild>
<Link href="/profile">Profile</Link>
<Link href="/profile" className="text-foreground">Profile</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 focus:text-red-600">
<DropdownMenuSeparator className="bg-border/40" />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<nav className="border-t border-border/30 px-4 pb-3 pt-2 sm:hidden animate-fade-in">
{NAV_LINKS.map((link) => {
const active = pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active ? "text-rose-accent" : "text-muted-foreground hover:text-foreground"
}`}
>
<link.icon size={16} />
{link.label}
</Link>
);
})}
{user?.role === "admin" && (
<Link
href="/admin"
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
pathname.startsWith("/admin") ? "text-rose-accent" : "text-muted-foreground hover:text-foreground"
}`}
>
<Shield size={16} />
Admin
</Link>
)}
</nav>
)}
</header>
);
}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { Registration } from "@/types/registration";
import { StatusBadge } from "@/components/shared/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { MapPin, Calendar } from "lucide-react";
const STEPS = ["submitted", "form_submitted", "payment_pending", "payment_confirmed", "video_submitted", "accepted"];
@@ -18,24 +19,38 @@ export function RegistrationCard({ registration: r }: Props) {
return (
<Link href={`/championships/${r.championship_id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<Card className="group border-border/40 bg-surface-elevated hover:border-rose-accent/30 transition-all duration-300 cursor-pointer hover:glow-rose">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold text-gray-900">{r.championship_title ?? "Championship"}</p>
{r.championship_location && <p className="text-sm text-gray-500">📍 {r.championship_location}</p>}
{date && <p className="text-sm text-gray-500">📅 {date}</p>}
<div className="space-y-1.5">
<p className="font-semibold text-foreground group-hover:text-rose-accent transition-colors">
{r.championship_title ?? "Championship"}
</p>
{r.championship_location && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin size={13} className="text-dim shrink-0" />
{r.championship_location}
</p>
)}
{date && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar size={13} className="text-dim shrink-0" />
{date}
</p>
)}
</div>
<StatusBadge status={r.status} type="registration" />
</div>
{/* Progress dots */}
{/* Progress bar */}
<div className="flex gap-1.5">
{STEPS.map((_, i) => (
<div
key={i}
className={`h-2 flex-1 rounded-full ${
i <= stepIndex ? "bg-violet-500" : "bg-gray-200"
className={`h-1.5 flex-1 rounded-full transition-colors ${
i <= stepIndex
? "bg-gradient-to-r from-rose-accent to-purple-accent"
: "bg-border/40"
}`}
/>
))}

View File

@@ -1,4 +1,5 @@
import { Registration } from "@/types/registration";
import { Check, AlertTriangle } from "lucide-react";
const STEPS: { key: string; label: string }[] = [
{ key: "submitted", label: "Submitted" },
@@ -20,7 +21,8 @@ export function RegistrationTimeline({ registration }: Props) {
if (isRejected) {
return (
<div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="flex items-center gap-3 rounded-xl bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
<AlertTriangle size={16} className="shrink-0" />
Your registration was <strong>rejected</strong>.
</div>
);
@@ -28,26 +30,34 @@ export function RegistrationTimeline({ registration }: Props) {
if (isWaitlisted) {
return (
<div className="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-700">
<div className="flex items-center gap-3 rounded-xl bg-gold-soft border border-gold-accent/20 px-4 py-3 text-sm text-gold-accent">
<AlertTriangle size={16} className="shrink-0" />
You are on the <strong>waitlist</strong>.
</div>
);
}
return (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Registration progress</p>
<ol className="space-y-2">
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">Registration progress</p>
<ol className="space-y-1">
{STEPS.map((step, i) => {
const done = i <= currentIndex;
const isCurrent = i === currentIndex;
return (
<li key={step.key} className="flex items-center gap-3 text-sm">
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-bold ${
done ? "bg-violet-600 text-white" : "bg-gray-200 text-gray-400"
}`}>
{done ? "✓" : i + 1}
<li key={step.key} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-all ${
done
? "bg-rose-accent text-white"
: "bg-surface-elevated border border-border/40 text-dim"
} ${isCurrent ? "glow-rose" : ""}`}>
{done ? <Check size={13} /> : i + 1}
</span>
<span className={`${
done ? "text-foreground" : "text-dim"
} ${isCurrent ? "font-medium" : ""}`}>
{step.label}
</span>
<span className={done ? "text-gray-900" : "text-gray-400"}>{step.label}</span>
</li>
);
})}

View File

@@ -1,21 +1,21 @@
import { Badge } from "@/components/ui/badge";
const CHAMPIONSHIP_COLORS: Record<string, string> = {
open: "bg-green-100 text-green-700",
closed: "bg-gray-100 text-gray-600",
draft: "bg-yellow-100 text-yellow-700",
completed: "bg-blue-100 text-blue-700",
open: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
closed: "bg-surface-elevated text-dim border-border/40",
draft: "bg-gold-soft text-gold-accent border-gold-accent/20",
completed: "bg-blue-500/15 text-blue-400 border-blue-500/20",
};
const REGISTRATION_COLORS: Record<string, string> = {
submitted: "bg-gray-100 text-gray-600",
form_submitted: "bg-yellow-100 text-yellow-700",
payment_pending: "bg-orange-100 text-orange-700",
payment_confirmed: "bg-blue-100 text-blue-700",
video_submitted: "bg-violet-100 text-violet-700",
accepted: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
waitlisted: "bg-amber-100 text-amber-700",
submitted: "bg-surface-elevated text-muted-foreground border-border/40",
form_submitted: "bg-gold-soft text-gold-accent border-gold-accent/20",
payment_pending: "bg-orange-500/15 text-orange-400 border-orange-500/20",
payment_confirmed: "bg-blue-500/15 text-blue-400 border-blue-500/20",
video_submitted: "bg-purple-soft text-purple-accent border-purple-accent/20",
accepted: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
rejected: "bg-destructive/15 text-destructive border-destructive/20",
waitlisted: "bg-gold-soft text-gold-accent border-gold-accent/20",
};
interface Props {
@@ -25,8 +25,10 @@ interface Props {
export function StatusBadge({ status, type = "championship" }: Props) {
const map = type === "championship" ? CHAMPIONSHIP_COLORS : REGISTRATION_COLORS;
const color = map[status] ?? "bg-gray-100 text-gray-600";
const color = map[status] ?? "bg-surface-elevated text-dim border-border/40";
return (
<Badge className={`${color} border-0 capitalize`}>{status.replace("_", " ")}</Badge>
<Badge className={`${color} border capitalize text-[11px] tracking-wide`}>
{status.replace("_", " ")}
</Badge>
);
}

View File

@@ -0,0 +1,62 @@
/* ─── Keyframes ─── */
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(233, 30, 99, 0.2); }
50% { box-shadow: 0 0 40px rgba(233, 30, 99, 0.4); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ─── Animation utilities ─── */
.animate-fade-in-up { animation: fade-in-up 0.5s ease-out both; }
.animate-fade-in { animation: fade-in 0.4s ease-out both; }
.animate-slide-in-left { animation: slide-in-left 0.5s ease-out both; }
.animate-pulse-glow { animation: pulse-glow 3s ease-in-out infinite; }
.animate-float { animation: float 4s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 8s linear infinite; }
.animate-shimmer {
background-size: 200% 100%;
animation: shimmer 2s linear infinite;
}
/* ─── Stagger delays ─── */
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
.stagger-7 { animation-delay: 0.35s; }
.stagger-8 { animation-delay: 0.4s; }
.stagger-9 { animation-delay: 0.45s; }

38
web/src/styles/theme.css Normal file
View File

@@ -0,0 +1,38 @@
/* ──────────────────────────────────────────
DARK LUXURY THEME — CSS Variables
────────────────────────────────────────── */
:root {
--radius: 0.75rem;
--background: #07060E;
--foreground: #F2EFFF;
--card: #12111F;
--card-foreground: #F2EFFF;
--popover: #15142A;
--popover-foreground: #F2EFFF;
--primary: #E91E63;
--primary-foreground: #FFFFFF;
--secondary: #1A1935;
--secondary-foreground: #C8C4E0;
--muted: #15142A;
--muted-foreground: #7B78A0;
--accent: #1F1E38;
--accent-foreground: #E8E5FF;
--destructive: #FF1744;
--border: #232040;
--input: #1A1935;
--ring: #E91E63;
--chart-1: #E91E63;
--chart-2: #9C27B0;
--chart-3: #D4A843;
--chart-4: #40C4FF;
--chart-5: #00E676;
--sidebar: #0A091A;
--sidebar-foreground: #F2EFFF;
--sidebar-primary: #E91E63;
--sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #1A1935;
--sidebar-accent-foreground: #F2EFFF;
--sidebar-border: #232040;
--sidebar-ring: #E91E63;
}

View File

@@ -0,0 +1,108 @@
/* ─── Glass morphism ─── */
.glass {
background: rgba(18, 17, 31, 0.7);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(35, 32, 64, 0.6);
}
.glass-strong {
background: rgba(18, 17, 31, 0.85);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(35, 32, 64, 0.8);
}
/* ─── Glow effects ─── */
.glow-rose {
box-shadow: 0 0 20px rgba(233, 30, 99, 0.15), 0 0 60px rgba(233, 30, 99, 0.05);
}
.glow-rose-strong {
box-shadow: 0 0 30px rgba(233, 30, 99, 0.25), 0 0 80px rgba(233, 30, 99, 0.1);
}
.glow-purple {
box-shadow: 0 0 20px rgba(156, 39, 176, 0.15), 0 0 60px rgba(156, 39, 176, 0.05);
}
.glow-gold {
box-shadow: 0 0 20px rgba(212, 168, 67, 0.15), 0 0 60px rgba(212, 168, 67, 0.05);
}
/* ─── Gradient border ─── */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(233, 30, 99, 0.4), rgba(156, 39, 176, 0.2), rgba(212, 168, 67, 0.3));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* ─── Text gradients ─── */
.text-gradient-rose {
background: linear-gradient(135deg, #E91E63, #F48FB1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-gold {
background: linear-gradient(135deg, #D4A843, #F5D58E);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-mixed {
background: linear-gradient(135deg, #E91E63, #9C27B0, #D4A843);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ─── Background meshes ─── */
.bg-mesh {
background:
radial-gradient(ellipse 80% 50% at 20% 40%, rgba(233, 30, 99, 0.08), transparent),
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(156, 39, 176, 0.06), transparent),
radial-gradient(ellipse 50% 60% at 50% 80%, rgba(212, 168, 67, 0.04), transparent);
}
.bg-mesh-strong {
background:
radial-gradient(ellipse 80% 50% at 20% 40%, rgba(233, 30, 99, 0.12), transparent),
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(156, 39, 176, 0.10), transparent),
radial-gradient(ellipse 50% 60% at 50% 80%, rgba(212, 168, 67, 0.06), transparent);
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #232040;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #2E2B55;
}