main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 3m36s

This commit is contained in:
2026-05-05 14:06:01 +03:00
parent e3cc6702dd
commit 4df27e3e6d
10 changed files with 367 additions and 53 deletions
+4 -15
View File
@@ -47,7 +47,6 @@
"low": "Low", "low": "Low",
"medium": "Medium", "medium": "Medium",
"high": "High", "high": "High",
"nav": { "nav": {
"home": "Home", "home": "Home",
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -68,7 +67,6 @@
"coupons": "Coupons", "coupons": "Coupons",
"tools": "Tools" "tools": "Tools"
}, },
"landing": { "landing": {
"hero-title": "AI-Powered Betting Predictions", "hero-title": "AI-Powered Betting Predictions",
"hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.", "hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.",
@@ -88,7 +86,6 @@
"stats-users": "Active Users", "stats-users": "Active Users",
"stats-matches": "Matches Analyzed" "stats-matches": "Matches Analyzed"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"welcome": "Welcome back", "welcome": "Welcome back",
@@ -101,7 +98,6 @@
"no-matches": "No matches available today.", "no-matches": "No matches available today.",
"no-predictions": "No predictions available." "no-predictions": "No predictions available."
}, },
"matches": { "matches": {
"title": "Matches", "title": "Matches",
"filter-sport": "Sport", "filter-sport": "Sport",
@@ -138,9 +134,11 @@
"red-card": "Red Card", "red-card": "Red Card",
"substitution": "Substitution", "substitution": "Substitution",
"starters": "Starting XI", "starters": "Starting XI",
"substitutes": "Substitutes" "substitutes": "Substitutes",
"all-matches": "All Matches",
"today-matches": "Today's Matches",
"next-1-hour": "Next 1 Hour"
}, },
"predictions": { "predictions": {
"title": "Predictions", "title": "Predictions",
"upcoming": "Upcoming", "upcoming": "Upcoming",
@@ -268,7 +266,6 @@
"recommended-stake-inline": "Suggested size" "recommended-stake-inline": "Suggested size"
} }
}, },
"coupons": { "coupons": {
"title": "Coupon Builder", "title": "Coupon Builder",
"builder-title": "Coupon Builder", "builder-title": "Coupon Builder",
@@ -392,7 +389,6 @@
"engine-mode-label": "Engine Mode", "engine-mode-label": "Engine Mode",
"engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis." "engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis."
}, },
"profile": { "profile": {
"title": "Profile", "title": "Profile",
"account-settings": "Account Settings", "account-settings": "Account Settings",
@@ -417,7 +413,6 @@
"win-rate": "Win Rate", "win-rate": "Win Rate",
"total-profit": "Total Profit" "total-profit": "Total Profit"
}, },
"leagues": { "leagues": {
"title": "Leagues & Teams", "title": "Leagues & Teams",
"countries": "Countries", "countries": "Countries",
@@ -425,7 +420,6 @@
"countries-leagues": "Countries & Leagues", "countries-leagues": "Countries & Leagues",
"search-at-least-2": "Type at least 2 characters to search teams." "search-at-least-2": "Type at least 2 characters to search teams."
}, },
"h2h": { "h2h": {
"title": "Head to Head", "title": "Head to Head",
"team-1": "Team 1", "team-1": "Team 1",
@@ -435,7 +429,6 @@
"draws": "Draws", "draws": "Draws",
"no-matches-found": "No head-to-head matches found between these teams." "no-matches-found": "No head-to-head matches found between these teams."
}, },
"analysis": { "analysis": {
"title": "Multi-Match Analysis", "title": "Multi-Match Analysis",
"select-matches": "Select Matches", "select-matches": "Select Matches",
@@ -447,7 +440,6 @@
"matches-analyzed": "matches analyzed", "matches-analyzed": "matches analyzed",
"no-history": "No analysis history yet." "no-history": "No analysis history yet."
}, },
"spor-toto": { "spor-toto": {
"title": "Spor Toto", "title": "Spor Toto",
"sync-bulletins": "Sync Bulletins", "sync-bulletins": "Sync Bulletins",
@@ -475,7 +467,6 @@
"rollover-stats": "Rollover Stats", "rollover-stats": "Rollover Stats",
"prediction-generated": "Prediction generated successfully!" "prediction-generated": "Prediction generated successfully!"
}, },
"admin": { "admin": {
"title": "Admin Panel", "title": "Admin Panel",
"subtitle": "Manage users, monitor predictions, and system overview.", "subtitle": "Manage users, monitor predictions, and system overview.",
@@ -505,7 +496,6 @@
"user-status": "Status", "user-status": "Status",
"no-users": "No users found." "no-users": "No users found."
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
"save": "Save", "save": "Save",
@@ -535,7 +525,6 @@
"showing": "Showing", "showing": "Showing",
"results": "results" "results": "results"
}, },
"seo": { "seo": {
"global": { "global": {
"title": "iddaai.com | AI-Powered Betting Predictions", "title": "iddaai.com | AI-Powered Betting Predictions",
+4 -1
View File
@@ -134,7 +134,10 @@
"red-card": "Kırmızı Kart", "red-card": "Kırmızı Kart",
"substitution": "Oyuncu Değişikliği", "substitution": "Oyuncu Değişikliği",
"starters": "İlk 11", "starters": "İlk 11",
"substitutes": "Yedekler" "substitutes": "Yedekler",
"all-matches": "Tüm Maçlar",
"today-matches": "Bugünün Maçları",
"next-1-hour": "1 Saat İçinde"
}, },
"predictions": { "predictions": {
"title": "Tahminler", "title": "Tahminler",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+13 -2
View File
@@ -2,16 +2,27 @@ import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: "standalone",
experimental: { experimental: {
optimizePackageImports: ["@chakra-ui/react"], optimizePackageImports: ["@chakra-ui/react"],
}, },
reactCompiler: true, reactCompiler: true,
async rewrites() { async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
throw new Error("url is not defined");
}
// Remove the trailing /api to map uploads from the base backend url
const backendUrl = apiUrl.replace(/\/api\/?$/, "");
return [ return [
{ {
source: "/api/backend/:path*", source: "/api/backend/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3005/api'}/:path*`, destination: `${apiUrl}/:path*`,
},
{
source: "/uploads/:path*",
destination: `${backendUrl}/uploads/:path*`,
}, },
]; ];
}, },
+1
View File
@@ -2,6 +2,7 @@ export { default as MatchCard } from "./match-card";
export { default as MatchList } from "./match-list"; export { default as MatchList } from "./match-list";
export { default as SportFilter } from "./sport-filter"; export { default as SportFilter } from "./sport-filter";
export { default as LeagueSidebar } from "./league-sidebar"; export { default as LeagueSidebar } from "./league-sidebar";
export { default as LeagueFilterBar } from "./league-filter-bar";
export { default as PredictionCard } from "./prediction-card"; export { default as PredictionCard } from "./prediction-card";
export { default as MatchDetailContent } from "./match-detail-content"; export { default as MatchDetailContent } from "./match-detail-content";
export { default as MatchesContent } from "./matches-content"; export { default as MatchesContent } from "./matches-content";
@@ -0,0 +1,175 @@
"use client";
import {
Box,
Flex,
Text,
Badge,
Image,
ScrollArea,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import type { ActiveLeagueDto } from "@/lib/api/matches/types";
interface LeagueFilterBarProps {
leagues: ActiveLeagueDto[];
selectedLeagueId: string | null;
onSelect: (leagueId: string | null) => void;
isLoading?: boolean;
}
/**
* LeagueFilterBar — Horizontal scrollable league filter chips for mobile.
* Shows country flag, league name, country name, and live/match count badges.
*/
export default function LeagueFilterBar({
leagues,
selectedLeagueId,
onSelect,
isLoading,
}: LeagueFilterBarProps) {
const t = useTranslations("matches");
const chipBg = useColorModeValue("white", "gray.800");
const chipBorder = useColorModeValue("gray.200", "gray.600");
const activeBg = useColorModeValue("primary.50", "primary.900");
const activeBorder = useColorModeValue("primary.400", "primary.500");
const countryText = useColorModeValue("gray.500", "gray.400");
if (isLoading) {
return (
<Flex gap={2} overflow="hidden" pb={2}>
{Array.from({ length: 5 }).map((_, i) => (
<Box
key={i}
h="42px"
w="120px"
bg="bg.muted"
borderRadius="full"
flexShrink={0}
animation="pulse 1.5s ease-in-out infinite"
/>
))}
</Flex>
);
}
return (
<ScrollArea.Root width="full" size="xs">
<ScrollArea.Viewport>
<ScrollArea.Content py="1">
<Flex gap={2} flexWrap="nowrap" pb={1}>
{/* "All Leagues" chip */}
<Box
as="button"
onClick={() => onSelect(null)}
px={3.5}
py={2}
borderRadius="full"
borderWidth="1.5px"
borderColor={selectedLeagueId === null ? activeBorder : chipBorder}
bg={selectedLeagueId === null ? activeBg : chipBg}
cursor="pointer"
flexShrink={0}
transition="all 0.15s"
_hover={{ borderColor: activeBorder }}
whiteSpace="nowrap"
>
<Text
fontSize="xs"
fontWeight={selectedLeagueId === null ? "bold" : "medium"}
color={selectedLeagueId === null ? "primary.fg" : "fg"}
>
{t("all-leagues")}
</Text>
</Box>
{/* League chips */}
{leagues.map((league) => {
const isActive = selectedLeagueId === league.id;
return (
<Box
as="button"
key={league.id}
onClick={() => onSelect(league.id)}
px={3}
py={1.5}
borderRadius="full"
borderWidth="1.5px"
borderColor={isActive ? activeBorder : chipBorder}
bg={isActive ? activeBg : chipBg}
cursor="pointer"
flexShrink={0}
transition="all 0.15s"
_hover={{ borderColor: activeBorder }}
whiteSpace="nowrap"
>
<Flex align="center" gap={1.5}>
{/* Flag or fallback */}
{league.countryFlag ? (
<Image
src={league.countryFlag}
alt={league.countryName || ""}
boxSize="14px"
objectFit="contain"
borderRadius="xs"
flexShrink={0}
/>
) : league.countryName ? (
<Flex
boxSize="14px"
bg="gray.200"
borderRadius="xs"
align="center"
justify="center"
flexShrink={0}
fontSize="6px"
fontWeight="bold"
color="gray.600"
>
{league.countryName.slice(0, 2).toUpperCase()}
</Flex>
) : null}
{/* League name + country */}
<Flex direction="column" align="flex-start" gap={0} lineHeight="1">
<Text
fontSize="xs"
fontWeight={isActive ? "bold" : "medium"}
color={isActive ? "primary.fg" : "fg"}
>
{league.name}
</Text>
{league.countryName && (
<Text fontSize="2xs" color={countryText} lineHeight="1">
{league.countryName}
</Text>
)}
</Flex>
{/* Live badge */}
{league.liveCount > 0 && (
<Badge
colorPalette="red"
variant="solid"
borderRadius="full"
fontSize="2xs"
px={1}
ml={0.5}
>
{league.liveCount}
</Badge>
)}
</Flex>
</Box>
);
})}
</Flex>
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="horizontal" />
<ScrollArea.Corner />
</ScrollArea.Root>
);
}
+44 -11
View File
@@ -24,6 +24,7 @@ export default function LeagueSidebar({
const borderColor = useColorModeValue("gray.100", "gray.700"); const borderColor = useColorModeValue("gray.100", "gray.700");
const activeBg = useColorModeValue("primary.50", "primary.900"); const activeBg = useColorModeValue("primary.50", "primary.900");
const hoverBg = useColorModeValue("gray.50", "gray.750"); const hoverBg = useColorModeValue("gray.50", "gray.750");
const countryTextColor = useColorModeValue("gray.500", "gray.400");
if (isLoading) { if (isLoading) {
return ( return (
@@ -111,26 +112,58 @@ export default function LeagueSidebar({
> >
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<Flex align="center" gap={2} minW={0} flex={1}> <Flex align="center" gap={2} minW={0} flex={1}>
{league.countryFlag && ( {/* Country Flag or Fallback */}
{league.countryFlag ? (
<Image <Image
src={league.countryFlag} src={league.countryFlag}
alt={league.countryName || ""} alt={league.countryName || ""}
boxSize="16px" boxSize="18px"
objectFit="contain" objectFit="contain"
flexShrink={0} flexShrink={0}
borderRadius="sm"
/> />
) : (
<Flex
boxSize="18px"
bg="gray.200"
borderRadius="sm"
align="center"
justify="center"
flexShrink={0}
fontSize="8px"
fontWeight="bold"
color="gray.600"
>
{league.countryName?.slice(0, 2)?.toUpperCase() || "??"}
</Flex>
)} )}
<Text
fontSize="sm" {/* League Name + Country */}
fontWeight={isActive ? "bold" : "medium"} <Box minW={0} flex={1}>
color={isActive ? "primary.fg" : "fg"} <Text
truncate fontSize="sm"
> fontWeight={isActive ? "bold" : "medium"}
{league.name} color={isActive ? "primary.fg" : "fg"}
</Text> truncate
lineHeight="1.3"
>
{league.name}
</Text>
{league.countryName && (
<Text
fontSize="2xs"
color={countryTextColor}
truncate
lineHeight="1.2"
>
{league.countryName}
</Text>
)}
</Box>
</Flex> </Flex>
<Flex gap={1.5} flexShrink={0}> {/* Badges */}
<Flex gap={1.5} flexShrink={0} ml={2}>
{league.liveCount > 0 && ( {league.liveCount > 0 && (
<Badge <Badge
colorPalette="red" colorPalette="red"
+115 -22
View File
@@ -1,13 +1,15 @@
"use client"; "use client";
import { Box, Flex, Heading } from "@chakra-ui/react"; import { Box, Flex, Heading, Group, Button } from "@chakra-ui/react";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { SlideUp } from "@/components/motion"; import { SlideUp } from "@/components/motion";
import { SportFilter, LeagueSidebar, MatchList } from "@/components/matches"; import { SportFilter, LeagueSidebar, LeagueFilterBar, MatchList } from "@/components/matches";
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks"; import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
import { useMatchStore } from "@/lib/stores/match-store"; import { useMatchStore } from "@/lib/stores/match-store";
type QuickFilter = "all" | "today" | "live" | "next_1_hour";
export default function MatchesContent() { export default function MatchesContent() {
const t = useTranslations("matches"); const t = useTranslations("matches");
@@ -16,6 +18,9 @@ export default function MatchesContent() {
const setSport = useMatchStore((s) => s.setSport); const setSport = useMatchStore((s) => s.setSport);
const setLeague = useMatchStore((s) => s.setLeague); const setLeague = useMatchStore((s) => s.setLeague);
const [quickFilter, setQuickFilter] = useState<QuickFilter>("all");
const [dateFilter, setDateFilter] = useState<string>("");
// Fetch active leagues for sidebar // Fetch active leagues for sidebar
const { data: leaguesData, isLoading: leaguesLoading } = const { data: leaguesData, isLoading: leaguesLoading } =
useActiveLeagues(sport); useActiveLeagues(sport);
@@ -26,42 +31,58 @@ export default function MatchesContent() {
// Trigger query on sport/league change // Trigger query on sport/league change
const { data: matchesData, isPending: matchesLoading } = (() => { const { data: matchesData, isPending: matchesLoading } = (() => {
// We use the queryMatches mutation for initial data
// but for the UI we want a reactive approach.
// Let's use the standard list with league filter
return { return {
data: queryMatches.data, data: queryMatches.data,
isPending: queryMatches.isPending, isPending: queryMatches.isPending,
}; };
})(); })();
const triggerQuery = (currentSport: typeof sport, currentLeague: string | null, currentFilter: QuickFilter, currentDate?: string) => {
const payload: any = {
sport: currentSport,
leagueId: currentLeague || undefined,
limit: 100,
};
if (currentDate) {
payload.date = currentDate;
} else if (currentFilter === "today") {
// YYYY-MM-DD for today
payload.date = new Date().toISOString().split("T")[0];
} else if (currentFilter === "live") {
payload.status = "LIVE";
} else if (currentFilter === "next_1_hour") {
payload.dateRange = {
from: new Date().toISOString(),
to: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
}
queryMatches.mutate(payload);
};
// Auto-trigger query when sport or league changes // Auto-trigger query when sport or league changes
const handleSportChange = (newSport: typeof sport) => { const handleSportChange = (newSport: typeof sport) => {
setSport(newSport); setSport(newSport);
queryMatches.mutate({ setLeague(null);
sport: newSport, triggerQuery(newSport, null, quickFilter, dateFilter);
leagueId: undefined,
limit: 100,
});
}; };
const handleLeagueChange = (leagueId: string | null) => { const handleLeagueChange = (leagueId: string | null) => {
setLeague(leagueId); setLeague(leagueId);
queryMatches.mutate({ triggerQuery(sport, leagueId, quickFilter, dateFilter);
sport, };
leagueId: leagueId || undefined,
limit: 100, const handleQuickFilterChange = (filter: QuickFilter) => {
}); setDateFilter(""); // Clear specific date
setQuickFilter(filter);
triggerQuery(sport, leagueFilter, filter, undefined);
}; };
// Initial load // Initial load
useEffect(() => { useEffect(() => {
if (!queryMatches.data && !queryMatches.isPending) { if (!queryMatches.data && !queryMatches.isPending) {
queryMatches.mutate({ triggerQuery(sport, leagueFilter, quickFilter, dateFilter);
sport,
leagueId: leagueFilter || undefined,
limit: 100,
});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -75,7 +96,7 @@ export default function MatchesContent() {
<Flex <Flex
justify="space-between" justify="space-between"
align="center" align="center"
mb={6} mb={4}
flexWrap="wrap" flexWrap="wrap"
gap={3} gap={3}
> >
@@ -85,6 +106,78 @@ export default function MatchesContent() {
<SportFilter value={sport} onChange={handleSportChange} /> <SportFilter value={sport} onChange={handleSportChange} />
</Flex> </Flex>
{/* Quick Filters */}
<Flex mb={6} overflowX="auto" pb={2} css={{ "&::-webkit-scrollbar": { display: "none" } }} gap={4} align="center">
<Group attached>
<Button
size="sm"
onClick={() => handleQuickFilterChange("all")}
colorPalette={quickFilter === "all" ? "primary" : "gray"}
variant={quickFilter === "all" ? "solid" : "outline"}
>
{t("all-matches")}
</Button>
<Button
size="sm"
onClick={() => handleQuickFilterChange("today")}
colorPalette={quickFilter === "today" ? "primary" : "gray"}
variant={quickFilter === "today" ? "solid" : "outline"}
>
{t("today-matches")}
</Button>
<Button
size="sm"
onClick={() => handleQuickFilterChange("live")}
colorPalette={quickFilter === "live" ? "primary" : "gray"}
variant={quickFilter === "live" ? "solid" : "outline"}
>
{t("live")}
</Button>
<Button
size="sm"
onClick={() => handleQuickFilterChange("next_1_hour")}
colorPalette={quickFilter === "next_1_hour" ? "primary" : "gray"}
variant={quickFilter === "next_1_hour" ? "solid" : "outline"}
>
{t("next-1-hour")}
</Button>
</Group>
<input
type="date"
value={dateFilter}
onChange={(e) => {
const dateVal = e.target.value;
setDateFilter(dateVal);
if (dateVal) {
setQuickFilter("all"); // Reset quick filter highlight
triggerQuery(sport, leagueFilter, "all", dateVal);
} else {
handleQuickFilterChange("all");
}
}}
style={{
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "1px solid var(--chakra-colors-gray-200)",
fontSize: "0.875rem",
background: "transparent",
color: "inherit",
outline: "none"
}}
/>
</Flex>
{/* Mobile League Filter Bar (visible on small screens only) */}
<Box display={{ base: "block", lg: "none" }} mb={4}>
<LeagueFilterBar
leagues={leagues}
selectedLeagueId={leagueFilter}
onSelect={handleLeagueChange}
isLoading={leaguesLoading}
/>
</Box>
{/* Main Content */} {/* Main Content */}
<Flex <Flex
gap={6} gap={6}
+10 -1
View File
@@ -16,8 +16,10 @@ import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { LuSearch, LuX } from "react-icons/lu"; import { LuSearch, LuX } from "react-icons/lu";
import type { TeamDto } from "@/lib/api/leagues/types"; import type { TeamDto } from "@/lib/api/leagues/types";
import { useSession } from "next-auth/react";
export default function GlobalSearch() { export default function GlobalSearch() {
const { data: session } = useSession();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [debouncedQuery, setDebouncedQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState("");
@@ -35,6 +37,7 @@ export default function GlobalSearch() {
const borderColor = useColorModeValue("gray.200", "gray.700"); const borderColor = useColorModeValue("gray.200", "gray.700");
const hoverBg = useColorModeValue("gray.50", "gray.800"); const hoverBg = useColorModeValue("gray.50", "gray.800");
const inputBg = useColorModeValue("gray.50", "gray.800"); const inputBg = useColorModeValue("gray.50", "gray.800");
const shortcutBg = useColorModeValue("gray.100", "gray.700");
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const timer = setTimeout(() => setDebouncedQuery(query), 300);
@@ -85,6 +88,12 @@ export default function GlobalSearch() {
[router], [router],
); );
// If user is not logged in, don't show the team search,
// as it requires auth to view team detail pages.
if (!session) {
return null;
}
return ( return (
<Box <Box
ref={containerRef} ref={containerRef}
@@ -142,7 +151,7 @@ export default function GlobalSearch() {
fontSize="xs" fontSize="xs"
color="fg.muted" color="fg.muted"
flexShrink={0} flexShrink={0}
bg={useColorModeValue("gray.100", "gray.700")} bg={shortcutBg}
px={1.5} px={1.5}
py={0.5} py={0.5}
borderRadius="md" borderRadius="md"
View File