auth rework

This commit is contained in:
2025-12-17 15:34:56 +01:00
parent a213a5cf9f
commit 75eaa1aa9f
24 changed files with 6115 additions and 1992 deletions

View File

@@ -0,0 +1,182 @@
'use client';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';
import {
UserPlus,
Link as LinkIcon,
Settings,
Trophy,
Car,
Users,
Shield,
CheckCircle2
} from 'lucide-react';
interface WorkflowStep {
id: number;
icon: typeof UserPlus;
title: string;
description: string;
color: string;
}
const WORKFLOW_STEPS: WorkflowStep[] = [
{
id: 1,
icon: UserPlus,
title: 'Create Account',
description: 'Sign up with email or connect iRacing',
color: 'text-primary-blue',
},
{
id: 2,
icon: LinkIcon,
title: 'Link iRacing',
description: 'Connect your iRacing profile for stats',
color: 'text-purple-400',
},
{
id: 3,
icon: Settings,
title: 'Configure Profile',
description: 'Set up your racing preferences',
color: 'text-warning-amber',
},
{
id: 4,
icon: Trophy,
title: 'Join Leagues',
description: 'Find and join competitive leagues',
color: 'text-performance-green',
},
{
id: 5,
icon: Car,
title: 'Start Racing',
description: 'Compete and track your progress',
color: 'text-primary-blue',
},
];
export default function AuthWorkflowMockup() {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % WORKFLOW_STEPS.length);
}, 3000);
return () => clearInterval(interval);
}, [isMounted]);
if (!isMounted) {
return (
<div className="relative w-full">
<div className="bg-iron-gray/50 rounded-2xl border border-charcoal-outline p-6">
<div className="flex justify-between gap-2">
{WORKFLOW_STEPS.map((step) => (
<div key={step.id} className="flex flex-col items-center text-center flex-1">
<div className="w-10 h-10 rounded-lg bg-iron-gray border border-charcoal-outline flex items-center justify-center mb-2">
<step.icon className={`w-4 h-4 ${step.color}`} />
</div>
<h4 className="text-xs font-medium text-white">{step.title}</h4>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="relative w-full">
<div className="bg-iron-gray/50 rounded-2xl border border-charcoal-outline p-4 sm:p-6 overflow-hidden">
{/* Connection Lines */}
<div className="absolute top-[3.5rem] left-[8%] right-[8%] hidden sm:block">
<div className="h-0.5 bg-charcoal-outline relative">
<motion.div
className="absolute h-full bg-gradient-to-r from-primary-blue to-performance-green"
initial={{ width: '0%' }}
animate={{ width: `${(activeStep / (WORKFLOW_STEPS.length - 1)) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</div>
</div>
{/* Steps */}
<div className="flex justify-between gap-2 relative">
{WORKFLOW_STEPS.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
const StepIcon = step.icon;
return (
<motion.div
key={step.id}
className="flex flex-col items-center text-center cursor-pointer flex-1"
onClick={() => setActiveStep(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
className={`w-10 h-10 sm:w-12 sm:h-12 rounded-lg border flex items-center justify-center mb-2 transition-all duration-300 ${
isActive
? 'bg-primary-blue/20 border-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: isCompleted
? 'bg-performance-green/20 border-performance-green/50'
: 'bg-iron-gray border-charcoal-outline'
}`}
animate={isActive && !shouldReduceMotion ? {
scale: [1, 1.08, 1],
transition: { duration: 1, repeat: Infinity }
} : {}}
>
{isCompleted ? (
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-performance-green" />
) : (
<StepIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${isActive ? step.color : 'text-gray-500'}`} />
)}
</motion.div>
<h4 className={`text-xs font-medium transition-colors hidden sm:block ${
isActive ? 'text-white' : 'text-gray-400'
}`}>
{step.title}
</h4>
</motion.div>
);
})}
</div>
{/* Active Step Preview - Mobile */}
<AnimatePresence mode="wait">
<motion.div
key={activeStep}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="mt-4 pt-4 border-t border-charcoal-outline sm:hidden"
>
<div className="text-center">
<p className="text-xs text-gray-400 mb-1">
Step {activeStep + 1}: {WORKFLOW_STEPS[activeStep].title}
</p>
<p className="text-xs text-gray-500">
{WORKFLOW_STEPS[activeStep].description}
</p>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { motion } from 'framer-motion';
import { LucideIcon } from 'lucide-react';
interface RoleCardProps {
icon: LucideIcon;
title: string;
description: string;
features: string[];
color: string;
selected?: boolean;
onClick?: () => void;
}
export default function RoleCard({
icon: Icon,
title,
description,
features,
color,
selected = false,
onClick,
}: RoleCardProps) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 ${
selected
? `border-${color} bg-${color}/10 shadow-[0_0_20px_rgba(25,140,255,0.2)]`
: 'border-charcoal-outline bg-iron-gray/50 hover:border-gray-600 hover:bg-iron-gray'
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
selected ? `bg-${color}/20` : 'bg-deep-graphite'
}`}
>
<Icon className={`w-5 h-5 ${selected ? `text-${color}` : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<h3
className={`font-semibold transition-colors ${
selected ? 'text-white' : 'text-gray-200'
}`}
>
{title}
</h3>
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all ${
selected ? 'border-primary-blue bg-primary-blue' : 'border-gray-600'
}`}
>
{selected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-2 h-2 rounded-full bg-white"
/>
)}
</div>
</div>
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<ul className="space-y-1">
{features.map((feature, index) => (
<li key={index} className="text-xs text-gray-400 flex items-center gap-2">
<span
className={`w-1 h-1 rounded-full ${selected ? 'bg-primary-blue' : 'bg-gray-600'}`}
/>
{feature}
</li>
))}
</ul>
</div>
</motion.button>
);
}

View File

@@ -2,7 +2,8 @@
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { LogOut, Settings, Star, Paintbrush, Building2, BarChart3, Megaphone, CreditCard, Handshake } from 'lucide-react';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import { LogOut, Settings, Star, Paintbrush, Building2, BarChart3, Megaphone, CreditCard, Handshake, ChevronDown, TrendingUp, Trophy } from 'lucide-react';
import { useAuth } from '@/lib/auth/AuthContext';
import { useEffectiveDriverId } from '@/lib/currentDriver';
@@ -25,11 +26,66 @@ function useSponsorMode(): boolean {
return isSponsor;
}
// Sponsor Pill Component - matches the style of DriverSummaryPill
function SponsorSummaryPill({
onClick,
companyName = 'Acme Racing Co.',
activeSponsors = 7,
impressions = 127,
}: {
onClick: () => void;
companyName?: string;
activeSponsors?: number;
impressions?: number;
}) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.button
onClick={onClick}
className="group flex items-center gap-3 rounded-full bg-gradient-to-r from-iron-gray to-deep-graphite border border-charcoal-outline px-3 py-1.5 hover:border-performance-green/50 transition-all duration-200"
whileHover={shouldReduceMotion ? {} : { scale: 1.02 }}
whileTap={shouldReduceMotion ? {} : { scale: 0.98 }}
>
{/* Avatar/Logo */}
<div className="relative">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-performance-green/20 to-performance-green/5 border border-performance-green/30 flex items-center justify-center">
<Building2 className="w-4 h-4 text-performance-green" />
</div>
{/* Active indicator */}
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-performance-green border-2 border-deep-graphite" />
</div>
{/* Info */}
<div className="hidden sm:flex flex-col items-start">
<span className="text-xs font-semibold text-white truncate max-w-[100px]">
{companyName.split(' ')[0]}
</span>
<div className="flex items-center gap-1.5 text-[10px] text-gray-500">
<span className="flex items-center gap-0.5">
<Trophy className="w-2.5 h-2.5 text-performance-green" />
{activeSponsors}
</span>
<span className="text-gray-600"></span>
<span className="flex items-center gap-0.5">
<TrendingUp className="w-2.5 h-2.5 text-primary-blue" />
{impressions}k
</span>
</div>
</div>
{/* Chevron */}
<ChevronDown className="w-3.5 h-3.5 text-gray-500 group-hover:text-gray-300 transition-colors" />
</motion.button>
);
}
export default function UserPill() {
const { session, login } = useAuth();
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isSponsorMode = useSponsorMode();
const shouldReduceMotion = useReducedMotion();
const user = session?.user as
| {
@@ -113,75 +169,112 @@ export default function UserPill() {
};
}, [session, driver, primaryDriverId]);
// Sponsor mode UI - check BEFORE session check so sponsors without auth still see sponsor UI
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (isMenuOpen) {
const target = e.target as HTMLElement;
if (!target.closest('[data-user-pill]')) {
setIsMenuOpen(false);
}
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [isMenuOpen]);
// Sponsor mode UI
if (isSponsorMode) {
return (
<div className="relative inline-flex items-center">
<button
onClick={() => setIsMenuOpen((open) => !open)}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-performance-green/10 border border-performance-green/30 hover:bg-performance-green/20 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-performance-green/20 flex items-center justify-center">
<Building2 className="w-4 h-4 text-performance-green" />
</div>
<span className="text-sm font-semibold text-performance-green">Sponsor</span>
</button>
<div className="relative inline-flex items-center" data-user-pill>
<SponsorSummaryPill onClick={() => setIsMenuOpen((open) => !open)} />
{isMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50">
<div className="p-3 border-b border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wide">Demo Sponsor Account</p>
<p className="text-sm font-semibold text-white mt-1">Acme Racing Co.</p>
</div>
<div className="py-1 text-sm text-gray-200">
<Link
href="/sponsor"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<BarChart3 className="h-4 w-4 text-performance-green" />
<span>Dashboard</span>
</Link>
<Link
href="/sponsor/campaigns"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Megaphone className="h-4 w-4 text-primary-blue" />
<span>My Sponsorships</span>
</Link>
<Link
href="/sponsor/billing"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<CreditCard className="h-4 w-4 text-warning-amber" />
<span>Billing</span>
</Link>
<Link
href="/sponsor/settings"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</div>
<div className="border-t border-charcoal-outline">
<button
type="button"
onClick={() => {
document.cookie = 'gridpilot_demo_mode=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.reload();
}}
className="flex w-full items-center justify-between px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<span>Exit Sponsor Mode</span>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
)}
<AnimatePresence>
{isMenuOpen && (
<motion.div
className="absolute right-0 top-full mt-2 w-64 rounded-xl bg-deep-graphite border border-charcoal-outline shadow-xl shadow-black/30 z-50 overflow-hidden"
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.15 }}
>
{/* Header */}
<div className="p-4 bg-gradient-to-r from-performance-green/10 to-transparent border-b border-charcoal-outline">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-performance-green/20 to-performance-green/5 border border-performance-green/30 flex items-center justify-center">
<Building2 className="w-5 h-5 text-performance-green" />
</div>
<div>
<p className="text-sm font-semibold text-white">Acme Racing Co.</p>
<p className="text-xs text-gray-500">Sponsor Account</p>
</div>
</div>
{/* Quick stats */}
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-charcoal-outline/50">
<div className="flex items-center gap-1.5">
<Trophy className="w-3.5 h-3.5 text-performance-green" />
<span className="text-xs text-gray-400">7 active</span>
</div>
<div className="flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-xs text-gray-400">127k views</span>
</div>
</div>
</div>
{/* Menu Items */}
<div className="py-2 text-sm text-gray-200">
<Link
href="/sponsor"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<BarChart3 className="h-4 w-4 text-performance-green" />
<span>Dashboard</span>
</Link>
<Link
href="/sponsor/campaigns"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Megaphone className="h-4 w-4 text-primary-blue" />
<span>My Sponsorships</span>
</Link>
<Link
href="/sponsor/billing"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<CreditCard className="h-4 w-4 text-warning-amber" />
<span>Billing</span>
</Link>
<Link
href="/sponsor/settings"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4 text-gray-400" />
<span>Settings</span>
</Link>
</div>
{/* Footer */}
<div className="border-t border-charcoal-outline">
<button
type="button"
onClick={() => {
document.cookie = 'gridpilot_demo_mode=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.reload();
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-gray-500 hover:text-racing-red hover:bg-racing-red/5 transition-colors"
>
<span>Exit Sponsor Mode</span>
<LogOut className="h-4 w-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -210,7 +303,7 @@ export default function UserPill() {
}
return (
<div className="relative inline-flex items-center">
<div className="relative inline-flex items-center" data-user-pill>
<DriverSummaryPill
driver={data.driver}
rating={data.rating}
@@ -219,61 +312,69 @@ export default function UserPill() {
onClick={() => setIsMenuOpen((open) => !open)}
/>
{isMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50">
<div className="py-1 text-sm text-gray-200">
<Link
href="/profile"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Profile
</Link>
<Link
href="/profile/leagues"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Manage leagues
</Link>
<Link
href="/profile/liveries"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Paintbrush className="h-4 w-4" />
<span>Liveries</span>
</Link>
<Link
href="/profile/sponsorship-requests"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Handshake className="h-4 w-4 text-performance-green" />
<span>Sponsorship Requests</span>
</Link>
<Link
href="/profile/settings"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</div>
<div className="border-t border-charcoal-outline">
<form action="/auth/logout" method="POST">
<button
type="submit"
className="flex w-full items-center justify-between px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
<AnimatePresence>
{isMenuOpen && (
<motion.div
className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50"
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
<div className="py-1 text-sm text-gray-200">
<Link
href="/profile"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<span>Logout</span>
<LogOut className="h-4 w-4" />
</button>
</form>
</div>
</div>
)}
Profile
</Link>
<Link
href="/profile/leagues"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Manage leagues
</Link>
<Link
href="/profile/liveries"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Paintbrush className="h-4 w-4" />
<span>Liveries</span>
</Link>
<Link
href="/profile/sponsorship-requests"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Handshake className="h-4 w-4 text-performance-green" />
<span>Sponsorship Requests</span>
</Link>
<Link
href="/profile/settings"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</div>
<div className="border-t border-charcoal-outline">
<form action="/auth/logout" method="POST">
<button
type="submit"
className="flex w-full items-center justify-between px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<span>Logout</span>
<LogOut className="h-4 w-4" />
</button>
</form>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { ReactNode, useEffect, useState } from 'react';
import { LucideIcon } from 'lucide-react';
interface SponsorBenefitCardProps {
icon: LucideIcon;
title: string;
description: string;
stats?: {
value: string;
label: string;
};
variant?: 'default' | 'highlight';
delay?: number;
}
export default function SponsorBenefitCard({
icon: Icon,
title,
description,
stats,
variant = 'default',
delay = 0,
}: SponsorBenefitCardProps) {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const isHighlight = variant === 'highlight';
const cardContent = (
<div
className={`
relative h-full rounded-xl p-6 transition-all duration-300
${isHighlight
? 'bg-gradient-to-br from-primary-blue/10 to-primary-blue/5 border border-primary-blue/30'
: 'bg-iron-gray/50 border border-charcoal-outline hover:border-charcoal-outline/80'
}
`}
>
{/* Icon */}
<div
className={`
w-12 h-12 rounded-xl flex items-center justify-center mb-4
${isHighlight
? 'bg-primary-blue/20'
: 'bg-iron-gray border border-charcoal-outline'
}
`}
>
<Icon className={`w-6 h-6 ${isHighlight ? 'text-primary-blue' : 'text-gray-400'}`} />
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-sm text-gray-400 leading-relaxed">{description}</p>
{/* Stats */}
{stats && (
<div className="mt-4 pt-4 border-t border-charcoal-outline/50">
<div className="flex items-baseline gap-2">
<span className={`text-2xl font-bold ${isHighlight ? 'text-primary-blue' : 'text-white'}`}>
{stats.value}
</span>
<span className="text-sm text-gray-500">{stats.label}</span>
</div>
</div>
)}
{/* Highlight Glow Effect */}
{isHighlight && (
<div className="absolute -inset-px rounded-xl bg-gradient-to-br from-primary-blue/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
)}
</div>
);
if (!isMounted || shouldReduceMotion) {
return <div className="group">{cardContent}</div>;
}
return (
<motion.div
className="group"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
>
{cardContent}
</motion.div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { ReactNode, useEffect, useState } from 'react';
import { Building2, TrendingUp, Eye, Users, ChevronRight } from 'lucide-react';
interface SponsorHeroProps {
title: string;
subtitle: string;
children?: ReactNode;
}
export default function SponsorHero({ title, subtitle, children }: SponsorHeroProps) {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
};
if (!isMounted || shouldReduceMotion) {
return (
<div className="relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-transparent" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary-blue/10 via-transparent to-transparent" />
{/* Grid Pattern */}
<div
className="absolute inset-0 opacity-5"
style={{
backgroundImage: `
linear-gradient(to right, #198CFF 1px, transparent 1px),
linear-gradient(to bottom, #198CFF 1px, transparent 1px)
`,
backgroundSize: '40px 40px',
}}
/>
<div className="relative max-w-5xl mx-auto px-4 py-16 sm:py-24">
<div className="text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary-blue/10 border border-primary-blue/20 mb-6">
<Building2 className="w-4 h-4 text-primary-blue" />
<span className="text-sm text-primary-blue font-medium">Sponsor Portal</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight">
{title}
</h1>
<p className="text-lg sm:text-xl text-gray-400 max-w-2xl mx-auto mb-10">
{subtitle}
</p>
{children}
</div>
</div>
</div>
);
}
return (
<motion.div
className="relative overflow-hidden"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-transparent" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary-blue/10 via-transparent to-transparent" />
{/* Animated Grid Pattern */}
<motion.div
className="absolute inset-0 opacity-5"
style={{
backgroundImage: `
linear-gradient(to right, #198CFF 1px, transparent 1px),
linear-gradient(to bottom, #198CFF 1px, transparent 1px)
`,
backgroundSize: '40px 40px',
}}
animate={{
backgroundPosition: ['0px 0px', '40px 40px'],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'linear',
}}
/>
<div className="relative max-w-5xl mx-auto px-4 py-16 sm:py-24">
<div className="text-center">
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary-blue/10 border border-primary-blue/20 mb-6"
>
<Building2 className="w-4 h-4 text-primary-blue" />
<span className="text-sm text-primary-blue font-medium">Sponsor Portal</span>
</motion.div>
<motion.h1
variants={itemVariants}
className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight"
>
{title}
</motion.h1>
<motion.p
variants={itemVariants}
className="text-lg sm:text-xl text-gray-400 max-w-2xl mx-auto mb-10"
>
{subtitle}
</motion.p>
<motion.div variants={itemVariants}>
{children}
</motion.div>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';
import {
Search,
MousePointer,
CreditCard,
CheckCircle2,
Car,
Eye,
TrendingUp,
Building2
} from 'lucide-react';
interface WorkflowStep {
id: number;
icon: typeof Search;
title: string;
description: string;
color: string;
}
const WORKFLOW_STEPS: WorkflowStep[] = [
{
id: 1,
icon: Search,
title: 'Browse Leagues',
description: 'Find leagues that match your target audience',
color: 'text-primary-blue',
},
{
id: 2,
icon: MousePointer,
title: 'Select Tier',
description: 'Choose main or secondary sponsorship slot',
color: 'text-purple-400',
},
{
id: 3,
icon: CreditCard,
title: 'Complete Payment',
description: 'Secure payment with automatic invoicing',
color: 'text-warning-amber',
},
{
id: 4,
icon: Car,
title: 'Logo Placement',
description: 'Your brand on liveries and league pages',
color: 'text-performance-green',
},
{
id: 5,
icon: TrendingUp,
title: 'Track Results',
description: 'Monitor impressions and engagement',
color: 'text-primary-blue',
},
];
export default function SponsorWorkflowMockup() {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % WORKFLOW_STEPS.length);
}, 3000);
return () => clearInterval(interval);
}, [isMounted]);
if (!isMounted) {
return (
<div className="relative w-full max-w-4xl mx-auto">
<div className="bg-iron-gray/50 rounded-2xl border border-charcoal-outline p-8">
<div className="grid grid-cols-5 gap-4">
{WORKFLOW_STEPS.map((step) => (
<div key={step.id} className="flex flex-col items-center text-center">
<div className="w-14 h-14 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center mb-3">
<step.icon className={`w-6 h-6 ${step.color}`} />
</div>
<h4 className="text-sm font-medium text-white mb-1">{step.title}</h4>
<p className="text-xs text-gray-500">{step.description}</p>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="relative w-full max-w-4xl mx-auto">
<div className="bg-iron-gray/50 rounded-2xl border border-charcoal-outline p-6 sm:p-8 overflow-hidden">
{/* Connection Lines */}
<div className="absolute top-[4.5rem] left-[10%] right-[10%] hidden sm:block">
<div className="h-0.5 bg-charcoal-outline relative">
<motion.div
className="absolute h-full bg-gradient-to-r from-primary-blue to-performance-green"
initial={{ width: '0%' }}
animate={{ width: `${(activeStep / (WORKFLOW_STEPS.length - 1)) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 relative">
{WORKFLOW_STEPS.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
const StepIcon = step.icon;
return (
<motion.div
key={step.id}
className="flex flex-col items-center text-center cursor-pointer"
onClick={() => setActiveStep(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
className={`w-14 h-14 rounded-xl border flex items-center justify-center mb-3 transition-all duration-300 ${
isActive
? 'bg-primary-blue/20 border-primary-blue shadow-[0_0_20px_rgba(25,140,255,0.3)]'
: isCompleted
? 'bg-performance-green/20 border-performance-green/50'
: 'bg-iron-gray border-charcoal-outline'
}`}
animate={isActive && !shouldReduceMotion ? {
scale: [1, 1.1, 1],
transition: { duration: 1, repeat: Infinity }
} : {}}
>
{isCompleted ? (
<CheckCircle2 className="w-6 h-6 text-performance-green" />
) : (
<StepIcon className={`w-6 h-6 ${isActive ? step.color : 'text-gray-500'}`} />
)}
</motion.div>
<h4 className={`text-sm font-medium mb-1 transition-colors ${
isActive ? 'text-white' : 'text-gray-400'
}`}>
{step.title}
</h4>
<p className={`text-xs transition-colors ${
isActive ? 'text-gray-300' : 'text-gray-600'
}`}>
{step.description}
</p>
</motion.div>
);
})}
</div>
{/* Active Step Preview */}
<AnimatePresence mode="wait">
<motion.div
key={activeStep}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mt-8 pt-6 border-t border-charcoal-outline"
>
<div className="flex items-center justify-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center`}>
{(() => {
const Icon = WORKFLOW_STEPS[activeStep].icon;
return <Icon className={`w-4 h-4 ${WORKFLOW_STEPS[activeStep].color}`} />;
})()}
</div>
<div className="text-left">
<p className="text-sm text-gray-400">
Step {activeStep + 1} of {WORKFLOW_STEPS.length}
</p>
<p className="text-white font-medium">
{WORKFLOW_STEPS[activeStep].title}
</p>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
interface FormFieldProps {
label: string;
icon?: React.ElementType;
children: React.ReactNode;
required?: boolean;
error?: string;
hint?: string;
}
/**
* Form field wrapper with label, optional icon, required indicator, and error/hint display.
* Used for consistent form field layout throughout the app.
*/
export default function FormField({
label,
icon: Icon,
children,
required = false,
error,
hint,
}: FormFieldProps) {
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
<div className="flex items-center gap-2">
{Icon && <Icon className="w-4 h-4 text-gray-500" />}
{label}
{required && <span className="text-racing-red">*</span>}
</div>
</label>
{children}
{error && (
<p className="text-xs text-racing-red mt-1">{error}</p>
)}
{hint && !error && (
<p className="text-xs text-gray-500 mt-1">{hint}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import React from 'react';
import { Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
type BannerType = 'info' | 'warning' | 'success' | 'error';
interface InfoBannerProps {
type?: BannerType;
title?: string;
children: React.ReactNode;
icon?: React.ElementType;
}
const bannerConfig: Record<BannerType, {
icon: React.ElementType;
bg: string;
border: string;
titleColor: string;
}> = {
info: {
icon: Info,
bg: 'bg-iron-gray/30',
border: 'border-charcoal-outline/50',
titleColor: 'text-gray-300',
},
warning: {
icon: AlertTriangle,
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
titleColor: 'text-warning-amber',
},
success: {
icon: CheckCircle,
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
titleColor: 'text-performance-green',
},
error: {
icon: XCircle,
bg: 'bg-racing-red/10',
border: 'border-racing-red/30',
titleColor: 'text-racing-red',
},
};
/**
* Info banner component for displaying contextual information, warnings, or notices.
* Used throughout the app for important messages and helper text.
*/
export default function InfoBanner({
type = 'info',
title,
children,
icon: CustomIcon,
}: InfoBannerProps) {
const config = bannerConfig[type];
const Icon = CustomIcon || config.icon;
return (
<div className={`flex items-start gap-3 p-4 rounded-lg border ${config.bg} ${config.border}`}>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-gray-400">
{title && (
<p className={`font-medium mb-1 ${config.titleColor}`}>{title}</p>
)}
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
interface PageHeaderProps {
icon: React.ElementType;
title: string;
description?: string;
action?: React.ReactNode;
iconGradient?: string;
iconBorder?: string;
}
/**
* Page header component with icon, title, description, and optional action.
* Used at the top of pages for consistent page titling.
*/
export default function PageHeader({
icon: Icon,
title,
description,
action,
iconGradient = 'from-iron-gray to-deep-graphite',
iconBorder = 'border-charcoal-outline',
}: PageHeaderProps) {
return (
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-4">
<div className={`p-3 rounded-xl bg-gradient-to-br ${iconGradient} border ${iconBorder}`}>
<Icon className="w-7 h-7 text-gray-300" />
</div>
{title}
</h1>
{description && (
<p className="text-gray-400 mt-2 ml-16">{description}</p>
)}
</div>
{action}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
interface SectionHeaderProps {
icon: React.ElementType;
title: string;
description?: string;
action?: React.ReactNode;
color?: string;
}
/**
* Section header component with icon, title, optional description and action.
* Used at the top of card sections throughout the app.
*/
export default function SectionHeader({
icon: Icon,
title,
description,
action,
color = 'text-primary-blue'
}: SectionHeaderProps) {
return (
<div className="flex items-center justify-between p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-3">
<div className={`p-2 rounded-lg bg-iron-gray/50 ${color}`}>
<Icon className="w-5 h-5" />
</div>
{title}
</h2>
{description && (
<p className="text-sm text-gray-500 mt-1 ml-12">{description}</p>
)}
</div>
{action}
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import React from 'react';
import Card from './Card';
interface StatCardProps {
icon: React.ElementType;
label: string;
value: string;
subValue?: string;
color?: string;
bgColor?: string;
trend?: {
value: number;
isPositive: boolean;
};
}
/**
* Statistics card component for displaying metrics with icon, label, value, and optional trend.
* Used in dashboards and overview sections.
*/
export default function StatCard({
icon: Icon,
label,
value,
subValue,
color = 'text-primary-blue',
bgColor = 'bg-primary-blue/10',
trend,
}: StatCardProps) {
return (
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
<div className={`p-2 rounded-lg ${bgColor}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<span className="text-sm text-gray-400">{label}</span>
</div>
<div className="text-2xl font-bold text-white">{value}</div>
{subValue && (
<div className="text-xs text-gray-500 mt-1">{subValue}</div>
)}
</div>
{trend && (
<div className={`flex items-center gap-1 text-sm ${trend.isPositive ? 'text-performance-green' : 'text-racing-red'}`}>
<span>{trend.isPositive ? '↑' : '↓'}</span>
<span>{Math.abs(trend.value)}%</span>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
type StatusType = 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
interface StatusBadgeProps {
status: StatusType;
label: string;
icon?: React.ElementType;
size?: 'sm' | 'md';
}
const statusConfig: Record<StatusType, { color: string; bg: string; border: string }> = {
success: {
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
},
warning: {
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
},
error: {
color: 'text-racing-red',
bg: 'bg-racing-red/10',
border: 'border-racing-red/30',
},
info: {
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
},
neutral: {
color: 'text-gray-400',
bg: 'bg-iron-gray',
border: 'border-charcoal-outline',
},
pending: {
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
},
};
/**
* Status badge component for displaying status indicators.
* Used for showing status of items like invoices, sponsorships, etc.
*/
export default function StatusBadge({
status,
label,
icon: Icon,
size = 'sm',
}: StatusBadgeProps) {
const config = statusConfig[status];
const sizeClasses = size === 'sm'
? 'px-2 py-0.5 text-xs'
: 'px-3 py-1 text-sm';
return (
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium border ${config.bg} ${config.color} ${config.border} ${sizeClasses}`}>
{Icon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
{label}
</span>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import React from 'react';
import { motion, useReducedMotion } from 'framer-motion';
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description?: string;
disabled?: boolean;
}
/**
* Toggle switch component with Framer Motion animation.
* Used for boolean settings/preferences.
*/
export default function Toggle({
checked,
onChange,
label,
description,
disabled = false,
}: ToggleProps) {
const shouldReduceMotion = useReducedMotion();
return (
<label className={`flex items-start justify-between cursor-pointer py-3 border-b border-charcoal-outline/50 last:border-b-0 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
<div className="flex-1 pr-4">
<span className="text-gray-200 font-medium">{label}</span>
{description && (
<p className="text-sm text-gray-500 mt-0.5">{description}</p>
)}
</div>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative w-12 h-6 rounded-full transition-colors duration-200 flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-primary-blue/50 ${
checked
? 'bg-primary-blue'
: 'bg-iron-gray'
} ${disabled ? 'cursor-not-allowed' : ''}`}
>
{/* Glow effect when active */}
{checked && (
<motion.div
className="absolute inset-0 rounded-full bg-primary-blue"
initial={{ boxShadow: '0 0 0px rgba(25, 140, 255, 0)' }}
animate={{ boxShadow: '0 0 12px rgba(25, 140, 255, 0.4)' }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
/>
)}
{/* Knob */}
<motion.span
className="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md"
initial={false}
animate={{
x: checked ? 24 : 2,
scale: 1,
}}
whileTap={{ scale: disabled ? 1 : 0.9 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
duration: shouldReduceMotion ? 0 : undefined,
}}
/>
</button>
</label>
);
}