v26-shadow #1

Merged
fahricansecer merged 3 commits from v26-shadow into main 2026-04-23 22:24:37 +03:00
14 changed files with 661 additions and 42 deletions
Showing only changes of commit 538612c8ea - Show all commits
+354
View File
@@ -0,0 +1,354 @@
# Frontend Project Summary
## 1. Bu frontend ne yapiyor?
Bu frontend, backend ve AI motorunun urettigi spor bahis istihbaratini son kullanici urunune ceviren Next.js uygulamasidir. Gorevi sadece veri gostermek degildir; kullaniciyi su urun akislarina sokar:
- maclari ve ligleri kesfetme
- tek mac detay ve AI tahminini gorme
- upcoming predictions ve value bet yuzeylerini tuketme
- AI destekli kupon uretme
- kullanici kuponlarini kaydetme / inceleme
- Spor Toto bultenlerinden sistem kuponu uretme
- profil, auth ve dashboard deneyimini yonetme
Bu nedenle frontend, "sports betting dashboard + decision UI" gibi dusunulmeli.
## 2. Teknoloji omurgasi
- Framework: Next.js 16 App Router
- UI: Chakra UI v3
- State/server cache: TanStack React Query
- Local state: Zustand
- Auth: NextAuth credentials akisi
- I18n: `next-intl`
- Motion: `framer-motion` + `aos`
Temel amac, backend'den gelen AI zengin response'lari urune ceviren modern bir dashboard/site yapisi kurmak.
## 3. Genel mimari
Frontend'i 5 katmanda okumak mantikli:
### A. Routing ve app shell
`src/app/[locale]` altindaki route yapisi, locale-prefix zorunlu bir App Router kurgusu kullaniyor. Uygulamada `tr` ve `en` mantigi bulunuyor.
### B. Provider ve global uygulama katmani
`src/components/ui/provider.tsx` tarafinda:
- SessionProvider
- Chakra provider
- React Query provider
- animation/top loader/toaster
gibi global baglamlar olusturuluyor.
### C. API client katmani
Frontend, backend ile dogrudan her komponentte konusmuyor. Bunun yerine:
- `src/lib/api/create-api-client.ts`
- `src/lib/api/api-service.ts`
- ilgili domain service/hook dosyalari
uzerinden iletisim kuruyor.
### D. Domain ekranlari
Asil urun ekranlari:
- home
- matches
- match detail
- predictions
- coupon builder
- coupon history
- analysis
- spor toto
- dashboard
- profile
- admin
### E. Client-side state
Zustand store'lari ile secili maclar, filtreler ve kupon builder state'i elde tutuluyor.
## 4. Route mantigi ve sayfa yapisi
### 4.1 Locale-first routing
Tum sayfalar `src/app/[locale]` altinda konumlanmis. Bu, URL yapisinin `/tr/...` veya `/en/...` olmasi gerektigini gosteriyor.
### 4.2 Site ve auth ayrimi
Routing yapisinda iki ana grup var:
- `(auth)`: signin, signup gibi auth ekranlari
- `(site)`: asil urun ekranlari
Bu ayrim layout seviyesinde de yapilmis; yani login/register deneyimi ile urun shell'i birbirinden ayriliyor.
### 4.3 Ana kullanici yuzeyleri
Onemli sayfalar:
- `home`: urunun tanitim ve giris noktasi
- `matches`: mac kesfi
- `matches/[id]`: tek mac detay ve prediction
- `predictions`: upcoming/value/history
- `coupon-builder`: AI destekli kupon olusturma
- `spor-toto`: Toto deneyimi
Bu sayfalar projenin asil urun omurgasini olusturuyor.
## 5. API ile konusma mantigi
### 5.1 API client'in onemi
`createApiClient` katmani frontend'in en onemli altyapilarindan biri. Burada:
- token otomatik eklenir
- `Accept-Language` gonderilir
- response body'deki `success/status` kontrol edilir
- 401 benzeri durumlarda logout/redirect davranisi uygulanabilir
Bu katman cok onemli, cunku backend bazen klasik HTTP hata semantigi yerine body-level durum donuyor.
### 5.2 Domain service yapisi
Her buyuk domain icin genelde su pattern var:
- `service.ts`
- `types.ts`
- `use-hooks.ts`
Bu pattern su alanlarda goruluyor:
- matches
- predictions
- coupons
- analysis
- spor-toto
- users
- admin
- leagues
Yani veri cekme mantigi komponentlerden ayrilmaya calisilmis.
## 6. Auth mantigi
### 6.1 NextAuth credentials akisi
Frontend auth deneyimi NextAuth ile yonetiliyor. Ancak burada OAuth agirlikli bir kurgu degil, backend'in kendi `/auth/*` endpointlerini kullanan credentials akisi var.
### 6.2 Backend bagimliligi
Auth ekranlari backend response formatina ciddi sekilde bagimli. Login/register sonrasi access token ve kullanici bilgileri session'a tasiniyor.
### 6.3 Dikkat cekici nokta
Kodda `src/lib/api/example/auth/service.ts` gibi "example" koklu bir auth servis yolu kullaniliyor. Bu, projede bir noktada boilerplate veya scaffold'tan evrim gecisi oldugunu gosteriyor. Uygulama gercekte backend auth endpointleriyle konusuyor ama isimlendirme tarafinda kalinti izler var.
## 7. Ana ekranlar ve urun rolleri
### 7.1 Home
`home-content.tsx`, landing page / urun tanitim sayfasi gibi calisiyor. Kullaniciya platformun vaadini, ozelliklerini ve yonlendirmelerini sunuyor. Bu sayfa operasyonel ekran degil; urunun vitrini.
### 7.2 Matches
`matches-content.tsx`, aktif ve upcoming maclari kesfetme ekrani. Lig bazli filtreleme, spor secimi ve yan panel benzeri yapilarla kullaniciya mac havuzu sunuyor.
Bu ekran backend'deki `live_matches` mantiginin frontend karsiligidir.
### 7.3 Match Detail
`match-detail-content.tsx` en onemli ekranlardan biri. Burada:
- mac bilgisi
- oranlar
- AI prediction
- yardimci market / confidence benzeri katmanlar
kullaniciya tek bir detay sayfasinda sunulur.
Urunun "tek maca odakli karar verme" deneyimi burada yasar.
### 7.4 Predictions
`predictions-content.tsx`, backend prediction yuzeylerini toplu urune cevirir:
- upcoming predictions
- value bets
- gecmis / history benzeri alanlar
Bu ekran, tek mac yerine "bugun sistem ne oneriyor?" sorusuna cevap verir.
### 7.5 Coupon Builder
`coupon-builder-content.tsx` frontend'in en stratejik ekranlarindan biridir.
Bu ekranin ana akisi:
1. Kullanici uygun maclari gorur.
2. Mac secer veya strateji belirler.
3. Kupon tipi / risk stratejisi secilir.
4. Backend uzerinden AI kupon onerisi alinir.
5. Sonuc store'a yazilir ve kullanici duzenleyebilir.
Bu ekran, projenin "AI ile kupon olusturma" vaadini gercege cevirir.
Koddan anlasilan bir detay: tamamlanmis maclar bazen referans amacli okunuyor ama secilebilir kupon yuzeyinden ayriliyor. Bu, veri kaynagi ve UI ayrimi acisindan mantikli.
### 7.6 Coupon History
`coupon-history-content.tsx`, kullanicinin onceki kuponlarini gostermeyi amaclar. Ancak backend tarafinda history akislarinin bazilarinin stub/eksik oldugu izlenimi var; dolayisiyla bu ekranin veri derinligi backend ile birlikte degerlendirilmelidir.
### 7.7 Spor Toto
`spor-toto-content.tsx`, frontend'in ayri urun kimligine sahip ekranlarindan biri.
Burada:
- bulten secimi
- strateji secimi
- bulten sync tetikleme
- kolon / tahmin / Toto odakli sonuc gosterimi
yer alir.
Bu ekran normal mac prediction ekranindan farkli bir zihinsel model ister.
### 7.8 Analysis
`analysis-content.tsx`, daha eski urun katmaninin frontend yuzeyi gibi duruyor. Birden fazla mac veya farkli analiz girdi tipleri ile backend analysis modulune gider. Mevcut repo'da ana omurgadan biraz daha kenarda.
### 7.9 Dashboard
`dashboard-content.tsx`, kullaniciya bugunun maclari, ozet prediction yuzeyleri ve bazi kullanici istatistiklerini tek yerde toplar. Urunun "kontrol paneli" gibi davranir.
### 7.10 Profile
`profile-content.tsx`, session verisi ile kullanici servislerini birlestirir; profil ve sifre guncelleme gibi akislarin UI tarafidir.
### 7.11 Admin
`admin-content.tsx`, admin analytics ve kullanici listesini gosterir. Burada frontend'in backend admin endpointlerine baglandigi goruluyor. Ancak rol isimleri ve gorunurluk kontrolu tarafinda backend ile tam uyum konusu tekrar kontrol edilmelidir.
## 8. UI komponent mantigi
Frontend iki tip komponentten olusuyor:
### A. Urun komponentleri
Bunlar is mantigi tasir:
- matches
- predictions
- coupons
- spor-toto
- dashboard
- profile
- admin
### B. Design system / wrapper komponentleri
`src/components/ui` altinda cok sayida Chakra wrapper ve tekrar kullanilabilir UI parcasi var. Bunlar tasarim/ergonomi icin onemli ama urunun asil domain mantigini tasimiyor.
Bir AI repo'yu anlamaya calisirken bu iki katmani karistirmamali; asil davranis urun komponentleri ve `src/lib/api` tarafinda yasar.
## 9. State yonetimi
### 9.1 React Query
Sunucu verisi cache'lenir, refetch edilir ve async lifecycle burada yonetilir. Domain hook'lari bu sistemin ustune kurulmus.
### 9.2 Zustand
Iki anlamli local state merkezi gorunuyor:
- `coupon-store`: secili kupon / strateji / user interaction state
- `match-store`: spor ve lig filtre state'i
Bu secim mantikli; server state ile saf UI/domain interaction state ayrilmis.
## 10. I18n ve navigation
`next-intl` temelli bir locale sistemi kurulmus. Navigation katmani da locale-aware. Bu su anlama gelir:
- route helper'lari dil farkindaligina sahip
- metinler translation namespace'leri ile cekiliyor
- arayuz ilk gunden iki dillilik dusunulerek kurgulanmis
Bu, urunun uluslararasilasma niyeti oldugunu gosteriyor.
## 11. Backend ile baglantinin urunsel anlami
Frontend'in cogu ekrani backend'in asagidaki endpoint ailelerine yaslanir:
- auth
- users
- admin
- matches
- leagues
- predictions
- coupons
- analysis
- spor-toto
Yani frontend, tek bir "feed" ekrani degil; backend'deki tum ana domainlere UI saglayan bir cok-urun kabugudur.
## 12. Dikkat edilmesi gereken gercek durum notlari
Bu kisim "elestiri listesi" degil; repo'yu anlayacak AI icin baglam notudur.
### 12.1 FE/BE response kontratlari her yerde tam oturmuyor
Bazi type tanimlari ve hook beklentileri, backend'in fiili cevabiyla birebir eslesmeyebilir. Ornek olarak istatistik alanlari, coupon history veya bazi admin/user response'lari tekrar kontrol edilmelidir.
### 12.2 Stub/legacy izleri var
Ozellikle coupon history, analysis ve bazi auth/admin naming alanlarinda "tamamlanmis urun" ile "evrim gecirmis scaffold" arasi bir durum hissediliyor.
### 12.3 Rol isimleri ve admin gorunurlugu hassas konu
Header veya admin kontrolu tarafinda `ADMIN` gibi sabitler kullanilirken backend rol isimlendirmesi baska olabilir. Bu, UI gorunurlugunde sessiz hata uretebilir.
### 12.4 Global search ve bazi UI metinlerinde encoding kalintilari gorunuyor
Bu, repo'da karakter kodlamasi veya eski kopyalama izleri oldugunu gosteriyor; ana urun mantigi degil ama kalite notu olarak onemli.
## 13. Bu frontend'i zihinde nasil tutmali?
En dogru ozet su:
Bu frontend, spor verisi ve AI prediction ciktilarini kullaniciya karar verilebilir, karsilastirilabilir ve kupona donusturulebilir bir urun deneyimi olarak sunan locale-aware Next.js dashboard/site uygulamasidir.
## 14. Repo'yu anlayacak AI icin okuma sirasi
1. `README.md`
2. `prompt.md`
3. `src/app/[locale]/layout.tsx`
4. `src/components/ui/provider.tsx`
5. `src/lib/api/create-api-client.ts`
6. `src/lib/api/*/service.ts`
7. `src/components/matches/*`
8. `src/components/predictions/*`
9. `src/components/coupons/coupon-builder-content.tsx`
10. `src/components/spor-toto/spor-toto-content.tsx`
11. `src/components/dashboard/dashboard-content.tsx`
12. `src/lib/stores/*`
## 15. Son soz
Bu frontend'in degeri yalnizca guzel sayfalar olmasinda degil, backend'deki su domainleri urunlestirmesinde yatar:
- canli ve upcoming mac kesfi
- AI tahminlerinin anlasilir sunumu
- kupon kurma ve kupon saklama deneyimi
- Spor Toto gibi ayri bir oyun modunu desteklemesi
- auth, locale ve dashboard deneyimini tek kabukta toplama
Bir AI bu repo'yu anlayacaksa, bu projeyi "Next.js arayuz" olarak degil "AI destekli spor bahis urununun kullanici deneyim katmani" olarak okumali.
+1 -1
View File
@@ -22,7 +22,7 @@ import { MdMail } from "react-icons/md";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PasswordInput } from "@/components/ui/forms/password-input"; import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton"; import { Skeleton } from "@/components/ui/feedback/skeleton";
import { authService } from "@/lib/api/example/auth/service"; import { authService } from "@/lib/api/auth/service";
import { useState } from "react"; import { useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
+11 -1
View File
@@ -1,5 +1,9 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import AdminContent from "@/components/admin/admin-content"; import AdminContent from "@/components/admin/admin-content";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { isAdminRole } from "@/lib/auth/roles";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getTranslations(); const t = await getTranslations();
@@ -10,6 +14,12 @@ export async function generateMetadata() {
}; };
} }
export default function AdminPage() { export default async function AdminPage() {
const session = await getServerSession(authOptions);
if (!isAdminRole(session?.user?.roles)) {
notFound();
}
return <AdminContent />; return <AdminContent />;
} }
+7 -5
View File
@@ -1,4 +1,5 @@
import { authService } from "@/lib/api/example/auth/service"; import { authService } from "@/lib/api/auth/service";
import { normalizeRoles } from "@/lib/auth/roles";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import type { NextAuthOptions } from "next-auth"; import type { NextAuthOptions } from "next-auth";
import type { JWT } from "next-auth/jwt"; import type { JWT } from "next-auth/jwt";
@@ -11,7 +12,7 @@ function randomToken() {
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true"; const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
providers: [ providers: [
Credentials({ Credentials({
name: "Credentials", name: "Credentials",
@@ -63,6 +64,7 @@ const authOptions: NextAuthOptions = {
} }
const { accessToken, refreshToken, user } = response.data; const { accessToken, refreshToken, user } = response.data;
const normalizedRoles = normalizeRoles(user.roles);
console.log("Login successful, creating user session object"); console.log("Login successful, creating user session object");
@@ -74,7 +76,7 @@ const authOptions: NextAuthOptions = {
email: user.email, email: user.email,
accessToken, accessToken,
refreshToken, refreshToken,
roles: user.roles || [], roles: normalizedRoles,
}; };
} catch (error: unknown) { } catch (error: unknown) {
console.error("Authorize error detailed:", error); console.error("Authorize error detailed:", error);
@@ -98,13 +100,13 @@ const authOptions: NextAuthOptions = {
token.accessToken = user.accessToken; token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken; token.refreshToken = user.refreshToken;
token.id = user.id; token.id = user.id;
token.roles = user.roles; token.roles = normalizeRoles(user.roles);
} }
return token; return token;
}, },
async session({ session, token }: { session: Session; token: JWT }) { async session({ session, token }: { session: Session; token: JWT }) {
session.user.id = token.id; session.user.id = token.id;
session.user.roles = token.roles; session.user.roles = normalizeRoles(token.roles);
session.accessToken = token.accessToken; session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken; session.refreshToken = token.refreshToken;
return session; return session;
+46 -10
View File
@@ -24,8 +24,10 @@ import {
} from "@/components/motion"; } from "@/components/motion";
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks"; import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types"; import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu"; import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
import { useState } from "react"; import { useState } from "react";
import { useSession } from "next-auth/react";
type AdminTab = "overview" | "users"; type AdminTab = "overview" | "users";
@@ -81,16 +83,21 @@ export default function AdminContent() {
const t = useTranslations("admin"); const t = useTranslations("admin");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const [activeTab, setActiveTab] = useState<AdminTab>("overview"); const [activeTab, setActiveTab] = useState<AdminTab>("overview");
const { data: session, status } = useSession();
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700"); const borderColor = useColorModeValue("gray.100", "gray.700");
const canAccessAdmin = isAdminRole(session?.user?.roles);
const { data: analyticsData, isLoading: analyticsLoading } = const { data: analyticsData, isLoading: analyticsLoading } =
useAdminAnalytics(); useAdminAnalytics(canAccessAdmin);
const { data: usersData, isLoading: usersLoading } = useAdminUsers(); const { data: usersData, isLoading: usersLoading } = useAdminUsers(
undefined,
canAccessAdmin,
);
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined; const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
const users = (usersData?.data as AdminUserDto[] | undefined) ?? []; const users = usersData?.data?.items ?? [];
const tabs: { key: AdminTab; label: string }[] = [ const tabs: { key: AdminTab; label: string }[] = [
{ key: "overview", label: t("overview") }, { key: "overview", label: t("overview") },
@@ -104,6 +111,37 @@ export default function AdminContent() {
return user.email.split("@")[0]; return user.email.split("@")[0];
}; };
if (status === "loading") {
return (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
);
}
if (!canAccessAdmin) {
return (
<SlideUp>
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body py={10}>
<VStack gap={3}>
<Badge colorPalette="red" variant="subtle" borderRadius="full">
<LuShield />
Restricted
</Badge>
<Heading as="h2" size="md">
Admin access required
</Heading>
<Text color="fg.muted" textAlign="center" maxW="md">
This area is only available to superadmin accounts.
</Text>
</VStack>
</Card.Body>
</Card.Root>
</SlideUp>
);
}
return ( return (
<SlideUp> <SlideUp>
<Box> <Box>
@@ -156,7 +194,7 @@ export default function AdminContent() {
<StaggerItem> <StaggerItem>
<AdminStat <AdminStat
label={t("total-users")} label={t("total-users")}
value={analytics?.totalUsers ?? 0} value={analytics?.totalUsers ?? analytics?.users?.total ?? 0}
icon={<LuUsers />} icon={<LuUsers />}
colorPalette="primary" colorPalette="primary"
/> />
@@ -164,7 +202,7 @@ export default function AdminContent() {
<StaggerItem> <StaggerItem>
<AdminStat <AdminStat
label={t("total-predictions")} label={t("total-predictions")}
value={analytics?.totalPredictions ?? 0} value={analytics?.totalPredictions ?? analytics?.predictions ?? 0}
icon={<LuChartBar />} icon={<LuChartBar />}
colorPalette="green" colorPalette="green"
/> />
@@ -172,7 +210,7 @@ export default function AdminContent() {
<StaggerItem> <StaggerItem>
<AdminStat <AdminStat
label={t("active-users")} label={t("active-users")}
value={analytics?.activeUsers ?? 0} value={analytics?.activeUsers ?? analytics?.users?.active ?? 0}
icon={<LuActivity />} icon={<LuActivity />}
colorPalette="orange" colorPalette="orange"
/> />
@@ -244,14 +282,12 @@ export default function AdminContent() {
</Text> </Text>
<Flex flex={1} justify="center"> <Flex flex={1} justify="center">
<Badge <Badge
colorPalette={ colorPalette={isAdminRole([user.role]) ? "red" : "gray"}
user.role === "ADMIN" ? "red" : "gray"
}
variant="subtle" variant="subtle"
fontSize="2xs" fontSize="2xs"
borderRadius="full" borderRadius="full"
> >
{user.role || "User"} {formatRoleLabel(user.role)}
</Badge> </Badge>
</Flex> </Flex>
<Flex flex={1} justify="center"> <Flex flex={1} justify="center">
+2 -2
View File
@@ -39,6 +39,7 @@ import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { authConfig } from "@/config/auth"; import { authConfig } from "@/config/auth";
import { LoginModal } from "@/components/auth/login-modal"; import { LoginModal } from "@/components/auth/login-modal";
import { isAdminRole } from "@/lib/auth/roles";
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu"; import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
import GlobalSearch from "@/components/search/global-search"; import GlobalSearch from "@/components/search/global-search";
@@ -81,8 +82,7 @@ export default function Header() {
<LuUser /> <LuUser />
{t("nav.profile")} {t("nav.profile")}
</MenuItem> </MenuItem>
{session?.user && {session?.user && isAdminRole(session.user.roles) && (
session.user.roles?.includes("ADMIN") && (
<MenuItem value="admin" onClick={() => router.push("/admin")}> <MenuItem value="admin" onClick={() => router.push("/admin")}>
<LuShield /> <LuShield />
{t("nav.admin")} {t("nav.admin")}
+14 -9
View File
@@ -25,12 +25,17 @@ export default function GlobalSearch() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter(); const router = useRouter();
const isApplePlatform =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
const shortcutLabel = isApplePlatform ? "Cmd+K" : "Ctrl+K";
const shortcutCapsule = isApplePlatform ? "⌘K" : "Ctrl+K";
const bg = useColorModeValue("white", "gray.900"); const bg = useColorModeValue("white", "gray.900");
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");
// Debounce search input
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
@@ -42,7 +47,6 @@ export default function GlobalSearch() {
const teams: TeamDto[] = searchData?.data ?? []; const teams: TeamDto[] = searchData?.data ?? [];
// Close dropdown on outside click
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if ( if (
@@ -56,7 +60,6 @@ export default function GlobalSearch() {
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
// Keyboard shortcut: Ctrl+K to focus
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") { if ((e.ctrlKey || e.metaKey) && e.key === "k") {
@@ -83,8 +86,11 @@ export default function GlobalSearch() {
); );
return ( return (
<Box ref={containerRef} position="relative" w={{ base: "full", lg: "280px" }}> <Box
{/* Search Input */} ref={containerRef}
position="relative"
w={{ base: "full", lg: "280px" }}
>
<Flex <Flex
align="center" align="center"
bg={inputBg} bg={inputBg}
@@ -110,7 +116,7 @@ export default function GlobalSearch() {
setIsOpen(true); setIsOpen(true);
}} }}
onFocus={() => query.length >= 2 && setIsOpen(true)} onFocus={() => query.length >= 2 && setIsOpen(true)}
placeholder="Takım ara... (Ctrl+K)" placeholder={`Takim ara... (${shortcutLabel})`}
variant="flushed" variant="flushed"
size="sm" size="sm"
px={2} px={2}
@@ -142,11 +148,10 @@ export default function GlobalSearch() {
borderRadius="md" borderRadius="md"
fontFamily="mono" fontFamily="mono"
> >
K {shortcutCapsule}
</Text> </Text>
</Flex> </Flex>
{/* Dropdown Results */}
{isOpen && debouncedQuery.length >= 2 && ( {isOpen && debouncedQuery.length >= 2 && (
<Box <Box
position="absolute" position="absolute"
@@ -170,7 +175,7 @@ export default function GlobalSearch() {
) : teams.length === 0 ? ( ) : teams.length === 0 ? (
<Flex justify="center" py={6}> <Flex justify="center" py={6}>
<Text fontSize="sm" color="fg.muted"> <Text fontSize="sm" color="fg.muted">
Sonuç bulunamadı Sonuc bulunamadi
</Text> </Text>
</Flex> </Flex>
) : ( ) : (
+11 -4
View File
@@ -66,10 +66,17 @@ export interface UpdateUserSubscriptionDto {
// ======================== // ========================
export interface AnalyticsOverviewDto { export interface AnalyticsOverviewDto {
totalUsers: number; totalUsers?: number;
activeUsers: number; activeUsers?: number;
totalPredictions: number; totalPredictions?: number;
totalCoupons: number; totalCoupons?: number;
users?: {
total: number;
active: number;
premium: number;
};
matches?: number;
predictions?: number;
aiHealth?: Record<string, unknown>; aiHealth?: Record<string, unknown>;
[key: string]: unknown; [key: string]: unknown;
} }
+7 -2
View File
@@ -19,10 +19,11 @@ export const AdminQueryKeys = {
}; };
// Analytics // Analytics
export const useAdminAnalytics = () => { export const useAdminAnalytics = (enabled = true) => {
return useQuery({ return useQuery({
queryKey: AdminQueryKeys.analytics(), queryKey: AdminQueryKeys.analytics(),
queryFn: () => adminService.getAnalyticsOverview(), queryFn: () => adminService.getAnalyticsOverview(),
enabled,
}); });
}; };
@@ -66,10 +67,14 @@ export const useResetAllUsageLimits = () => {
}; };
// Users // Users
export const useAdminUsers = (params?: AdminPaginationParams) => { export const useAdminUsers = (
params?: AdminPaginationParams,
enabled = true,
) => {
return useQuery({ return useQuery({
queryKey: AdminQueryKeys.users(params), queryKey: AdminQueryKeys.users(params),
queryFn: () => adminService.getAllUsers(params), queryFn: () => adminService.getAllUsers(params),
enabled,
}); });
}; };
+50
View File
@@ -0,0 +1,50 @@
import { apiRequest } from "@/lib/api/api-service";
import { ApiResponse } from "@/types/api-response";
import {
LoginDto,
AuthResponse,
RegisterDto,
RefreshTokenDto,
} from "./types";
const login = (data: LoginDto) => {
return apiRequest<ApiResponse<AuthResponse>>({
url: "/auth/login",
client: "auth",
method: "post",
data,
});
};
const register = (data: RegisterDto) => {
return apiRequest<ApiResponse<AuthResponse>>({
url: "/auth/register",
client: "auth",
method: "post",
data,
});
};
const refreshToken = (data: RefreshTokenDto) => {
return apiRequest<ApiResponse<AuthResponse>>({
url: "/auth/refresh",
client: "auth",
method: "post",
data,
});
};
const logout = () => {
return apiRequest<ApiResponse<null>>({
url: "/auth/logout",
client: "auth",
method: "post",
});
};
export const authService = {
login,
register,
refreshToken,
logout,
};
+30
View File
@@ -0,0 +1,30 @@
export interface LoginDto {
email: string;
password: string;
}
export interface RegisterDto {
email: string;
password: string;
firstName: string;
lastName: string;
username?: string;
}
export interface RefreshTokenDto {
refreshToken: string;
}
export interface AuthResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
email: string;
firstName: string;
lastName: string;
isActive?: boolean;
roles: string[];
};
}
+64
View File
@@ -0,0 +1,64 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiResponse } from "@/types/api-response";
import { authService } from "./service";
import { LoginDto, RegisterDto, RefreshTokenDto, AuthResponse } from "./types";
export const AuthQueryKeys = {
all: ["auth"] as const,
session: () => [...AuthQueryKeys.all, "session"] as const,
};
export function useLogin() {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<
ApiResponse<AuthResponse>,
Error,
LoginDto
>({
mutationFn: (credentials: LoginDto) => authService.login(credentials),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AuthQueryKeys.session() });
},
});
return { data: data?.data, ...rest };
}
export function useRegister() {
const { data, ...rest } = useMutation<
ApiResponse<AuthResponse>,
Error,
RegisterDto
>({
mutationFn: (userData: RegisterDto) => authService.register(userData),
});
return { data: data?.data, ...rest };
}
export function useRefreshToken() {
const { data, ...rest } = useMutation<
ApiResponse<AuthResponse>,
Error,
RefreshTokenDto
>({
mutationFn: (tokenData: RefreshTokenDto) =>
authService.refreshToken(tokenData),
});
return { data: data?.data, ...rest };
}
export function useLogout() {
const queryClient = useQueryClient();
const { data, ...rest } = useMutation<ApiResponse<null>, Error, void>({
mutationFn: () => authService.logout(),
onSuccess: () => {
queryClient.clear();
},
});
return { data: data?.data, ...rest };
}
+16 -3
View File
@@ -99,13 +99,26 @@ export function createApiClient(baseURL: string): AxiosInstance {
) { ) {
const errorMessage = extractApiErrorMessage(data, "Bir hata oluştu"); const errorMessage = extractApiErrorMessage(data, "Bir hata oluştu");
const errorStatus =
"status" in data && typeof data.status === "number"
? data.status
: response.status;
// Handle 429 in success: false body // Handle 429 in success: false body
if (data.status === 429) { if (errorStatus === 429) {
show429Toast(errorMessage); show429Toast(errorMessage);
} }
// Use API-level status (data.status) if available, otherwise fall back to HTTP status if (errorStatus === 401 && typeof window !== "undefined") {
const errorStatus = data.status || response.status; const isAuthPath =
window.location.pathname.includes("/api/auth") ||
window.location.pathname === "/";
if (!isAuthPath) {
void signOut({ redirect: true, callbackUrl: "/" });
}
}
const apiError = new ApiError(errorMessage, errorStatus, data); const apiError = new ApiError(errorMessage, errorStatus, data);
return Promise.reject(apiError); return Promise.reject(apiError);
} }
+43
View File
@@ -0,0 +1,43 @@
const ADMIN_ROLES = new Set(["superadmin"]);
export function normalizeRole(role: string | null | undefined): string {
return role?.trim().toLowerCase() ?? "";
}
export function normalizeRoles(
roles: Array<string | null | undefined> | null | undefined,
): string[] {
if (!roles?.length) {
return [];
}
return Array.from(
new Set(roles.map((role) => normalizeRole(role)).filter(Boolean)),
);
}
export function hasRole(
roles: Array<string | null | undefined> | null | undefined,
expectedRole: string,
): boolean {
return normalizeRoles(roles).includes(normalizeRole(expectedRole));
}
export function isAdminRole(
roles: Array<string | null | undefined> | null | undefined,
): boolean {
return normalizeRoles(roles).some((role) => ADMIN_ROLES.has(role));
}
export function formatRoleLabel(role: string | null | undefined): string {
const normalizedRole = normalizeRole(role);
switch (normalizedRole) {
case "superadmin":
return "Superadmin";
case "user":
return "User";
default:
return role?.trim() || "User";
}
}