From 5d775b253a1b83626c1720132640c967787130fd Mon Sep 17 00:00:00 2001 From: Alex Mboutchouang Date: Sat, 31 Jan 2026 01:43:24 +0100 Subject: [PATCH 1/3] new data fields and edition rules logic --- app/about/page.tsx | 4 +- app/contact/page.tsx | 2 +- app/leagues/[id]/ChampionshipClient.tsx | 55 ++++---- app/matchs/[id]/MatchTimelineClient.tsx | 122 ++++++++++++++---- app/page.tsx | 17 ++- components/StandingCard.tsx | 5 +- components/matchs/MatchTimeline.tsx | 72 ++++++++--- components/matchs/TimelineItem.tsx | 92 +++++++------ components/players/PlayerTeams.tsx | 5 +- components/players/PlayerTransferts.tsx | 9 +- components/standings/ChampionshipStanding.tsx | 44 +++++-- components/standings/StandingDataTable.tsx | 93 ++++++------- components/standings/StandingTableHeader.tsx | 28 ++-- components/teams/ChampionshipTeamList.tsx | 3 + components/teams/TeamStatDataTable.tsx | 41 ++---- constants/urls.ts | 5 +- contexts/DateContext.tsx | 41 ++++-- enums/timeline.ts | 1 + interfaces/IEditions.ts | 11 ++ interfaces/IMatch.d.ts | 21 ++- interfaces/ITeams.ts | 1 - interfaces/ITimelineEvent.d.ts | 6 +- services/ChampionshipService.ts | 11 +- services/MatchService.ts | 15 +-- 24 files changed, 449 insertions(+), 255 deletions(-) diff --git a/app/about/page.tsx b/app/about/page.tsx index 5b8ff1e..7570c7c 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -111,7 +111,7 @@ export default function AboutPage() {

We envision a future where fans, analysts, and football enthusiasts can easily access reliable information about - Cameroon's local leagues. By combining real-time match updates + Cameroon's local leagues. By combining real-time match updates with a growing historical data archive, Scoreboards aims to enhance the experience of following football in Cameroon.

@@ -127,7 +127,7 @@ export default function AboutPage() {

About Our Data

At Scoreboards, we are committed to collecting accurate and - comprehensive data from Cameroon's local football leagues. Every + comprehensive data from Cameroon's local football leagues. Every match, player performance, and team statistic is carefully recorded to create a reliable historical archive.

diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 223a56d..ad261b0 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -29,7 +29,7 @@ export default function ContactPage() {

Contact Us

- Have questions or feedback? Fill out the form below and we'll get + Have questions or feedback? Fill out the form below and we'll get back to you as soon as possible.

diff --git a/app/leagues/[id]/ChampionshipClient.tsx b/app/leagues/[id]/ChampionshipClient.tsx index 131bde8..15961e1 100644 --- a/app/leagues/[id]/ChampionshipClient.tsx +++ b/app/leagues/[id]/ChampionshipClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ArrowLeft, MapPin } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -22,10 +22,9 @@ interface Props { export default function ChampionshipClient({ id }: Props) { const router = useRouter(); const searchParams = useSearchParams(); - const pathname = usePathname(); const [edition, setEdition] = useState(null); - const championshipTab = searchParams.get("tab") || "table"; + const [championshipTab, setChampionshipTab] = useState("table"); const [matchTab, setMatchTab] = useState("all"); useEffect(() => { @@ -34,15 +33,10 @@ export default function ChampionshipClient({ id }: Props) { setEdition(data); }; loadEdition(); - // Tab logic removed from here to prevent cascading renders - }, [id]); - // 2. Helper to update tabs via URL - const handleTabChange = (value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set("tab", value); - router.replace(`${pathname}?${params.toString()}`, { scroll: false }); - }; + const tab = searchParams.get("tab"); + if (tab) setChampionshipTab(tab); + }, [id, searchParams]); if (!edition) { return
Loading...
; @@ -52,19 +46,21 @@ export default function ChampionshipClient({ id }: Props) {
- {edition.championship?.logo && ( - - - {edition.label} - - )} + {edition.championship?.logo && + + {edition.label} + } -

{edition.label}

+

+ {edition.label} +

{edition.championship.country && (
- {edition.championship.country} + + {edition.championship.country} +
)}
@@ -74,16 +70,24 @@ export default function ChampionshipClient({ id }: Props) { onClick={() => router.back()} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4" > - Back + + Back - + Table Teams Matches - Team Stats - Player Stats + + Team Stats + + + Player Stats + @@ -105,10 +109,7 @@ export default function ChampionshipClient({ id }: Props) { - + diff --git a/app/matchs/[id]/MatchTimelineClient.tsx b/app/matchs/[id]/MatchTimelineClient.tsx index 1643d0e..81087ab 100644 --- a/app/matchs/[id]/MatchTimelineClient.tsx +++ b/app/matchs/[id]/MatchTimelineClient.tsx @@ -3,7 +3,12 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft } from "lucide-react"; -import { FaFutbol, FaExchangeAlt } from "react-icons/fa"; +import { + FaFutbol, + FaExchangeAlt, + FaCheckCircle, + FaTimesCircle, +} from "react-icons/fa"; import { BsFillSquareFill } from "react-icons/bs"; import { MatchService } from "@/services/MatchService"; import { MatchTimeline } from "@/components/matchs/MatchTimeline"; @@ -13,6 +18,8 @@ import { ITimelineEvent } from "@/interfaces/ITimelineEvent"; import { MatchHeader } from "@/components/matchs/MatchHeader"; import { CenteredMessage } from "@/components/CenteredMessage"; import { TimelineEventEnum } from "@/enums/timeline"; +import { GoalStatus } from "@/enums/goals"; +import { GoalType } from "@/enums/goals"; interface IProps { id: string; @@ -27,31 +34,92 @@ const sortByMatchTime = (a: ITimelineEvent, b: ITimelineEvent) => { function buildEventsFromMatch(match: IMatch): ITimelineEvent[] { const events: ITimelineEvent[] = []; - match.goals.forEach((goal) => { - events.push({ - id: goal.id, - type: TimelineEventEnum.GOAL, - minute: goal.minute ?? 0, - stoppage_minute: goal.stoppage_minute ?? null, - team: goal.team, - icon: ( - <> - - {goal.is_csc && "(OG)"} - - ), - title: goal.scorer ? `${goal.scorer.firstname} ${goal.scorer.lastname}` : "Unknown scorer", - description: goal.is_csc - ? `Own goal` - : goal.assister - ? `(${goal.assister.firstname} ${goal.assister.lastname})` - : "", - is_penalty: goal.is_penalty, - is_home: - (goal.team.id === match.home_team.id && !goal.is_csc) || - (goal.team.id === match.away_team.id && goal.is_csc), + match.goals + .filter((elem) => { + return ( + elem.status == GoalStatus.VALID && + elem.goal_type != GoalType.PENALTY_SHOOTOUT + ); + }) + .forEach((goal) => { + events.push({ + id: goal.id, + type: TimelineEventEnum.GOAL, + minute: goal.minute ?? 0, + stoppage_minute: goal.stoppage_minute ?? null, + team: goal.team, + icon: ( + <> + + + ), + title: goal.scorer + ? `${goal.scorer.firstname} ${goal.scorer.lastname}` + : "Unknown scorer", + description: goal.is_csc + ? `Own goal` + : goal.assister + ? `(${goal.assister.firstname} ${goal.assister.lastname})` + : "", + is_penalty: goal.is_penalty, + is_home: + (goal.team.id === match.home_team.id && !goal.is_csc) || + (goal.team.id === match.away_team.id && goal.is_csc), + }); + }); + + // 2. MISSED goal + match.goals + .filter((elem) => { + return ( + elem.status == (GoalStatus.CANCELLED || GoalStatus.MISSED) && + elem.goal_type != GoalType.PENALTY_SHOOTOUT + ); + }) + .forEach((miss) => { + events.push({ + id: miss.id, + type: TimelineEventEnum.GOAL, // Keep as Goal type for UI grouping or use a specific MISSED type + minute: miss.minute ?? 0, + stoppage_minute: miss.stoppage_minute ?? null, + team: miss.team, + is_missed: true, + icon: , + title: miss.scorer + ? `${miss.scorer.firstname} ${miss.scorer.lastname}` + : "Unknown scorer", + description: "Missed Penalty", + is_home: miss.team.id === match.home_team.id, + }); + }); + + match.goals + .filter((elem) => { + return elem.goal_type == GoalType.PENALTY_SHOOTOUT; + }) + .forEach((attempt, index) => { + events.push({ + id: attempt.id, + type: TimelineEventEnum.PENALTY_SHOOTOUT, + minute: 121, + shootout_order: attempt.shootout_order || index + 1, // Used for side-by-side alignment + team: attempt.team, + is_missed: attempt.status != GoalStatus.VALID, + icon: + attempt.status != GoalStatus.VALID ? ( + + ) : ( + + ), + title: attempt.scorer + ? `${attempt.scorer.firstname} ${attempt.scorer.lastname}` + : "Unknown scorer", + description: "", + is_home: attempt.team.id === match.home_team.id, + }); }); - }); match.cards.forEach((card) => { events.push({ @@ -66,7 +134,9 @@ function buildEventsFromMatch(match: IMatch): ITimelineEvent[] { ) : ( ), - title: card.player ? `${card.player.firstname} ${card.player.lastname}` : "Unknown player", + title: card.player + ? `${card.player.firstname} ${card.player.lastname}` + : "Unknown player", description: "", is_home: card.team.id === match.home_team.id, }); diff --git a/app/page.tsx b/app/page.tsx index cd7a147..66102e0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ import MatchsPageClient from "./MatchsPageClient"; import { homePageText, appDomain } from "@/constants/seoTexts"; +import { DateContextProvider } from "@/contexts/DateContext"; export const metadata = { title: "Home | Scoreboards", @@ -13,6 +14,16 @@ export const metadata = { }, }; -export default function Home() { - return ; -} \ No newline at end of file +export default async function Home({ + searchParams, +}: { + searchParams: Promise<{ date?: string }>; +}) { + const { date } = await searchParams; + + return ( + + + + ); +} diff --git a/components/StandingCard.tsx b/components/StandingCard.tsx index fb4062d..21c5cb6 100644 --- a/components/StandingCard.tsx +++ b/components/StandingCard.tsx @@ -1,6 +1,5 @@ import React from "react"; import { IStanding } from "../interfaces/IStanding"; -import Image from "next/image"; interface IStandingCardProps { standing: IStanding; @@ -15,11 +14,9 @@ export const StandingCard: React.FC = ( {props.index + 1} {props.standing.participation.team.logo && ( - {props.standing.participation.team.name} )} diff --git a/components/matchs/MatchTimeline.tsx b/components/matchs/MatchTimeline.tsx index a25e8d8..b8c0b26 100644 --- a/components/matchs/MatchTimeline.tsx +++ b/components/matchs/MatchTimeline.tsx @@ -1,32 +1,74 @@ -"use client" +"use client"; import { TimelineItem } from "./TimelineItem"; import { ITimelineEvent } from "@/interfaces/ITimelineEvent"; +import { TimelineEventEnum } from "@/enums/timeline"; +import { ShootoutCompactItem } from "./TimelineItem"; +import { sortByMatchTime } from "@/lib/utils"; interface IMatchTimelineProps { events: ITimelineEvent[]; } -export const MatchTimeline: React.FC = ( - props: IMatchTimelineProps -) => { - if (!props.events.length) { - return ( -

- Waiting for match events... -

- ); - } +export const MatchTimeline: React.FC = ({ events }) => { + if (!events.length) + return

Waiting for events...

; + + const regularEvents = events.filter( + (e) => e.type !== TimelineEventEnum.PENALTY_SHOOTOUT, + ); + const shootoutEvents = events.filter( + (e) => e.type === TimelineEventEnum.PENALTY_SHOOTOUT, + ); + + const shootoutRounds = Array.from( + new Set(shootoutEvents.map((e) => e.shootout_order)), + ).sort((a, b) => a! - b!); return ( -
-
+
+
-
- {props.events.map((event) => ( +
+ {regularEvents.sort(sortByMatchTime).map((event) => ( ))}
+ + {shootoutRounds.length > 0 && ( +
+

+ Penalty Shootout +

+ {shootoutRounds.map((round) => { + const homeShot = shootoutEvents.find( + (e) => e.shootout_order === round && e.is_home, + ); + const awayShot = shootoutEvents.find( + (e) => e.shootout_order === round && !e.is_home, + ); + + return ( +
+
+ {homeShot && } +
+ +
+ {round} +
+ +
+ {awayShot && } +
+
+ ); + })} +
+ )}
); }; diff --git a/components/matchs/TimelineItem.tsx b/components/matchs/TimelineItem.tsx index e8f2dfe..f659b38 100644 --- a/components/matchs/TimelineItem.tsx +++ b/components/matchs/TimelineItem.tsx @@ -1,7 +1,8 @@ -"use client" +"use client"; import { ITimelineEvent } from "@/interfaces/ITimelineEvent"; -import { TimelineEventEnum } from "@/enums/timeline"; +// import { TimelineEventEnum } from "@/enums/timeline"; +import { cn } from "@/lib/utils"; interface ItemProps { event: ITimelineEvent; @@ -11,49 +12,68 @@ export const TimelineItem: React.FC = ({ event }) => { const isHome = event.is_home; return (
-
-
- - {event.icon} - - - {event.minute} - {event.stoppage_minute ? `+${event.stoppage_minute}` : ""}' + {event.icon} + +
+ + {event.title} + + + {event.description && ( + + {event.description} -
-
-

- {event.title} -

- {event.description && ( - - {event.description} - - )} -
+ )}
-
-
+
+ {event.minute}'
-
+
); }; + +export const ShootoutCompactItem: React.FC<{ event: ITimelineEvent }> = ({ + event, +}) => ( +
+ + {event.title} + + {event.icon} +
+); diff --git a/components/players/PlayerTeams.tsx b/components/players/PlayerTeams.tsx index 360c6cb..97ef23c 100644 --- a/components/players/PlayerTeams.tsx +++ b/components/players/PlayerTeams.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react"; import { PlayerService } from "@/services/PlayerService"; import { IPlayer, IPlayerTeamHistory } from "@/interfaces/IPlayers"; import { Loader2 } from "lucide-react"; -import Image from "next/image"; interface IPlayerTeamsProps { player: IPlayer; @@ -62,11 +61,9 @@ export const PlayerTeams: React.FC = ({ player }) => { className="flex items-center gap-4 border rounded-lg p-4 shadow-sm hover:shadow-md transition" > {h.team.logo && ( - {h.team.name} )} diff --git a/components/players/PlayerTransferts.tsx b/components/players/PlayerTransferts.tsx index 32c089b..2cf4a13 100644 --- a/components/players/PlayerTransferts.tsx +++ b/components/players/PlayerTransferts.tsx @@ -6,7 +6,6 @@ import { IPlayer } from "@/interfaces/IPlayers"; import { IPlayerTransfers } from "@/interfaces/ITransfers"; import { Loader2 } from "lucide-react"; import { ArrowRight } from "lucide-react"; -import Image from "next/image"; interface IPlayerTransfertsProps { player: IPlayer; @@ -60,11 +59,9 @@ export const PlayerTransferts: React.FC = ({ {/* From team */}
{t.team?.logo && ( - {t.team.name} )} @@ -79,11 +76,9 @@ export const PlayerTransferts: React.FC = ({ {/* To team */}
{t.team?.logo && ( - {t.team.name} )} diff --git a/components/standings/ChampionshipStanding.tsx b/components/standings/ChampionshipStanding.tsx index c3a1538..58d89aa 100644 --- a/components/standings/ChampionshipStanding.tsx +++ b/components/standings/ChampionshipStanding.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import { ColumnDef } from "@tanstack/react-table"; import { ChampionshipService } from "@/services/ChampionshipService.ts"; import { IStanding } from "@/interfaces/IStanding"; +import { IEditionStandingRule } from "@/interfaces/IEditions"; import { Loader2 } from "lucide-react"; import { StandingDataTable } from "./StandingDataTable"; @@ -17,6 +18,7 @@ export const ChampionshipStanding: React.FC = ({ editionId, }) => { const [standings, setStandings] = useState([]); + const [rules, setRules] = useState([]); const [isDataLoading, setIsDataLoading] = useState(false); const [groups, setGroups] = useState([]); @@ -24,11 +26,12 @@ export const ChampionshipStanding: React.FC = ({ const getStandings = async () => { setIsDataLoading(true); try { - const data = - await ChampionshipService.getStandingsByChampionshipEdition( - editionId - ); + const [data, ruleData] = await Promise.all([ + ChampionshipService.getStandingsByChampionshipEdition(editionId), + ChampionshipService.getRulesByChampionshipEdition(editionId), + ]); setStandings(data); + setRules(ruleData); // Extract unique groups safely const uniqueGroups = Array.from( @@ -36,9 +39,9 @@ export const ChampionshipStanding: React.FC = ({ data .map((s) => s.participation.group) .filter( - (g): g is string => g !== undefined && g !== null && g !== "" - ) - ) + (g): g is string => g !== undefined && g !== null && g !== "", + ), + ), ); setGroups(uniqueGroups); } finally { @@ -75,14 +78,37 @@ export const ChampionshipStanding: React.FC = ({ s.participation.group === group + (s) => s.participation.group === group, )} + rules={rules} />
)) ) : ( - + + )} + {rules.length > 0 && ( +
+ {rules + // Sort by priority or position so the legend looks organized + .sort((a, b) => a.priority - b.priority) + .map((rule) => ( +
+
+ + {rule.outcome} + +
+ ))} +
)}
); diff --git a/components/standings/StandingDataTable.tsx b/components/standings/StandingDataTable.tsx index 653ae8b..904f390 100644 --- a/components/standings/StandingDataTable.tsx +++ b/components/standings/StandingDataTable.tsx @@ -1,6 +1,4 @@ -"use client"; - -import React, { useMemo } from "react"; +import React from "react"; import { ColumnDef, flexRender, @@ -15,70 +13,73 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { IEditionStandingRule } from "@/interfaces/IEditions"; import { standingSort } from "@/lib/utils"; import { IStanding } from "@/interfaces/IStanding"; interface IDataTableProps { columns: ColumnDef[]; standings: IStanding[]; + rules: IEditionStandingRule[]; } -export const StandingDataTable: React.FC = ({ - columns: propColumns, - standings, -}) => { - const columns = useMemo(() => propColumns, [propColumns]); - - const data = useMemo(() => [...standings].sort(standingSort), [standings]); - +export const StandingDataTable: React.FC = ( + props: IDataTableProps +) => { const table = useReactTable({ - data, - columns, + data: props.standings.sort(standingSort), + columns: props.columns, getCoreRowModel: getCoreRowModel(), + meta: { + rules: props.rules, + }, }); return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, - header.getContext(), + header.getContext() )} + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
+ )) + ) : ( + + + No results. + + + )} + + ); }; diff --git a/components/standings/StandingTableHeader.tsx b/components/standings/StandingTableHeader.tsx index cd086da..ec095e1 100644 --- a/components/standings/StandingTableHeader.tsx +++ b/components/standings/StandingTableHeader.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; import { IStanding } from "@/interfaces/IStanding"; +import { IEditionStandingRule } from "@/interfaces/IEditions"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; export const StandingTableHeader: ColumnDef[] = [ @@ -8,20 +9,25 @@ export const StandingTableHeader: ColumnDef[] = [ accessorKey: "position", header: "#", cell: ({ row, table }) => { - // number of rows in the table - const rows = table.getRowModel().rows.length; - // set the background color of the cell based on the position - const bgColor = - row.index - 2 < 0 - ? "bg-green-500" - : row.index >= rows - 3 - ? "bg-red-600" - : ""; + const rules = + ((table.options.meta as any)?.rules as IEditionStandingRule[]) || []; + const position = row.index + 1; + + const rule = rules.find( + (r) => + position >= r.from_position && + position <= (r.to_position ?? position), + ); + return (
- {row.index + 1} + {position}
); }, diff --git a/components/teams/ChampionshipTeamList.tsx b/components/teams/ChampionshipTeamList.tsx index 6cdc8d4..18c0fab 100644 --- a/components/teams/ChampionshipTeamList.tsx +++ b/components/teams/ChampionshipTeamList.tsx @@ -31,6 +31,9 @@ export const ChampionshipTeamList: React.FC = ({ return (
+

+ {championship.name}'s Teams +

{teams.map((team) => ( = ({ - columns: propColumns, - data: propData, + columns, + data, }) => { const [sorting, setSorting] = useState([]); - const columns = useMemo(() => propColumns, [propColumns]); - - const data = useMemo(() => propData, [propData]); - const table = useReactTable({ data, columns, @@ -46,10 +42,9 @@ export const TeamStatDataTable: React.FC = ({ debugTable: false, }); - const renderSortingIcon = (column: Column) => { + const renderSortingIcon = (column: any) => { if (!column.getCanSort() || ["position", "team"].includes(column.id)) return null; - const sorted = column.getIsSorted(); if (sorted === "asc") return ; if (sorted === "desc") return ; @@ -70,7 +65,7 @@ export const TeamStatDataTable: React.FC = ({ > {flexRender( header.column.columnDef.header, - header.getContext(), + header.getContext() )} {renderSortingIcon(header.column)} @@ -80,23 +75,15 @@ export const TeamStatDataTable: React.FC = ({ - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} - )} + ))}
diff --git a/constants/urls.ts b/constants/urls.ts index 999fbf8..db0ed23 100644 --- a/constants/urls.ts +++ b/constants/urls.ts @@ -1,9 +1,9 @@ if (!process.env.NEXT_PUBLIC_API_HOST) { - throw new Error('NEXT_PUBLIC_API_HOST is missing'); + throw new Error("NEXT_PUBLIC_API_HOST is missing"); } export const HOST = process.env.NEXT_PUBLIC_API_HOST; -export const PROTOCOLE: string = +export let PROTOCOLE: string = process.env.NODE_ENV === "production" ? "https" : "http"; const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION ?? "v1"; @@ -20,6 +20,7 @@ export const URLs = { }, EDITIONS: { ACTIVE: `${BASE_URL}/championships/editions/`, + RULES: `${BASE_URL}/edition/$editionId/rules/`, }, STANDINGS: { ALL: `${BASE_URL}/standings/`, diff --git a/contexts/DateContext.tsx b/contexts/DateContext.tsx index d75f78c..5d671a2 100644 --- a/contexts/DateContext.tsx +++ b/contexts/DateContext.tsx @@ -1,33 +1,52 @@ "use client"; -import React, { createContext, useReducer, FC, ReactNode } from "react"; +import React, { + createContext, + useReducer, + FC, + ReactNode, + useMemo, +} from "react"; import { dateReducer } from "@/reducers/dateReducer"; import { IDateState, IDateActionType } from "@/interfaces/ICommons"; -// Default date: current date -const currentDate = new Date(); - -const defaultDate: IDateState = { - date: currentDate, -}; - // Context definition export const DateContext = createContext<{ state: IDateState; dispatch: React.Dispatch; }>({ - state: defaultDate, + state: { date: new Date() }, dispatch: () => {}, }); // Provider props type interface DateContextProviderProps { children: ReactNode; + initialDate: string | undefined; } // Client component provider -export const DateContextProvider: FC = ({ children }) => { - const [state, dispatch] = useReducer(dateReducer, defaultDate); +export const DateContextProvider: FC = ({ + children, + initialDate, +}) => { + const validateDate = (dateStr: string | undefined): Date => { + if (!dateStr) return new Date(); + const d = new Date(dateStr); + // isNaN(d.getTime()) is true if the date is "Invalid Date" + return isNaN(d.getTime()) ? new Date() : d; + }; + + const parsedDate = useMemo(() => validateDate(initialDate), [initialDate]); + + const [state, dispatch] = useReducer(dateReducer, { date: parsedDate }); + + // This ensures the reducer stays in sync with URL changes + React.useEffect(() => { + if (initialDate) { + dispatch({ type: "SET_DATE", payload: new Date(initialDate) }); + } + }, [initialDate]); return ( diff --git a/enums/timeline.ts b/enums/timeline.ts index 101bcb9..227e56a 100644 --- a/enums/timeline.ts +++ b/enums/timeline.ts @@ -2,4 +2,5 @@ export enum TimelineEventEnum { CARD = "card", GOAL = "goal", SUBSTITUTION = "substitution", + PENALTY_SHOOTOUT = "penalty_shootout" } \ No newline at end of file diff --git a/interfaces/IEditions.ts b/interfaces/IEditions.ts index d9dcfa7..50f7996 100644 --- a/interfaces/IEditions.ts +++ b/interfaces/IEditions.ts @@ -5,6 +5,17 @@ export interface IChampionshipEdition { logo?: string; } +export interface IEditionStandingRule { + id: number; + edition: number; + phase: string; + from_position: number; + to_position?: number; + outcome: string; + color: string; + priority: number; +} + export interface IMatchEdition { id: number; championship: IChampionshipEdition; diff --git a/interfaces/IMatch.d.ts b/interfaces/IMatch.d.ts index b9c7308..9bdd25c 100644 --- a/interfaces/IMatch.d.ts +++ b/interfaces/IMatch.d.ts @@ -12,16 +12,6 @@ interface IMatchTeam { logo: string; } -interface IMatchLineUp { - id: number, - player: IBasePlayer; - team: ITeamBase, - match: number, - poistion: string; - is_starting: boolean; - minutes_played: number; -} - export interface IMatchBase { id: number; type: MatchType; @@ -53,7 +43,16 @@ export interface IMatch extends IMatchBase { goals: IGoal[]; cards: ICard[]; substitutions: ISubtitution[]; - lineups: IMatchLineUp[]; +} + + +export interface IMatchAppearance { + id: number; + player: IBasePlayer; + team: ITeamBase; + position: string; + is_starting: boolean; + minutesPlayed: number; } diff --git a/interfaces/ITeams.ts b/interfaces/ITeams.ts index f8765e9..f03bdda 100644 --- a/interfaces/ITeams.ts +++ b/interfaces/ITeams.ts @@ -4,7 +4,6 @@ export interface ITeamBase { logo: string; stadium: string; country: string; - website: string | null; } export interface ITeam extends ITeamBase { diff --git a/interfaces/ITimelineEvent.d.ts b/interfaces/ITimelineEvent.d.ts index 43009af..a320a71 100644 --- a/interfaces/ITimelineEvent.d.ts +++ b/interfaces/ITimelineEvent.d.ts @@ -1,8 +1,6 @@ import React from "react"; import { TimelineEventEnum } from "@/enums/timeline"; -// export type TimelineEventType = TimelineEventEnum.GOAL | TimelineEventEnum.CARD | TimelineEventEnum.SUBSTITUTION; - export interface ITimelineEvent { id: number; type: TimelineEventEnum; @@ -11,7 +9,9 @@ export interface ITimelineEvent { team: IMatchTeam; title: string; description: string; - icon: string | React; + icon: string | React; is_penalty?: boolean; is_home?: boolean; + is_missed?: boolean; + shootout_order?: number; } diff --git a/services/ChampionshipService.ts b/services/ChampionshipService.ts index ebb4192..2c2b116 100644 --- a/services/ChampionshipService.ts +++ b/services/ChampionshipService.ts @@ -1,6 +1,6 @@ import { URLs } from "../constants/urls"; import { IChampionship } from "../interfaces/IChampionship"; -import { IEdition } from "@/interfaces/IEditions"; +import { IEdition, IEditionStandingRule } from "@/interfaces/IEditions"; import { IStanding } from "../interfaces/IStanding"; export class ChampionshipService { @@ -34,4 +34,13 @@ export class ChampionshipService { ); return response.json() as Promise; } + + public static async getRulesByChampionshipEdition( + editionId: number + ): Promise { + const response = await fetch( + URLs.EDITIONS.RULES.replace("$editionId", editionId.toString()) + ); + return response.json() as Promise; + } } diff --git a/services/MatchService.ts b/services/MatchService.ts index b26b40a..60f5110 100644 --- a/services/MatchService.ts +++ b/services/MatchService.ts @@ -2,8 +2,8 @@ import { URLs } from "../constants/urls"; import { IMatchBase, IMatch, + IMatchAppearance, IMatchEvent, - IMatchLineUp, } from "@/interfaces/IMatch"; export class MatchService { @@ -65,19 +65,18 @@ export class MatchService { return response.json() as Promise; } - public static async getMatchLineUp(matchId: number): Promise { - const url: string = URLs.MATCHS.LINEUPS.replace( - "$matchId", - matchId.toString(), + public static async getMatchAppearances( + matchId: number, + ): Promise { + const response = await fetch( + URLs.MATCHS.APPEARANCE.replace("$matchId", matchId.toString()), ); - const response = await fetch(url); - return response.json() as Promise; + return response.json() as Promise; } public static async getMatchTimeline( _matchId: number, ): Promise { - console.log(_matchId); return []; } From 1b5886e7d8599f065ef763a32ad27d881ca594bd Mon Sep 17 00:00:00 2001 From: Alex Mboutchouang Date: Sat, 31 Jan 2026 01:57:09 +0100 Subject: [PATCH 2/3] new data fields and edition rules logic --- constants/urls.ts | 2 +- services/MatchService.ts | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/constants/urls.ts b/constants/urls.ts index db0ed23..5aa7582 100644 --- a/constants/urls.ts +++ b/constants/urls.ts @@ -3,7 +3,7 @@ if (!process.env.NEXT_PUBLIC_API_HOST) { } export const HOST = process.env.NEXT_PUBLIC_API_HOST; -export let PROTOCOLE: string = +export const PROTOCOLE: string = process.env.NODE_ENV === "production" ? "https" : "http"; const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION ?? "v1"; diff --git a/services/MatchService.ts b/services/MatchService.ts index 60f5110..62beae2 100644 --- a/services/MatchService.ts +++ b/services/MatchService.ts @@ -3,7 +3,6 @@ import { IMatchBase, IMatch, IMatchAppearance, - IMatchEvent, } from "@/interfaces/IMatch"; export class MatchService { @@ -74,12 +73,6 @@ export class MatchService { return response.json() as Promise; } - public static async getMatchTimeline( - _matchId: number, - ): Promise { - return []; - } - public static async getUpcomingMatchs( limit: number = 10, ): Promise { From 9a447a83a0c300a4705d4c691ae7fbe44a9abbc8 Mon Sep 17 00:00:00 2001 From: Alex Mboutchouang Date: Mon, 16 Feb 2026 16:02:04 +0100 Subject: [PATCH 3/3] Fix lint: new data fields and edition rules logic --- app/about/page.tsx | 4 +- app/contact/page.tsx | 2 +- app/leagues/[id]/ChampionshipClient.tsx | 53 ++++++++++---------- components/StandingCard.tsx | 7 ++- components/matchs/TimelineItem.tsx | 2 +- components/players/PlayerTeams.tsx | 5 +- components/players/PlayerTransferts.tsx | 9 +++- components/standings/StandingTableHeader.tsx | 2 +- components/teams/ChampionshipTeamList.tsx | 2 +- components/teams/TeamStatDataTable.tsx | 5 +- lib/utils.ts | 23 +++++++++ 11 files changed, 74 insertions(+), 40 deletions(-) diff --git a/app/about/page.tsx b/app/about/page.tsx index 7570c7c..5b8ff1e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -111,7 +111,7 @@ export default function AboutPage() {

We envision a future where fans, analysts, and football enthusiasts can easily access reliable information about - Cameroon's local leagues. By combining real-time match updates + Cameroon's local leagues. By combining real-time match updates with a growing historical data archive, Scoreboards aims to enhance the experience of following football in Cameroon.

@@ -127,7 +127,7 @@ export default function AboutPage() {

About Our Data

At Scoreboards, we are committed to collecting accurate and - comprehensive data from Cameroon's local football leagues. Every + comprehensive data from Cameroon's local football leagues. Every match, player performance, and team statistic is carefully recorded to create a reliable historical archive.

diff --git a/app/contact/page.tsx b/app/contact/page.tsx index ad261b0..223a56d 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -29,7 +29,7 @@ export default function ContactPage() {

Contact Us

- Have questions or feedback? Fill out the form below and we'll get + Have questions or feedback? Fill out the form below and we'll get back to you as soon as possible.

diff --git a/app/leagues/[id]/ChampionshipClient.tsx b/app/leagues/[id]/ChampionshipClient.tsx index 15961e1..a20cc0f 100644 --- a/app/leagues/[id]/ChampionshipClient.tsx +++ b/app/leagues/[id]/ChampionshipClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ArrowLeft, MapPin } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -22,9 +22,10 @@ interface Props { export default function ChampionshipClient({ id }: Props) { const router = useRouter(); const searchParams = useSearchParams(); + const pathname = usePathname(); const [edition, setEdition] = useState(null); - const [championshipTab, setChampionshipTab] = useState("table"); + const championshipTab = searchParams.get("tab") || "table"; const [matchTab, setMatchTab] = useState("all"); useEffect(() => { @@ -33,34 +34,36 @@ export default function ChampionshipClient({ id }: Props) { setEdition(data); }; loadEdition(); + // Tab logic removed from here to prevent cascading renders + }, [id]); - const tab = searchParams.get("tab"); - if (tab) setChampionshipTab(tab); - }, [id, searchParams]); + // 2. Helper to update tabs via URL + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }; if (!edition) { return
Loading...
; } - return (
- {edition.championship?.logo && - - {edition.label} - } + {edition.championship?.logo && ( + + + {edition.label} + + )} -

- {edition.label} -

+

{edition.label}

{edition.championship.country && (
- - {edition.championship.country} - + {edition.championship.country}
)}
@@ -74,20 +77,13 @@ export default function ChampionshipClient({ id }: Props) { Back - + Table Teams Matches - - Team Stats - - - Player Stats - + Team Stats + Player Stats @@ -109,7 +105,10 @@ export default function ChampionshipClient({ id }: Props) { - + diff --git a/components/StandingCard.tsx b/components/StandingCard.tsx index 21c5cb6..33b89b9 100644 --- a/components/StandingCard.tsx +++ b/components/StandingCard.tsx @@ -1,5 +1,6 @@ import React from "react"; import { IStanding } from "../interfaces/IStanding"; +import Image from "next/image"; interface IStandingCardProps { standing: IStanding; @@ -7,17 +8,19 @@ interface IStandingCardProps { } export const StandingCard: React.FC = ( - props: IStandingCardProps + props: IStandingCardProps, ) => { return ( {props.index + 1} {props.standing.participation.team.logo && ( - {props.standing.participation.team.name} )} {props.standing.participation.team.name} diff --git a/components/matchs/TimelineItem.tsx b/components/matchs/TimelineItem.tsx index f659b38..40eecc9 100644 --- a/components/matchs/TimelineItem.tsx +++ b/components/matchs/TimelineItem.tsx @@ -49,7 +49,7 @@ export const TimelineItem: React.FC = ({ event }) => {
- {event.minute}' + {event.minute}'
diff --git a/components/players/PlayerTeams.tsx b/components/players/PlayerTeams.tsx index 97ef23c..7adf863 100644 --- a/components/players/PlayerTeams.tsx +++ b/components/players/PlayerTeams.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import { PlayerService } from "@/services/PlayerService"; import { IPlayer, IPlayerTeamHistory } from "@/interfaces/IPlayers"; import { Loader2 } from "lucide-react"; +import Image from "next/image"; interface IPlayerTeamsProps { player: IPlayer; @@ -61,10 +62,12 @@ export const PlayerTeams: React.FC = ({ player }) => { className="flex items-center gap-4 border rounded-lg p-4 shadow-sm hover:shadow-md transition" > {h.team.logo && ( - {h.team.name} )}
diff --git a/components/players/PlayerTransferts.tsx b/components/players/PlayerTransferts.tsx index 2cf4a13..2271762 100644 --- a/components/players/PlayerTransferts.tsx +++ b/components/players/PlayerTransferts.tsx @@ -6,6 +6,7 @@ import { IPlayer } from "@/interfaces/IPlayers"; import { IPlayerTransfers } from "@/interfaces/ITransfers"; import { Loader2 } from "lucide-react"; import { ArrowRight } from "lucide-react"; +import Image from "next/image"; interface IPlayerTransfertsProps { player: IPlayer; @@ -59,10 +60,12 @@ export const PlayerTransferts: React.FC = ({ {/* From team */}
{t.team?.logo && ( - {t.team.name} )} @@ -76,10 +79,12 @@ export const PlayerTransferts: React.FC = ({ {/* To team */}
{t.team?.logo && ( - {t.team.name} )} diff --git a/components/standings/StandingTableHeader.tsx b/components/standings/StandingTableHeader.tsx index ec095e1..5cf06f2 100644 --- a/components/standings/StandingTableHeader.tsx +++ b/components/standings/StandingTableHeader.tsx @@ -10,7 +10,7 @@ export const StandingTableHeader: ColumnDef[] = [ header: "#", cell: ({ row, table }) => { const rules = - ((table.options.meta as any)?.rules as IEditionStandingRule[]) || []; + (table.options.meta as { rules?: IEditionStandingRule[] })?.rules ?? []; const position = row.index + 1; const rule = rules.find( diff --git a/components/teams/ChampionshipTeamList.tsx b/components/teams/ChampionshipTeamList.tsx index 18c0fab..835097e 100644 --- a/components/teams/ChampionshipTeamList.tsx +++ b/components/teams/ChampionshipTeamList.tsx @@ -32,7 +32,7 @@ export const ChampionshipTeamList: React.FC = ({ return (

- {championship.name}'s Teams + {championship.name}'s Teams

{teams.map((team) => ( diff --git a/components/teams/TeamStatDataTable.tsx b/components/teams/TeamStatDataTable.tsx index eeec7f1..45851b3 100644 --- a/components/teams/TeamStatDataTable.tsx +++ b/components/teams/TeamStatDataTable.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ColumnDef, + Column, flexRender, getCoreRowModel, useReactTable, @@ -42,7 +43,7 @@ export const TeamStatDataTable: React.FC = ({ debugTable: false, }); - const renderSortingIcon = (column: any) => { + const renderSortingIcon = (column: Column) => { if (!column.getCanSort() || ["position", "team"].includes(column.id)) return null; const sorted = column.getIsSorted(); @@ -65,7 +66,7 @@ export const TeamStatDataTable: React.FC = ({ > {flexRender( header.column.columnDef.header, - header.getContext() + header.getContext(), )} {renderSortingIcon(header.column)} diff --git a/lib/utils.ts b/lib/utils.ts index 918e9e1..19bdbab 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { IStanding } from "@/interfaces/IStanding"; +import { ITimelineEvent } from "@/interfaces/ITimelineEvent"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -29,3 +30,25 @@ export function standingSort(a: IStanding, b: IStanding): number { return b.participation.team.name.localeCompare(a.participation.team.name); } + +/** + * Sorts match events chronologically, handling stoppage time. + * Logic: (Minute * 100) + StoppageMinute + */ +export const sortByMatchTime = ( + a: ITimelineEvent, + b: ITimelineEvent, +): number => { + // Use 0 if minute or stoppage_minute is missing/null + const minuteA = a.minute ?? 0; + const stoppageA = a.stoppage_minute ?? 0; + + const minuteB = b.minute ?? 0; + const stoppageB = b.stoppage_minute ?? 0; + + const weightA = minuteA * 100 + stoppageA; + const weightB = minuteB * 100 + stoppageB; + + return weightA - weightB; +}; +