| import { useEffect, useMemo, useState } from 'react'; |
| import { AnimatePresence, motion } from 'motion/react'; |
| import { |
| Zap, |
| Clock, |
| Target, |
| Trophy, |
| CheckCircle2, |
| PlayCircle, |
| Lock, |
| ChevronRight, |
| Calendar, |
| Sparkles, |
| Crown, |
| Coins, |
| Medal, |
| Clock3, |
| } from 'lucide-react'; |
| import { codingQuestions, type CodingQuestion } from '@/data/codingQuestions'; |
| import type { AuthUser } from '@/lib/authClient'; |
| import { cn } from '@/lib/utils'; |
| import { getActivityDateKey } from '@/lib/activityDates'; |
|
|
| interface DailyContestPageProps { |
| user: AuthUser; |
| solvedQuestionIds: string[]; |
| onSelectQuestion: (question: CodingQuestion, mode: 'code' | 'solution') => void; |
| } |
|
|
| const DAILY_CONTEST_STORAGE_PREFIX = 'codequest.dailyContest'; |
| const DAILY_CONTEST_JOIN_STORAGE_PREFIX = 'codequest.dailyContest.joined'; |
|
|
| export const DAILY_CONTEST_PRIZE_TIERS = [ |
| { rank: 1, label: '1st place', coins: 100 }, |
| { rank: 2, label: '2nd place', coins: 75 }, |
| { rank: 3, label: '3rd place', coins: 50 }, |
| { rank: 4, label: '4th - 10th', coins: 5 }, |
| ] as const; |
|
|
| interface DailyContestQuestion { |
| id: string; |
| label: string; |
| question: CodingQuestion; |
| points: number; |
| } |
|
|
| interface DailyContest { |
| id: string; |
| date: Date; |
| questions: DailyContestQuestion[]; |
| } |
|
|
| function getDailyContest(now: Date): DailyContest { |
| const dateKey = getActivityDateKey(now); |
| |
| const dayIndex = now.getDate() % codingQuestions.length; |
| const secondIndex = (dayIndex + Math.floor(codingQuestions.length / 2)) % codingQuestions.length; |
| |
| const q1 = codingQuestions[dayIndex]; |
| const q2 = codingQuestions[secondIndex]; |
| |
| return { |
| id: `daily-contest-${dateKey}`, |
| date: new Date(now.getFullYear(), now.getMonth(), now.getDate()), |
| questions: [ |
| { |
| id: `daily-${dateKey}-q1`, |
| label: 'Q1', |
| question: q1, |
| points: getPointsForDifficulty(q1.difficulty), |
| }, |
| { |
| id: `daily-${dateKey}-q2`, |
| label: 'Q2', |
| question: q2, |
| points: getPointsForDifficulty(q2.difficulty), |
| }, |
| ], |
| }; |
| } |
|
|
| function getPointsForDifficulty(difficulty: CodingQuestion['difficulty']) { |
| const points = { |
| Easy: 100, |
| Medium: 200, |
| Hard: 350, |
| }; |
| return points[difficulty]; |
| } |
|
|
| function readJoinedContestIds(userId: string): string[] { |
| if (typeof window === 'undefined') return []; |
| const key = `${DAILY_CONTEST_JOIN_STORAGE_PREFIX}.${userId}`; |
| try { |
| const data = localStorage.getItem(key); |
| return data ? JSON.parse(data) : []; |
| } catch { |
| return []; |
| } |
| } |
|
|
| function joinDailyContest(userId: string, contestId: string): string[] { |
| const existing = readJoinedContestIds(userId); |
| if (!existing.includes(contestId)) { |
| const updated = [...existing, contestId]; |
| localStorage.setItem(`${DAILY_CONTEST_JOIN_STORAGE_PREFIX}.${userId}`, JSON.stringify(updated)); |
| return updated; |
| } |
| return existing; |
| } |
|
|
| function readDailySolvedIds(userId: string): string[] { |
| if (typeof window === 'undefined') return []; |
| const key = `${DAILY_CONTEST_STORAGE_PREFIX}.solved.${userId}`; |
| try { |
| const data = localStorage.getItem(key); |
| return data ? JSON.parse(data) : []; |
| } catch { |
| return []; |
| } |
| } |
|
|
| function markDailySolved(userId: string, contestId: string): string[] { |
| const existing = readDailySolvedIds(userId); |
| if (!existing.includes(contestId)) { |
| const updated = [...existing, contestId]; |
| localStorage.setItem(`${DAILY_CONTEST_STORAGE_PREFIX}.solved.${userId}`, JSON.stringify(updated)); |
| return updated; |
| } |
| return existing; |
| } |
|
|
| function readDailyStreak(userId: string): { current: number; lastDate: string | null } { |
| if (typeof window === 'undefined') return { current: 0, lastDate: null }; |
| const key = `${DAILY_CONTEST_STORAGE_PREFIX}.streak.${userId}`; |
| try { |
| const data = localStorage.getItem(key); |
| return data ? JSON.parse(data) : { current: 0, lastDate: null }; |
| } catch { |
| return { current: 0, lastDate: null }; |
| } |
| } |
|
|
| function updateDailyStreak(userId: string): number { |
| const today = getActivityDateKey(new Date()); |
| const { current, lastDate } = readDailyStreak(userId); |
| |
| let newStreak = current; |
| if (lastDate) { |
| const last = new Date(lastDate); |
| const todayDate = new Date(); |
| const diffDays = Math.floor((todayDate.getTime() - last.getTime()) / (1000 * 60 * 60 * 24)); |
| |
| if (diffDays === 1) { |
| newStreak = current + 1; |
| } else if (diffDays > 1) { |
| newStreak = 1; |
| } |
| } else { |
| newStreak = 1; |
| } |
| |
| localStorage.setItem(`${DAILY_CONTEST_STORAGE_PREFIX}.streak.${userId}`, |
| JSON.stringify({ current: newStreak, lastDate: today })); |
| return newStreak; |
| } |
|
|
| const CONTEST_START_HOUR = 20; |
| const CONTEST_DURATION_MINUTES = 70; |
| const CONTEST_END_MINUTES = CONTEST_START_HOUR * 60 + CONTEST_DURATION_MINUTES; |
| const REGISTRATION_CUTOFF_MINUTES = 30; |
|
|
| function getContestStartTime(date: Date): Date { |
| const start = new Date(date); |
| start.setHours(CONTEST_START_HOUR, 0, 0, 0); |
| return start; |
| } |
|
|
| function getContestEndTime(date: Date): Date { |
| const end = new Date(date); |
| end.setHours(CONTEST_START_HOUR, CONTEST_DURATION_MINUTES, 0, 0); |
| return end; |
| } |
|
|
| function getRegistrationCutoffTime(date: Date): Date { |
| const cutoff = getContestStartTime(date); |
| cutoff.setMinutes(cutoff.getMinutes() - REGISTRATION_CUTOFF_MINUTES); |
| return cutoff; |
| } |
|
|
| function isContestLive(now: Date): boolean { |
| const start = getContestStartTime(now); |
| const end = getContestEndTime(now); |
| return now >= start && now < end; |
| } |
|
|
| function isRegistrationOpen(now: Date): boolean { |
| const todayStart = getContestStartTime(now); |
| const todayEnd = getContestEndTime(now); |
| const cutoffTime = getRegistrationCutoffTime(now); |
| |
| |
| if (now >= todayStart && now < todayEnd) { |
| return false; |
| } |
| |
| |
| if (now >= cutoffTime && now < todayStart) { |
| return false; |
| } |
| |
| |
| if (now >= todayEnd) { |
| return true; |
| } |
| |
| |
| if (now < cutoffTime) { |
| return true; |
| } |
| |
| return false; |
| } |
|
|
| function getNextContestTime(now: Date): Date { |
| const todayStart = getContestStartTime(now); |
| const todayEnd = getContestEndTime(now); |
| |
| if (now < todayEnd) { |
| |
| return todayStart; |
| } |
| |
| |
| const next = new Date(todayStart); |
| next.setDate(next.getDate() + 1); |
| return next; |
| } |
|
|
| export default function DailyContestPage({ user, solvedQuestionIds, onSelectQuestion }: DailyContestPageProps) { |
| const now = useTime(); |
| const dailyContest = useMemo(() => getDailyContest(now), [now]); |
| const [solvedIds, setSolvedIds] = useState(() => readDailySolvedIds(user.id)); |
| const [streak, setStreak] = useState(() => readDailyStreak(user.id)); |
| const [joinedContestIds, setJoinedContestIds] = useState<string[]>(() => readJoinedContestIds(user.id)); |
| const solvedSet = useMemo(() => new Set(solvedQuestionIds), [solvedQuestionIds]); |
| |
| const contestLive = isContestLive(now); |
| const isJoinedCurrentContest = joinedContestIds.includes(dailyContest.id); |
| const canAccessQuestions = contestLive && isJoinedCurrentContest; |
| |
| const nextContestTime = getNextContestTime(now); |
| const timeUntilContest = formatTimeRemaining(now, nextContestTime); |
| |
| const solvedCount = dailyContest.questions.filter(q => |
| solvedIds.includes(q.id) || solvedSet.has(q.question.id) |
| ).length; |
| const isAllSolved = solvedCount === dailyContest.questions.length; |
| const totalPoints = dailyContest.questions.reduce((sum, q) => sum + q.points, 0); |
| |
| useEffect(() => { |
| setSolvedIds(readDailySolvedIds(user.id)); |
| setStreak(readDailyStreak(user.id)); |
| setJoinedContestIds(readJoinedContestIds(user.id)); |
| }, [user.id, dailyContest.id]); |
|
|
| const registrationOpen = isRegistrationOpen(now); |
| |
| const handleJoinContest = () => { |
| if (registrationOpen && !isJoinedCurrentContest) { |
| const updated = joinDailyContest(user.id, dailyContest.id); |
| setJoinedContestIds(updated); |
| } |
| }; |
|
|
| const handleSolve = (dailyQuestion: DailyContestQuestion) => { |
| if (!canAccessQuestions) return; |
| |
| const isAlreadySolved = solvedIds.includes(dailyQuestion.id) || solvedSet.has(dailyQuestion.question.id); |
| if (isAlreadySolved) { |
| onSelectQuestion(dailyQuestion.question, 'code'); |
| return; |
| } |
| |
| onSelectQuestion(dailyQuestion.question, 'code'); |
| |
| |
| setTimeout(() => { |
| const updated = markDailySolved(user.id, dailyQuestion.id); |
| setSolvedIds(updated); |
| |
| |
| const newSolvedCount = dailyContest.questions.filter(q => |
| updated.includes(q.id) || solvedSet.has(q.question.id) |
| ).length; |
| |
| if (newSolvedCount === dailyContest.questions.length) { |
| const newStreak = updateDailyStreak(user.id); |
| setStreak({ current: newStreak, lastDate: getActivityDateKey(new Date()) }); |
| } |
| }, 500); |
| }; |
|
|
| |
| const contestStatusLabel = contestLive |
| ? isJoinedCurrentContest |
| ? 'Contest Live' |
| : 'Registration Closed' |
| : isJoinedCurrentContest |
| ? 'Registered' |
| : registrationOpen |
| ? 'Open until 7:30 PM' |
| : 'Upcoming'; |
| |
| const contestStatusTone = contestLive |
| ? isJoinedCurrentContest |
| ? 'border-emerald-400/25 bg-emerald-400/15 text-emerald-100' |
| : 'border-rose-400/25 bg-rose-400/12 text-rose-100' |
| : isJoinedCurrentContest |
| ? 'border-cyan-400/25 bg-cyan-400/12 text-cyan-100' |
| : registrationOpen |
| ? 'border-emerald-400/25 bg-emerald-400/12 text-emerald-100' |
| : 'border-amber-400/25 bg-amber-400/12 text-amber-100'; |
| |
| const contestPrimaryAction = contestLive |
| ? isJoinedCurrentContest |
| ? 'Contest Running' |
| : 'Registration Closed' |
| : isJoinedCurrentContest |
| ? 'Registered for Tonight' |
| : registrationOpen |
| ? 'Join Contest' |
| : 'Registration Closed'; |
| |
| const contestWindowLabel = `Join by 7:30 PM • Contest 8:00-9:10 PM`; |
| |
| const statusTone = !contestLive && !isJoinedCurrentContest |
| ? 'border-amber-400/25 bg-amber-400/15 text-amber-100' |
| : !contestLive |
| ? 'border-cyan-400/25 bg-cyan-400/15 text-cyan-100' |
| : isAllSolved |
| ? 'border-emerald-400/25 bg-emerald-400/15 text-emerald-100' |
| : solvedCount > 0 |
| ? 'border-amber-400/25 bg-amber-400/15 text-amber-100' |
| : 'border-orange-400/25 bg-orange-400/15 text-orange-100'; |
|
|
| return ( |
| <div className="mx-auto flex h-full max-w-5xl flex-col gap-5 overflow-y-auto scrollbar-auto-hide text-white pb-6"> |
| {/* Header */} |
| <motion.div |
| initial={{ opacity: 0, y: -16 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.4 }} |
| className="shrink-0 flex flex-wrap items-start justify-between gap-4" |
| > |
| <div> |
| <p className="text-[10px] font-semibold uppercase tracking-[0.3em] text-orange-300/70"> |
| Daily Challenge |
| </p> |
| <h1 className="mt-0.5 text-2xl font-bold tracking-tight text-white lg:text-3xl"> |
| Daily Contest |
| </h1> |
| <p className="mt-1 text-xs text-slate-500"> |
| {now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })} |
| </p> |
| </div> |
| |
| <div className="flex flex-col items-end gap-2"> |
| {/* Simple Text Countdown */} |
| {!contestLive && ( |
| <div className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-2.5 text-right backdrop-blur-xl"> |
| <p className="text-[10px] font-black uppercase tracking-[0.22em] text-slate-400"> |
| {registrationOpen ? 'Registration closes in' : 'Contest starts in'} |
| </p> |
| <p className="mt-1 text-sm font-semibold text-white tabular-nums tracking-widest"> |
| {formatTimeRemaining(now, registrationOpen ? getRegistrationCutoffTime(now) : nextContestTime)} |
| </p> |
| </div> |
| )} |
| {contestLive && ( |
| <div className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-2.5 text-right backdrop-blur-xl"> |
| <p className="text-[10px] font-black uppercase tracking-[0.22em] text-slate-400">Contest ends in</p> |
| <p className="mt-1 text-sm font-semibold text-white"> |
| {formatTimeRemaining(now, getContestEndTime(now))} |
| </p> |
| </div> |
| )} |
| </div> |
| </motion.div> |
| |
| {/* Stats Row */} |
| <div className="shrink-0 grid grid-cols-2 gap-4 lg:grid-cols-4"> |
| <StatCard |
| label="Status" |
| value={contestStatusLabel} |
| hint={contestWindowLabel} |
| icon={contestLive ? (isJoinedCurrentContest ? CheckCircle2 : Lock) : (isJoinedCurrentContest ? Calendar : Lock)} |
| grad={contestLive |
| ? (isJoinedCurrentContest ? 'from-emerald-400 to-cyan-500' : 'from-rose-400 to-red-500') |
| : (isJoinedCurrentContest ? 'from-cyan-400 to-blue-500' : 'from-amber-400 to-orange-500')} |
| glow={contestLive |
| ? (isJoinedCurrentContest ? 'bg-emerald-500/10' : 'bg-rose-500/10') |
| : (isJoinedCurrentContest ? 'bg-cyan-500/10' : 'bg-amber-500/10')} |
| badge={contestStatusLabel} |
| badgeClassName={contestStatusTone} |
| index={0} |
| /> |
| <RewardsStatCard |
| label="Daily Rewards" |
| icon={Crown} |
| grad="from-amber-400 to-orange-500" |
| glow="bg-amber-500/10" |
| index={1} |
| /> |
| <StatCard |
| label="Points" |
| value={`+${totalPoints}`} |
| hint="For solving both" |
| icon={Trophy} |
| grad="from-violet-400 to-fuchsia-500" |
| glow="bg-violet-500/10" |
| index={2} |
| /> |
| <StatCard |
| label="Questions" |
| value={`${dailyContest.questions.length}`} |
| hint="Daily challenges" |
| icon={Target} |
| grad="from-cyan-400 to-blue-500" |
| glow="bg-cyan-500/10" |
| index={3} |
| /> |
| </div> |
| |
| {/* Main Content - Problem Arena */} |
| <motion.div |
| initial={{ opacity: 0, y: 16 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ delay: 0.1 }} |
| className="shrink-0" |
| > |
| <div className="h-full rounded-2xl border border-white/[0.08] bg-[#0b111d]/95 p-5 shadow-xl backdrop-blur-sm"> |
| <div className="flex items-start justify-between gap-3"> |
| <div> |
| <p className="text-[10px] font-black uppercase tracking-[0.24em] text-slate-500"> |
| Problem Arena |
| </p> |
| <h2 className="mt-1.5 text-xl font-black text-white">Problems of the Day</h2> |
| </div> |
| <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/20 to-rose-500/10 text-orange-300"> |
| <Calendar size={22} /> |
| </div> |
| </div> |
| |
| {/* Not Joined State */} |
| {!isJoinedCurrentContest && ( |
| <div className="mt-6"> |
| <div className="relative overflow-hidden rounded-2xl border border-amber-500/20 bg-[linear-gradient(180deg,rgba(251,191,36,0.08),rgba(251,191,36,0.02))] p-8"> |
| <div className="absolute inset-0 bg-[linear-gradient(90deg,transparent,rgba(251,191,36,0.1),transparent)]" /> |
| <div className="relative text-center"> |
| <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/20 border border-amber-500/30"> |
| <Lock size={28} className="text-amber-400" /> |
| </div> |
| <h3 className="mt-4 text-lg font-bold text-white">Join to Unlock Questions</h3> |
| <p className="mt-2 text-sm text-slate-400 max-w-md mx-auto"> |
| Join the daily contest to access today's coding problems. Registration opens after 9:10 PM and closes at 7:30 PM the next day. |
| </p> |
| <button |
| type="button" |
| onClick={handleJoinContest} |
| disabled={!registrationOpen} |
| className={cn( |
| 'mt-5 inline-flex items-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold transition-all duration-200', |
| !registrationOpen |
| ? 'bg-slate-700/50 text-slate-500 cursor-not-allowed' |
| : 'bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-400 hover:to-orange-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-amber-500/20' |
| )} |
| > |
| {!registrationOpen ? 'Registration Closed' : 'Join Contest'} |
| <ChevronRight size={16} /> |
| </button> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Questions Grid - Only show if joined */} |
| {isJoinedCurrentContest && ( |
| <div className="mt-6 grid gap-4 sm:grid-cols-2"> |
| {dailyContest.questions.map((dailyQuestion) => { |
| const isSolved = solvedIds.includes(dailyQuestion.id) || solvedSet.has(dailyQuestion.question.id); |
| const canSolve = contestLive && !isSolved; |
| |
| return ( |
| <div |
| key={dailyQuestion.id} |
| className={cn( |
| 'group relative overflow-hidden rounded-2xl border p-5 shadow-lg transition-all duration-300', |
| !contestLive |
| ? 'border-slate-500/20 bg-slate-500/5' |
| : isSolved |
| ? 'border-emerald-400/20 bg-emerald-400/5 hover:border-emerald-400/30' |
| : 'border-white/[0.08] bg-[linear-gradient(180deg,rgba(16,24,38,0.88),rgba(10,15,28,0.92))] hover:border-white/[0.12] hover:shadow-xl' |
| )} |
| > |
| <div className="pointer-events-none absolute inset-x-0 top-0 h-[2px] bg-[linear-gradient(90deg,transparent,rgba(148,163,184,0.35),transparent)] opacity-80" /> |
| |
| {/* Lock overlay when contest not live */} |
| {!contestLive && ( |
| <div className="absolute inset-0 flex items-center justify-center bg-[#0b111d]/60 backdrop-blur-[2px] z-10 rounded-2xl"> |
| <div className="flex flex-col items-center gap-2"> |
| <div className="flex h-14 w-14 items-center justify-center rounded-full bg-slate-700/50 border border-slate-600/50"> |
| <Clock3 size={24} className="text-slate-400" /> |
| </div> |
| <p className="text-xs font-semibold text-slate-400">Unlocks at 8:00 PM</p> |
| </div> |
| </div> |
| )} |
| |
| <div className="flex items-start justify-between gap-3"> |
| <div className="flex items-center gap-3"> |
| <div className={cn( |
| 'flex h-12 w-12 items-center justify-center rounded-xl border text-base font-black shadow-sm', |
| !contestLive |
| ? 'border-slate-600/30 bg-slate-700/20 text-slate-500' |
| : 'border-white/10 bg-white/[0.05] text-slate-100' |
| )}> |
| {dailyQuestion.label} |
| </div> |
| <div className="min-w-0"> |
| <h3 className={cn('text-base font-bold truncate', !contestLive ? 'text-slate-500' : 'text-white')}> |
| {dailyQuestion.question.title} |
| </h3> |
| <p className="text-sm text-slate-400">{dailyQuestion.question.category}</p> |
| </div> |
| </div> |
| |
| <span |
| className={cn( |
| 'rounded-full px-2.5 py-1 text-xs font-semibold shrink-0', |
| !contestLive |
| ? 'bg-slate-600/20 text-slate-500' |
| : dailyQuestion.question.difficulty === 'Easy' |
| ? 'bg-emerald-400/10 text-emerald-200' |
| : dailyQuestion.question.difficulty === 'Medium' |
| ? 'bg-amber-400/10 text-amber-200' |
| : 'bg-rose-400/10 text-rose-200' |
| )} |
| > |
| {!contestLive ? 'Locked' : dailyQuestion.question.difficulty} |
| </span> |
| </div> |
| |
| <div className="mt-4 flex items-center justify-between"> |
| <div className="flex items-center gap-3"> |
| <div className="flex items-center gap-1.5"> |
| <Trophy size={14} className={!contestLive ? 'text-slate-600' : 'text-amber-300'} /> |
| <span className={cn('text-sm font-semibold', !contestLive ? 'text-slate-500' : 'text-slate-300')}> |
| +{dailyQuestion.points} |
| </span> |
| </div> |
| <span |
| className={cn( |
| 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold', |
| !contestLive |
| ? 'border-slate-500/20 bg-slate-500/10 text-slate-400' |
| : isSolved |
| ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100' |
| : 'border-orange-400/20 bg-orange-400/10 text-orange-100' |
| )} |
| > |
| {!contestLive ? ( |
| <> |
| <Lock size={12} /> |
| Locked |
| </> |
| ) : isSolved ? ( |
| <> |
| <CheckCircle2 size={12} /> |
| Solved |
| </> |
| ) : ( |
| <> |
| <PlayCircle size={12} /> |
| Ready |
| </> |
| )} |
| </span> |
| </div> |
| |
| <button |
| type="button" |
| onClick={() => handleSolve(dailyQuestion)} |
| disabled={!canSolve} |
| className={cn( |
| 'inline-flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200', |
| !contestLive |
| ? 'bg-slate-700/30 text-slate-500 cursor-not-allowed' |
| : canSolve |
| ? 'bg-gradient-to-r from-orange-500 to-rose-500 text-white hover:from-orange-400 hover:to-rose-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-orange-500/20' |
| : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700/70' |
| )} |
| > |
| {!contestLive ? 'Locked' : isSolved ? 'View' : 'Solve'} |
| <ChevronRight size={14} /> |
| </button> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| )} |
|
|
| {} |
| <div className="mt-5 grid grid-cols-3 gap-3"> |
| <InfoCard |
| icon={isJoinedCurrentContest ? CheckCircle2 : Lock} |
| title={isJoinedCurrentContest ? 'Registered' : 'Not Registered'} |
| desc={isJoinedCurrentContest ? 'Ready to compete at 8 PM' : 'Join before 7:30 PM'} |
| variant={isJoinedCurrentContest ? 'success' : 'muted'} |
| /> |
| <InfoCard |
| icon={Trophy} |
| title="Points" |
| desc={`${totalPoints} points available`} |
| variant="default" |
| /> |
| <InfoCard |
| icon={Target} |
| title="Questions" |
| desc={`${dailyContest.questions.length} challenges daily`} |
| variant="default" |
| /> |
| </div> |
| </div> |
| </motion.div> |
| </div> |
| ); |
| } |
|
|
| function StatCard({ |
| label, |
| value, |
| hint, |
| icon, |
| grad, |
| glow, |
| badge, |
| badgeClassName, |
| index, |
| }: { |
| label: string; |
| value: string; |
| hint: string; |
| icon: typeof Trophy; |
| grad: string; |
| glow: string; |
| badge?: string; |
| badgeClassName?: string; |
| index: number; |
| }) { |
| const Icon = icon; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.35, delay: index * 0.06 }} |
| className="group relative flex h-full min-h-[100px] flex-col justify-between overflow-hidden rounded-2xl border border-white/[0.08] bg-[linear-gradient(180deg,rgba(13,19,34,0.96),rgba(10,15,28,0.92))] p-4 shadow-lg backdrop-blur-xl transition-all duration-300 hover:border-white/[0.12] hover:shadow-xl" |
| > |
| <div className={cn('pointer-events-none absolute -right-4 -top-4 h-24 w-24 rounded-full opacity-25 blur-2xl transition-opacity duration-300 group-hover:opacity-40', glow)} /> |
| <div className={cn('absolute bottom-0 left-0 right-0 h-[2px] bg-gradient-to-r', grad)} /> |
| |
| <div className="flex items-center justify-between gap-3"> |
| <div className={cn('flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg opacity-90 transition-transform duration-300 group-hover:scale-105', grad)}> |
| <Icon size={18} className="text-white" /> |
| </div> |
| {badge ? ( |
| <span className={cn('rounded-full border px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] transition-colors duration-200', badgeClassName)}> |
| {badge} |
| </span> |
| ) : null} |
| </div> |
| |
| <div className="mt-2"> |
| <p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p> |
| <p className="mt-1.5 text-xl font-bold tracking-tight text-white">{value}</p> |
| <p className="mt-1 text-[11px] text-slate-400">{hint}</p> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| function RewardsStatCard({ |
| label, |
| icon, |
| grad, |
| glow, |
| index, |
| }: { |
| label: string; |
| icon: typeof Crown; |
| grad: string; |
| glow: string; |
| index: number; |
| }) { |
| const Icon = icon; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.35, delay: index * 0.06 }} |
| className="group relative flex h-full min-h-[100px] flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[linear-gradient(180deg,rgba(13,19,34,0.96),rgba(10,15,28,0.92))] p-3.5 shadow-lg backdrop-blur-xl transition-all duration-300 hover:border-white/[0.12] hover:shadow-xl" |
| > |
| <div className={cn('pointer-events-none absolute -right-4 -top-4 h-24 w-24 rounded-full opacity-25 blur-2xl transition-opacity duration-300 group-hover:opacity-40', glow)} /> |
| <div className={cn('absolute bottom-0 left-0 right-0 h-[2px] bg-gradient-to-r', grad)} /> |
| |
| <div className="flex items-center justify-between gap-2"> |
| <div className={cn('flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br shadow-lg opacity-90 transition-transform duration-300 group-hover:scale-105', grad)}> |
| <Icon size={16} className="text-white" /> |
| </div> |
| <span className="rounded-full border border-amber-500/25 bg-amber-500/12 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.15em] text-amber-100"> |
| Prize Pool |
| </span> |
| </div> |
| |
| <div className="mt-1.5"> |
| <p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p> |
| |
| {/* Prize Tiers - Clear Position Labels */} |
| <div className="mt-2 space-y-1.5"> |
| {/* 1st Place */} |
| <div className="flex items-center justify-between rounded-lg bg-gradient-to-r from-amber-500/15 to-amber-500/5 border border-amber-500/25 px-2.5 py-1.5"> |
| <div className="flex items-center gap-2"> |
| <div className="flex h-5 w-5 items-center justify-center rounded bg-amber-500/25"> |
| <Crown size={12} className="text-amber-300" /> |
| </div> |
| <span className="text-xs font-semibold text-amber-100">1st Place</span> |
| </div> |
| <div className="flex items-center gap-1"> |
| <span className="text-sm font-bold text-amber-200">100</span> |
| <Coins size={11} className="text-amber-400" /> |
| </div> |
| </div> |
| |
| {/* 2nd Place */} |
| <div className="flex items-center justify-between rounded-lg bg-gradient-to-r from-slate-400/15 to-slate-400/5 border border-slate-400/25 px-2.5 py-1.5"> |
| <div className="flex items-center gap-2"> |
| <div className="flex h-5 w-5 items-center justify-center rounded bg-slate-400/25"> |
| <Medal size={12} className="text-slate-300" /> |
| </div> |
| <span className="text-xs font-semibold text-slate-200">2nd Place</span> |
| </div> |
| <div className="flex items-center gap-1"> |
| <span className="text-sm font-bold text-slate-200">75</span> |
| <Coins size={11} className="text-slate-400" /> |
| </div> |
| </div> |
| |
| {/* 3rd Place */} |
| <div className="flex items-center justify-between rounded-lg bg-gradient-to-r from-orange-500/15 to-orange-500/5 border border-orange-500/25 px-2.5 py-1.5"> |
| <div className="flex items-center gap-2"> |
| <div className="flex h-5 w-5 items-center justify-center rounded bg-orange-500/25"> |
| <Target size={12} className="text-orange-300" /> |
| </div> |
| <span className="text-xs font-semibold text-orange-100">3rd Place</span> |
| </div> |
| <div className="flex items-center gap-1"> |
| <span className="text-sm font-bold text-orange-200">50</span> |
| <Coins size={11} className="text-orange-400" /> |
| </div> |
| </div> |
| |
| {/* 4th-10th */} |
| <div className="flex items-center justify-between rounded-lg bg-gradient-to-r from-slate-600/15 to-slate-600/5 border border-slate-600/25 px-2.5 py-1.5"> |
| <div className="flex items-center gap-2"> |
| <div className="flex h-5 w-5 items-center justify-center rounded bg-slate-600/25"> |
| <span className="text-[9px] font-bold text-slate-400">4-10</span> |
| </div> |
| <span className="text-xs font-semibold text-slate-300">4th - 10th Place</span> |
| </div> |
| <div className="flex items-center gap-1"> |
| <span className="text-sm font-bold text-slate-200">5</span> |
| <span className="text-[10px] text-slate-400">each</span> |
| <Coins size={11} className="text-slate-500" /> |
| </div> |
| </div> |
| </div> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| function InfoCard({ icon, title, desc, variant = 'default' }: { icon: typeof Clock; title: string; desc: string; variant?: 'default' | 'success' | 'muted' }) { |
| const Icon = icon; |
| const iconBgClass = variant === 'success' |
| ? 'bg-emerald-500/20 text-emerald-300 group-hover:bg-emerald-500/30' |
| : variant === 'muted' |
| ? 'bg-slate-600/30 text-slate-400 group-hover:bg-slate-600/40' |
| : 'bg-slate-700/30 text-slate-300 group-hover:bg-slate-700/50'; |
| |
| return ( |
| <div className="group flex h-full items-center gap-3 rounded-xl border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)] transition-all duration-200 hover:border-white/[0.12] hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.03))]"> |
| <div className={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors', iconBgClass)}> |
| <Icon size={16} /> |
| </div> |
| <div className="min-w-0"> |
| <p className="text-sm font-semibold text-white truncate">{title}</p> |
| <p className="text-xs text-slate-400 leading-relaxed">{desc}</p> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function useTime() { |
| const [now, setNow] = useState(new Date()); |
|
|
| useEffect(() => { |
| const timer = setInterval(() => setNow(new Date()), 1_000); |
| return () => clearInterval(timer); |
| }, []); |
|
|
| return now; |
| } |
|
|
| function formatTimeRemaining(now: Date, target: Date): string { |
| const diff = target.getTime() - now.getTime(); |
| if (diff <= 0) return '00:00:00'; |
|
|
| const hours = Math.floor(diff / (1000 * 60 * 60)); |
| const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); |
| const seconds = Math.floor((diff % (1000 * 60)) / 1000); |
|
|
| return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; |
| } |
|
|
|
|