v2
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
interface CountdownTimerProps {
|
||||
refreshInterval?: number; // in seconds
|
||||
}
|
||||
|
||||
export default function CountdownTimer({ refreshInterval = 300 }: CountdownTimerProps) {
|
||||
const [timeLeft, setTimeLeft] = useState<number>(refreshInterval)
|
||||
|
||||
useEffect(() => {
|
||||
// Reset when refresh interval changes
|
||||
setTimeLeft(refreshInterval);
|
||||
}, [refreshInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Trigger page reload for data refresh
|
||||
window.location.reload();
|
||||
return refreshInterval;
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshInterval])
|
||||
|
||||
const minutes = Math.floor((timeLeft ?? 0) / 60)
|
||||
const seconds = (timeLeft ?? 0) % 60
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-[#E42518]/10 rounded-lg border border-[#E42518]/20">
|
||||
<Clock className="w-4 h-4 text-[#E42518]" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Next update: {minutes}:{seconds?.toString()?.padStart(2, '0') ?? '00'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Radio, Loader2, AlertCircle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Fixture {
|
||||
fixture: {
|
||||
id: number
|
||||
status: {
|
||||
short: string
|
||||
elapsed: number | null
|
||||
}
|
||||
}
|
||||
teams: {
|
||||
home: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
away: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
}
|
||||
goals: {
|
||||
home: number | null
|
||||
away: number | null
|
||||
}
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function LiveMatches() {
|
||||
const [fixtures, setFixtures] = useState<Fixture[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchLiveMatches = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/fixtures?live=true')
|
||||
if (!res?.ok) throw new Error('Failed to fetch live matches')
|
||||
const data = await res.json()
|
||||
setFixtures(data?.response ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err?.message : 'Failed to load live matches')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLiveMatches()
|
||||
const interval = setInterval(fetchLiveMatches, REFRESH_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-[#E42518] animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if ((fixtures?.length ?? 0) === 0) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Radio className="w-6 h-6 text-[#E42518]" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Live Matches</h2>
|
||||
</div>
|
||||
<p className="text-center py-8 text-gray-500">No live matches at the moment</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Radio className="w-6 h-6 text-[#E42518] animate-pulse" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Live Matches</h2>
|
||||
<span className="ml-auto px-3 py-1 bg-red-500 text-white text-xs font-semibold rounded-full animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{fixtures?.map((fixture) => (
|
||||
<div
|
||||
key={fixture?.fixture?.id}
|
||||
className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow bg-gradient-to-r from-gray-50 to-white"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Home Team */}
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="relative w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Image
|
||||
src={fixture?.teams?.home?.logo ?? ''}
|
||||
alt={fixture?.teams?.home?.name ?? ''}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-800">{fixture?.teams?.home?.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="flex flex-col items-center mx-4">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||
<span className="text-[#E42518]">{fixture?.goals?.home ?? 0}</span>
|
||||
<span className="text-gray-400">-</span>
|
||||
<span className="text-[#006600]">{fixture?.goals?.away ?? 0}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">{fixture?.fixture?.status?.elapsed ?? 0}'</span>
|
||||
</div>
|
||||
|
||||
{/* Away Team */}
|
||||
<div className="flex items-center gap-3 flex-1 justify-end">
|
||||
<span className="font-semibold text-gray-800">{fixture?.teams?.away?.name}</span>
|
||||
<div className="relative w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Image
|
||||
src={fixture?.teams?.away?.logo ?? ''}
|
||||
alt={fixture?.teams?.away?.name ?? ''}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Fixture {
|
||||
fixture: {
|
||||
id: number
|
||||
date: string
|
||||
}
|
||||
teams: {
|
||||
home: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
away: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
}
|
||||
goals: {
|
||||
home: number | null
|
||||
away: number | null
|
||||
}
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function RecentResults() {
|
||||
const [fixtures, setFixtures] = useState<Fixture[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchRecentResults = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/fixtures?last=10')
|
||||
if (!res?.ok) throw new Error('Failed to fetch recent results')
|
||||
const data = await res.json()
|
||||
setFixtures(data?.response ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err?.message : 'Failed to load recent results')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentResults()
|
||||
const interval = setInterval(fetchRecentResults, REFRESH_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-[#006600] animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<CheckCircle2 className="w-6 h-6 text-[#006600]" />
|
||||
<h2 className="text-xl font-bold text-gray-800">Recent Results</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fixtures?.slice(0, 8)?.map((fixture) => {
|
||||
const date = new Date(fixture?.fixture?.date ?? '')
|
||||
const formattedDate = date?.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fixture?.fixture?.id}
|
||||
className="p-3 border border-gray-200 rounded-lg hover:shadow-md transition-shadow bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="relative w-6 h-6">
|
||||
<Image
|
||||
src={fixture?.teams?.home?.logo ?? ''}
|
||||
alt={fixture?.teams?.home?.name ?? ''}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 truncate">
|
||||
{fixture?.teams?.home?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mx-3">
|
||||
<span className={`text-sm font-bold ${
|
||||
(fixture?.goals?.home ?? 0) > (fixture?.goals?.away ?? 0) ? 'text-[#006600]' : 'text-gray-600'
|
||||
}`}>
|
||||
{fixture?.goals?.home ?? 0}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
<span className={`text-sm font-bold ${
|
||||
(fixture?.goals?.away ?? 0) > (fixture?.goals?.home ?? 0) ? 'text-[#006600]' : 'text-gray-600'
|
||||
}`}>
|
||||
{fixture?.goals?.away ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||
<span className="text-sm font-medium text-gray-800 truncate">
|
||||
{fixture?.teams?.away?.name}
|
||||
</span>
|
||||
<div className="relative w-6 h-6">
|
||||
<Image
|
||||
src={fixture?.teams?.away?.logo ?? ''}
|
||||
alt={fixture?.teams?.away?.name ?? ''}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center mt-2">{formattedDate}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Trophy, Loader2, AlertCircle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Standing {
|
||||
rank: number
|
||||
team: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
points: number
|
||||
all: {
|
||||
played: number
|
||||
win: number
|
||||
draw: number
|
||||
lose: number
|
||||
}
|
||||
goalsDiff: number
|
||||
form: string
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function Standings() {
|
||||
const [standings, setStandings] = useState<Standing[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchStandings = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/standings')
|
||||
if (!res?.ok) throw new Error('Failed to fetch standings')
|
||||
const data = await res.json()
|
||||
setStandings(data?.response?.[0]?.league?.standings?.[0] ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err?.message : 'Failed to load standings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStandings()
|
||||
const interval = setInterval(fetchStandings, REFRESH_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-[#006600] animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Trophy className="w-6 h-6 text-[#006600]" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">League Standings</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="text-left py-3 px-2 text-sm font-semibold text-gray-600">#</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-600">Team</th>
|
||||
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">P</th>
|
||||
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">W</th>
|
||||
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">D</th>
|
||||
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">L</th>
|
||||
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">GD</th>
|
||||
<th className="text-center py-3 px-3 text-sm font-semibold text-gray-600">Pts</th>
|
||||
<th className="text-left py-3 px-2 text-sm font-semibold text-gray-600">Form</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{standings?.map((team, idx) => (
|
||||
<tr
|
||||
key={team?.team?.id}
|
||||
className={`border-b border-gray-100 hover:bg-gray-50 transition-colors ${
|
||||
idx < 3 ? 'bg-green-50/50' : idx < 6 ? 'bg-blue-50/30' : idx >= (standings?.length ?? 0) - 2 ? 'bg-red-50/30' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-2 text-sm font-semibold text-gray-700">{team?.rank}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Image
|
||||
src={team?.team?.logo ?? ''}
|
||||
alt={team?.team?.name ?? ''}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-gray-800">{team?.team?.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.played ?? 0}</td>
|
||||
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.win ?? 0}</td>
|
||||
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.draw ?? 0}</td>
|
||||
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.lose ?? 0}</td>
|
||||
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.goalsDiff ?? 0}</td>
|
||||
<td className="text-center py-3 px-3 text-sm font-bold text-[#006600]">{team?.points ?? 0}</td>
|
||||
<td className="py-3 px-2">
|
||||
<div className="flex gap-1">
|
||||
{team?.form?.split('')?.slice(-5)?.map((result, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-5 h-5 rounded-full text-xs flex items-center justify-center font-semibold ${
|
||||
result === 'W' ? 'bg-green-500 text-white' :
|
||||
result === 'D' ? 'bg-gray-400 text-white' :
|
||||
result === 'L' ? 'bg-red-500 text-white' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Target, Loader2, AlertCircle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface TopScorer {
|
||||
player: {
|
||||
id: number
|
||||
name: string
|
||||
photo: string
|
||||
}
|
||||
statistics: Array<{
|
||||
team: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
goals: {
|
||||
total: number | null
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function TopScorers() {
|
||||
const [scorers, setScorers] = useState<TopScorer[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTopScorers = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/topscorers')
|
||||
if (!res?.ok) throw new Error('Failed to fetch top scorers')
|
||||
const data = await res.json()
|
||||
setScorers(data?.response ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err?.message : 'Failed to load top scorers')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopScorers()
|
||||
const interval = setInterval(fetchTopScorers, REFRESH_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-[#E42518] animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Target className="w-6 h-6 text-[#E42518]" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Top Scorers</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{scorers?.slice(0, 12)?.map((scorer, idx) => {
|
||||
const stats = scorer?.statistics?.[0]
|
||||
const goals = stats?.goals?.total ?? 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={scorer?.player?.id}
|
||||
className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-all hover:-translate-y-1 bg-gradient-to-br from-white to-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="relative w-12 h-12 rounded-full overflow-hidden bg-gray-200">
|
||||
<Image
|
||||
src={scorer?.player?.photo ?? ''}
|
||||
alt={scorer?.player?.name ?? ''}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{scorer?.player?.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative w-5 h-5">
|
||||
<Image
|
||||
src={stats?.team?.logo ?? ''}
|
||||
alt={stats?.team?.name ?? ''}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">{stats?.team?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-600">Goals</span>
|
||||
<span className="text-2xl font-bold text-[#E42518]">{goals}</span>
|
||||
</div>
|
||||
{idx === 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<span className="inline-block px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-semibold rounded">
|
||||
👑 Top Scorer
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Calendar, Loader2, AlertCircle } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Fixture {
|
||||
fixture: {
|
||||
id: number
|
||||
date: string
|
||||
}
|
||||
teams: {
|
||||
home: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
away: {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export default function UpcomingFixtures() {
|
||||
const [fixtures, setFixtures] = useState<Fixture[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchUpcomingFixtures = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/fixtures?next=10')
|
||||
if (!res?.ok) throw new Error('Failed to fetch upcoming fixtures')
|
||||
const data = await res.json()
|
||||
setFixtures(data?.response ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err?.message : 'Failed to load upcoming fixtures')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUpcomingFixtures()
|
||||
const interval = setInterval(fetchUpcomingFixtures, REFRESH_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-[#E42518] animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Calendar className="w-6 h-6 text-[#E42518]" />
|
||||
<h2 className="text-xl font-bold text-gray-800">Upcoming Fixtures</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fixtures?.slice(0, 8)?.map((fixture) => {
|
||||
const date = new Date(fixture?.fixture?.date ?? '')
|
||||
const formattedDate = date?.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||
const formattedTime = date?.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fixture?.fixture?.id}
|
||||
className="p-3 border border-gray-200 rounded-lg hover:shadow-md transition-shadow bg-gradient-to-r from-white to-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="relative w-6 h-6">
|
||||
<Image
|
||||
src={fixture?.teams?.home?.logo ?? ''}
|
||||
alt={fixture?.teams?.home?.name ?? ''}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 truncate">
|
||||
{fixture?.teams?.home?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-3 text-sm text-gray-500 font-medium">
|
||||
vs
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||
<span className="text-sm font-medium text-gray-800 truncate">
|
||||
{fixture?.teams?.away?.name}
|
||||
</span>
|
||||
<div className="relative w-6 h-6">
|
||||
<Image
|
||||
src={fixture?.teams?.away?.logo ?? ''}
|
||||
alt={fixture?.teams?.away?.name ?? ''}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center mt-2">
|
||||
{formattedDate} • {formattedTime}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ChartProps {
|
||||
data: { time: string; count: number }[];
|
||||
}
|
||||
|
||||
export function ApiStatsChart({ data }: ChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || data.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Get max value
|
||||
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = '#374151';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding.top + (chartHeight / 5) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = '#9CA3AF';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
const value = Math.round(maxCount - (maxCount / 5) * i);
|
||||
ctx.fillText(value.toString(), padding.left - 10, y + 4);
|
||||
}
|
||||
|
||||
// Draw bars
|
||||
const barWidth = chartWidth / data.length - 4;
|
||||
data.forEach((item, index) => {
|
||||
const x = padding.left + (chartWidth / data.length) * index + 2;
|
||||
const barHeight = (item.count / maxCount) * chartHeight;
|
||||
const y = padding.top + chartHeight - barHeight;
|
||||
|
||||
// Gradient fill
|
||||
const gradient = ctx.createLinearGradient(x, y, x, y + barHeight);
|
||||
gradient.addColorStop(0, '#10B981');
|
||||
gradient.addColorStop(1, '#059669');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, barWidth, barHeight, 4);
|
||||
ctx.fill();
|
||||
|
||||
// X-axis labels (every 4th label to avoid crowding)
|
||||
if (index % 4 === 0 || data.length <= 6) {
|
||||
ctx.fillStyle = '#9CA3AF';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const time = item.time.split('T')[1]?.slice(0, 5) || item.time;
|
||||
ctx.fillText(time, x + barWidth / 2, height - 10);
|
||||
}
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-48 flex items-center justify-center text-gray-500">
|
||||
No data available yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">
|
||||
Requests Over Time (Last 24 Hours)
|
||||
</h3>
|
||||
<canvas ref={canvasRef} className="w-full h-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin - Primeira Liga Stats',
|
||||
description: 'Backoffice administration panel',
|
||||
};
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Settings,
|
||||
Key,
|
||||
BarChart3,
|
||||
Globe,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { ApiStatsChart } from './_components/api-stats-chart';
|
||||
|
||||
interface SettingsData {
|
||||
id: string;
|
||||
apiKey: string | null;
|
||||
selectedLeague: string;
|
||||
leagueId: number;
|
||||
season: number;
|
||||
siteName: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
showLiveMatches: boolean;
|
||||
showStandings: boolean;
|
||||
showTopScorers: boolean;
|
||||
showRecentResults: boolean;
|
||||
showUpcoming: boolean;
|
||||
refreshInterval: number;
|
||||
}
|
||||
|
||||
interface ApiStats {
|
||||
totalRequests: number;
|
||||
requestsToday: number;
|
||||
requestsByEndpoint: { endpoint: string; count: number }[];
|
||||
chartData: { time: string; count: number }[];
|
||||
avgResponseTime: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [settings, setSettings] = useState<SettingsData | null>(null);
|
||||
const [stats, setStats] = useState<ApiStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [settingsRes, statsRes] = await Promise.all([
|
||||
fetch('/api/admin/settings'),
|
||||
fetch('/api/admin/stats'),
|
||||
]);
|
||||
|
||||
if (settingsRes.ok) {
|
||||
const settingsData = await settingsRes.json();
|
||||
setSettings(settingsData);
|
||||
}
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load data');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedSettings = await res.json();
|
||||
setSettings(updatedSettings);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} else {
|
||||
setError('Failed to save settings');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to save settings');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof SettingsData>(
|
||||
key: K,
|
||||
value: SettingsData[K]
|
||||
) => {
|
||||
if (settings) {
|
||||
setSettings({ ...settings, [key]: value });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-green-500 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
{/* Header */}
|
||||
<header className="bg-gray-900/80 backdrop-blur-md border-b border-gray-700 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span>Back to Site</span>
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-gray-700" />
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Settings className="h-6 w-6 text-green-500" />
|
||||
Backoffice
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : saveSuccess ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{saveSuccess ? 'Saved!' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 pt-4">
|
||||
<div className="bg-red-500/20 border border-red-500 text-red-400 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Layout Settings */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<Settings className="h-5 w-5 text-green-500" />
|
||||
Layout Settings
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Site Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.siteName || ''}
|
||||
onChange={(e) => updateSetting('siteName', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={settings?.primaryColor || '#E42518'}
|
||||
onChange={(e) =>
|
||||
updateSetting('primaryColor', e.target.value)
|
||||
}
|
||||
className="w-12 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.primaryColor || ''}
|
||||
onChange={(e) =>
|
||||
updateSetting('primaryColor', e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Secondary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={settings?.secondaryColor || '#006600'}
|
||||
onChange={(e) =>
|
||||
updateSetting('secondaryColor', e.target.value)
|
||||
}
|
||||
className="w-12 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.secondaryColor || ''}
|
||||
onChange={(e) =>
|
||||
updateSetting('secondaryColor', e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Refresh Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="60"
|
||||
max="3600"
|
||||
value={settings?.refreshInterval || 300}
|
||||
onChange={(e) =>
|
||||
updateSetting('refreshInterval', parseInt(e.target.value))
|
||||
}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Visible Sections
|
||||
</label>
|
||||
{[
|
||||
{ key: 'showLiveMatches', label: 'Live Matches' },
|
||||
{ key: 'showStandings', label: 'Standings Table' },
|
||||
{ key: 'showTopScorers', label: 'Top Scorers' },
|
||||
{ key: 'showRecentResults', label: 'Recent Results' },
|
||||
{ key: 'showUpcoming', label: 'Upcoming Fixtures' },
|
||||
].map((item) => (
|
||||
<label
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings?.[item.key as keyof SettingsData] as boolean
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateSetting(
|
||||
item.key as keyof SettingsData,
|
||||
e.target.checked as never
|
||||
)
|
||||
}
|
||||
className="w-5 h-5 rounded bg-gray-700 border-gray-600 text-green-500 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-gray-300">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key Management */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<Key className="h-5 w-5 text-yellow-500" />
|
||||
API Key Management
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
API-Football API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={settings?.apiKey || ''}
|
||||
onChange={(e) => updateSetting('apiKey', e.target.value)}
|
||||
placeholder="Enter your API key"
|
||||
className="w-full px-4 py-2 pr-12 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-yellow-500 focus:border-transparent font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://www.api-football.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-500 hover:underline"
|
||||
>
|
||||
api-football.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">
|
||||
API Status
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{settings?.apiKey ? (
|
||||
<>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<span className="text-green-400">API Key Configured</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<span className="text-red-400">No API Key Set</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* League Switcher */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
League Selection
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => updateSetting('selectedLeague', 'primeira_liga')}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings?.selectedLeague === 'primeira_liga'
|
||||
? 'border-green-500 bg-green-500/20'
|
||||
: 'border-gray-600 bg-gray-700/50 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">🇵🇹</div>
|
||||
<div className="font-semibold text-white">Primeira Liga</div>
|
||||
<div className="text-sm text-gray-400">Portuguese Championship</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => updateSetting('selectedLeague', 'euro_2026')}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings?.selectedLeague === 'euro_2026'
|
||||
? 'border-blue-500 bg-blue-500/20'
|
||||
: 'border-gray-600 bg-gray-700/50 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">🇪🇺</div>
|
||||
<div className="font-semibold text-white">UEFA Euro</div>
|
||||
<div className="text-sm text-gray-400">European Championship</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">
|
||||
Current Configuration
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-gray-400">League ID:</div>
|
||||
<div className="text-white font-mono">{settings?.leagueId}</div>
|
||||
<div className="text-gray-400">Season:</div>
|
||||
<div className="text-white font-mono">{settings?.season}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Statistics */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<BarChart3 className="h-5 w-5 text-purple-500" />
|
||||
API Statistics
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{stats?.totalRequests || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Total Requests</div>
|
||||
</div>
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{stats?.requestsToday || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Requests Today</div>
|
||||
</div>
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{stats?.avgResponseTime || 0}ms
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Avg Response Time</div>
|
||||
</div>
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-green-400">
|
||||
{stats?.successRate || 100}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Success Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiStatsChart data={stats?.chartData || []} />
|
||||
|
||||
{stats?.requestsByEndpoint && stats.requestsByEndpoint.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">
|
||||
Requests by Endpoint
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.requestsByEndpoint.map((item) => (
|
||||
<div
|
||||
key={item.endpoint}
|
||||
className="flex items-center justify-between bg-gray-700/50 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-gray-300 font-mono text-sm">
|
||||
{item.endpoint}
|
||||
</span>
|
||||
<span className="text-white font-semibold">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/db';
|
||||
import { invalidateSettingsCache } from '@/lib/settings';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
let settings = await prisma.settings.findUnique({
|
||||
where: { id: 'main' },
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
settings = await prisma.settings.create({
|
||||
data: {
|
||||
id: 'main',
|
||||
apiKey: process.env.API_FOOTBALL_API_KEY || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to get settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
// Update league settings based on selection
|
||||
let leagueId = data.leagueId;
|
||||
let season = data.season;
|
||||
|
||||
if (data.selectedLeague === 'euro_2026') {
|
||||
leagueId = 4; // EURO Championship
|
||||
season = 2024;
|
||||
} else if (data.selectedLeague === 'primeira_liga') {
|
||||
leagueId = 94;
|
||||
season = 2024;
|
||||
}
|
||||
|
||||
const settings = await prisma.settings.upsert({
|
||||
where: { id: 'main' },
|
||||
update: { ...data, leagueId, season },
|
||||
create: { id: 'main', ...data, leagueId, season },
|
||||
});
|
||||
|
||||
// Bust the settings cache so API routes pick up the new values immediately
|
||||
invalidateSettingsCache();
|
||||
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to update settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
// Run all queries in parallel instead of sequentially
|
||||
const [
|
||||
totalRequests,
|
||||
requestsByEndpoint,
|
||||
recentRequests,
|
||||
avgDuration,
|
||||
successfulRequests,
|
||||
requestsToday,
|
||||
] = await Promise.all([
|
||||
prisma.apiRequestLog.count(),
|
||||
prisma.apiRequestLog.groupBy({
|
||||
by: ['endpoint'],
|
||||
_count: { id: true },
|
||||
}),
|
||||
prisma.apiRequestLog.findMany({
|
||||
where: { createdAt: { gte: twentyFourHoursAgo } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { createdAt: true }, // only fetch what we need
|
||||
}),
|
||||
prisma.apiRequestLog.aggregate({ _avg: { duration: true } }),
|
||||
prisma.apiRequestLog.count({ where: { status: { gte: 200, lt: 300 } } }),
|
||||
prisma.apiRequestLog.count({ where: { createdAt: { gte: startOfDay } } }),
|
||||
]);
|
||||
|
||||
// Group by hour
|
||||
const hourlyStats: Record<string, number> = {};
|
||||
for (const req of recentRequests) {
|
||||
const hour = new Date(req.createdAt).toISOString().slice(0, 13) + ':00';
|
||||
hourlyStats[hour] = (hourlyStats[hour] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const chartData = Object.entries(hourlyStats).map(([time, count]) => ({
|
||||
time,
|
||||
count,
|
||||
}));
|
||||
|
||||
const successRate =
|
||||
totalRequests > 0 ? (successfulRequests / totalRequests) * 100 : 100;
|
||||
|
||||
return NextResponse.json({
|
||||
totalRequests,
|
||||
requestsToday,
|
||||
requestsByEndpoint: requestsByEndpoint.map((r) => ({
|
||||
endpoint: r.endpoint,
|
||||
count: r._count.id,
|
||||
})),
|
||||
chartData,
|
||||
avgResponseTime: Math.round(avgDuration._avg.duration ?? 0),
|
||||
successRate: Math.round(successRate * 100) / 100,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSettings } from '@/lib/settings';
|
||||
import { logApiRequest } from '@/lib/api-logger';
|
||||
|
||||
// Revalidation times per fixture type
|
||||
const REVALIDATE: Record<string, number> = {
|
||||
live: 30, // live scores — refresh every 30s
|
||||
last: 300, // recent results — refresh every 5 min
|
||||
next: 300, // upcoming — refresh every 5 min
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const startTime = Date.now();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'live';
|
||||
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const apiKey = settings.apiKey || process.env.API_FOOTBALL_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
league: settings.leagueId.toString(),
|
||||
season: settings.season.toString(),
|
||||
});
|
||||
|
||||
if (type === 'live') {
|
||||
params.set('live', 'all');
|
||||
} else if (type === 'last') {
|
||||
params.set('last', '10');
|
||||
} else if (type === 'next') {
|
||||
params.set('next', '10');
|
||||
}
|
||||
|
||||
const revalidate = REVALIDATE[type] ?? 60;
|
||||
|
||||
const response = await fetch(
|
||||
`https://v3.football.api-sports.io/fixtures?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'x-rapidapi-key': apiKey,
|
||||
'x-rapidapi-host': 'v3.football.api-sports.io',
|
||||
},
|
||||
next: { revalidate },
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Fire-and-forget log (non-blocking)
|
||||
logApiRequest(`/fixtures?type=${type}`, response.status, duration);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate * 2}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logApiRequest(`/fixtures?type=${type}`, 500, duration);
|
||||
console.error('Fixtures API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch fixtures' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSettings } from '@/lib/settings';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
// Return only public settings (no API key)
|
||||
return NextResponse.json({
|
||||
siteName: settings.siteName,
|
||||
selectedLeague: settings.selectedLeague,
|
||||
primaryColor: settings.primaryColor,
|
||||
secondaryColor: settings.secondaryColor,
|
||||
showLiveMatches: settings.showLiveMatches,
|
||||
showStandings: settings.showStandings,
|
||||
showTopScorers: settings.showTopScorers,
|
||||
showRecentResults: settings.showRecentResults,
|
||||
showUpcoming: settings.showUpcoming,
|
||||
refreshInterval: settings.refreshInterval,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Settings API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSettings } from '@/lib/settings';
|
||||
import { logApiRequest } from '@/lib/api-logger';
|
||||
|
||||
const REVALIDATE = 300; // 5 minutes
|
||||
|
||||
export async function GET() {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const apiKey = settings.apiKey || process.env.API_FOOTBALL_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://v3.football.api-sports.io/standings?league=${settings.leagueId}&season=${settings.season}`,
|
||||
{
|
||||
headers: {
|
||||
'x-rapidapi-key': apiKey,
|
||||
'x-rapidapi-host': 'v3.football.api-sports.io',
|
||||
},
|
||||
next: { revalidate: REVALIDATE },
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logApiRequest('/standings', response.status, duration);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': `public, s-maxage=${REVALIDATE}, stale-while-revalidate=${REVALIDATE * 2}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logApiRequest('/standings', 500, duration);
|
||||
console.error('Standings API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch standings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSettings } from '@/lib/settings';
|
||||
import { logApiRequest } from '@/lib/api-logger';
|
||||
|
||||
const REVALIDATE = 300; // 5 minutes
|
||||
|
||||
export async function GET() {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const apiKey = settings.apiKey || process.env.API_FOOTBALL_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://v3.football.api-sports.io/players/topscorers?league=${settings.leagueId}&season=${settings.season}`,
|
||||
{
|
||||
headers: {
|
||||
'x-rapidapi-key': apiKey,
|
||||
'x-rapidapi-host': 'v3.football.api-sports.io',
|
||||
},
|
||||
next: { revalidate: REVALIDATE },
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logApiRequest('/topscorers', response.status, duration);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': `public, s-maxage=${REVALIDATE}, stale-while-revalidate=${REVALIDATE * 2}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logApiRequest('/topscorers', 500, duration);
|
||||
console.error('Top Scorers API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch top scorers' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--primeira-red: #E42518;
|
||||
--primeira-green: #006600;
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: linear-gradient(to bottom, #f5f5f5, #ffffff);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* Suppress hydration errors */
|
||||
[data-hydration-error] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Primeira Liga Stats - Live Football Statistics',
|
||||
description: 'Real-time statistics, standings, and scores for Portuguese Primeira Liga',
|
||||
icons: {
|
||||
icon: '/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
},
|
||||
metadataBase: new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000'),
|
||||
openGraph: {
|
||||
title: 'Primeira Liga Stats',
|
||||
description: 'Real-time statistics for Portuguese Primeira Liga',
|
||||
images: ['/og-image.png'],
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script src="https://apps.abacus.ai/chatllm/appllm-lib.js"></script>
|
||||
</head>
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Settings } from 'lucide-react';
|
||||
import LiveMatches from './_components/live-matches';
|
||||
import Standings from './_components/standings';
|
||||
import TopScorers from './_components/top-scorers';
|
||||
import RecentResults from './_components/recent-results';
|
||||
import UpcomingFixtures from './_components/upcoming-fixtures';
|
||||
import CountdownTimer from './_components/countdown-timer';
|
||||
import { SettingsContext, defaultSettings, type AppSettings } from '@/lib/settings-context';
|
||||
|
||||
export default function HomePage() {
|
||||
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const leagueName =
|
||||
settings.selectedLeague === 'euro_2026'
|
||||
? 'UEFA Euro 2024'
|
||||
: 'Primeira Liga';
|
||||
|
||||
const leagueSubtitle =
|
||||
settings.selectedLeague === 'euro_2026'
|
||||
? 'European Championship'
|
||||
: 'Portuguese Championship 2024/2025';
|
||||
|
||||
const heroText =
|
||||
settings.selectedLeague === 'euro_2026'
|
||||
? 'European Football at Your Fingertips'
|
||||
: 'Portuguese Football at Your Fingertips';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[#006600] border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<main className="min-h-screen pb-16">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 backdrop-blur-md bg-white/80 border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center shadow-lg"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${settings.primaryColor}, ${settings.secondaryColor})`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-7 h-7 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" strokeWidth="2" />
|
||||
<path d="M12 2v20M2 12h20" strokeWidth="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-2xl font-bold bg-clip-text text-transparent"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, ${settings.primaryColor}, ${settings.secondaryColor})`,
|
||||
}}
|
||||
>
|
||||
{leagueName}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">{leagueSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<CountdownTimer refreshInterval={settings.refreshInterval} />
|
||||
<Link
|
||||
href="/admin"
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title="Admin Panel"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-gray-500" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section
|
||||
className="text-white py-12"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${settings.primaryColor}, ${settings.secondaryColor})`,
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-4xl font-bold mb-3">{heroText}</h2>
|
||||
<p className="text-xl opacity-90">
|
||||
Real-time scores, standings, and statistics
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
{settings.showLiveMatches && <LiveMatches />}
|
||||
{settings.showStandings && <Standings />}
|
||||
{settings.showTopScorers && <TopScorers />}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{settings.showRecentResults && <RecentResults />}
|
||||
{settings.showUpcoming && <UpcomingFixtures />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-16 py-8 border-t border-gray-200 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600">
|
||||
<p className="text-sm">
|
||||
Data updates every {Math.round(settings.refreshInterval / 60)}{' '}
|
||||
minutes • Powered by API-Football
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user