Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10e521e382 | |||
| 9e04ca5627 | |||
| 4896323e04 | |||
| 538612c8ea |
@@ -1 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.next
|
||||||
+33
-2
@@ -34,7 +34,9 @@
|
|||||||
"forgot-password": "Forgot Password?",
|
"forgot-password": "Forgot Password?",
|
||||||
"or-continue-with": "Or continue with",
|
"or-continue-with": "Or continue with",
|
||||||
"logging-in": "Signing in...",
|
"logging-in": "Signing in...",
|
||||||
"registering": "Creating account..."
|
"registering": "Creating account...",
|
||||||
|
"login-success": "Login successful!",
|
||||||
|
"register-success": "Registration successful!"
|
||||||
},
|
},
|
||||||
"all-right-reserved": "All rights reserved.",
|
"all-right-reserved": "All rights reserved.",
|
||||||
"privacy-policy": "Privacy Policy",
|
"privacy-policy": "Privacy Policy",
|
||||||
@@ -342,7 +344,36 @@
|
|||||||
"risk-label": "Risk",
|
"risk-label": "Risk",
|
||||||
"data-quality-label": "Data Quality",
|
"data-quality-label": "Data Quality",
|
||||||
"rejected-matches-title": "Rejected Matches",
|
"rejected-matches-title": "Rejected Matches",
|
||||||
"no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest."
|
"no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest.",
|
||||||
|
"freq-engine-title": "Frequency Engine",
|
||||||
|
"freq-engine-subtitle": "Analyzes teams' historical performance by odds band. Uses statistical database scans instead of AI models.",
|
||||||
|
"freq-suggest": "Generate Frequency Coupon",
|
||||||
|
"freq-suggest-loading": "Running frequency analysis...",
|
||||||
|
"freq-min-signal": "Minimum Signal",
|
||||||
|
"freq-min-signal-help": "Combined signal threshold (0.50-0.99). Lower = more matches, higher = more precise results.",
|
||||||
|
"freq-markets": "Markets",
|
||||||
|
"freq-markets-help": "Select markets to analyze. Leave empty to scan all markets.",
|
||||||
|
"freq-ev-label": "Expected Value (EV)",
|
||||||
|
"freq-ev-help": "Hit Rate × Total Odds. Above 1.0 means +EV (profitable).",
|
||||||
|
"freq-hit-rate": "Est. Hit Rate",
|
||||||
|
"freq-hit-rate-help": "Combined historical hit rate of all bets in the coupon.",
|
||||||
|
"freq-ev-positive": "+EV Positive",
|
||||||
|
"freq-ev-negative": "EV Negative",
|
||||||
|
"freq-home-signal": "Home Signal",
|
||||||
|
"freq-away-signal": "Away Signal",
|
||||||
|
"freq-combined-signal": "Combined Signal",
|
||||||
|
"freq-odds-band": "Odds Band",
|
||||||
|
"freq-league-profile": "League Profile",
|
||||||
|
"freq-league-golcu": "High-Scoring",
|
||||||
|
"freq-league-defansif": "Defensive",
|
||||||
|
"freq-league-normal": "Normal",
|
||||||
|
"freq-match-count": "Past Matches",
|
||||||
|
"freq-reasoning-title": "Analysis Details",
|
||||||
|
"freq-no-result": "No matches found meeting frequency analysis criteria. Try lowering the signal threshold.",
|
||||||
|
"freq-mode-active": "Frequency Engine active",
|
||||||
|
"ai-mode-active": "AI Engine active",
|
||||||
|
"engine-mode-label": "Engine Mode",
|
||||||
|
"engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis."
|
||||||
},
|
},
|
||||||
|
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|||||||
+33
-2
@@ -34,7 +34,9 @@
|
|||||||
"forgot-password": "Şifremi Unuttum?",
|
"forgot-password": "Şifremi Unuttum?",
|
||||||
"or-continue-with": "Veya şununla devam edin",
|
"or-continue-with": "Veya şununla devam edin",
|
||||||
"logging-in": "Giriş yapılıyor...",
|
"logging-in": "Giriş yapılıyor...",
|
||||||
"registering": "Hesap oluşturuluyor..."
|
"registering": "Hesap oluşturuluyor...",
|
||||||
|
"login-success": "Giriş başarılı!",
|
||||||
|
"register-success": "Kayıt başarılı!"
|
||||||
},
|
},
|
||||||
"all-right-reserved": "Tüm hakları saklıdır.",
|
"all-right-reserved": "Tüm hakları saklıdır.",
|
||||||
"privacy-policy": "Gizlilik Politikası",
|
"privacy-policy": "Gizlilik Politikası",
|
||||||
@@ -331,7 +333,36 @@
|
|||||||
"risk-label": "Risk",
|
"risk-label": "Risk",
|
||||||
"data-quality-label": "Veri Kalitesi",
|
"data-quality-label": "Veri Kalitesi",
|
||||||
"rejected-matches-title": "Elenen Maçlar",
|
"rejected-matches-title": "Elenen Maçlar",
|
||||||
"no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın."
|
"no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın.",
|
||||||
|
"freq-engine-title": "Frekans Motoru",
|
||||||
|
"freq-engine-subtitle": "Takımların oran bandına göre tarihsel performansını analiz eder. AI modeli yerine istatistiksel veritabanı taraması kullanır.",
|
||||||
|
"freq-suggest": "Frekans Kuponu Oluştur",
|
||||||
|
"freq-suggest-loading": "Frekans analizi çalışıyor...",
|
||||||
|
"freq-min-signal": "Minimum Sinyal",
|
||||||
|
"freq-min-signal-help": "Kombinasyon sinyal eşiği (0.50-0.99). Düşürürseniz daha fazla maç bulunur, yükseltirseniz daha kesin sonuçlar gelir.",
|
||||||
|
"freq-markets": "Marketler",
|
||||||
|
"freq-markets-help": "Analiz edilecek marketleri seçin. Boş bırakırsanız tüm marketler taranır.",
|
||||||
|
"freq-ev-label": "Beklenen Değer (EV)",
|
||||||
|
"freq-ev-help": "Hit Rate × Toplam Oran. 1.0'ın üzeri +EV (karlı) anlamına gelir.",
|
||||||
|
"freq-hit-rate": "Tahmini İsabet",
|
||||||
|
"freq-hit-rate-help": "Tüm bahislerin birleşik tarihsel isabet oranı.",
|
||||||
|
"freq-ev-positive": "+EV Pozitif",
|
||||||
|
"freq-ev-negative": "EV Negatif",
|
||||||
|
"freq-home-signal": "Ev Sinyali",
|
||||||
|
"freq-away-signal": "Dep Sinyali",
|
||||||
|
"freq-combined-signal": "Kombine Sinyal",
|
||||||
|
"freq-odds-band": "Oran Bandı",
|
||||||
|
"freq-league-profile": "Lig Profili",
|
||||||
|
"freq-league-golcu": "Golcü",
|
||||||
|
"freq-league-defansif": "Defansif",
|
||||||
|
"freq-league-normal": "Normal",
|
||||||
|
"freq-match-count": "Geçmiş Maç",
|
||||||
|
"freq-reasoning-title": "Analiz Detayları",
|
||||||
|
"freq-no-result": "Frekans analizine uygun yeterli maç bulunamadı. Sinyal eşiğini düşürmeyi deneyin.",
|
||||||
|
"freq-mode-active": "Frekans Motoru aktif",
|
||||||
|
"ai-mode-active": "AI Motoru aktif",
|
||||||
|
"engine-mode-label": "Motor Seçimi",
|
||||||
|
"engine-mode-help": "AI: Gemini tabanlı yapay zeka tahmini. Frekans: Veritabanı tabanlı istatistiksel analiz."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
|
|||||||
Generated
+21
@@ -147,6 +147,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -515,6 +516,7 @@
|
|||||||
"version": "3.33.0",
|
"version": "3.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.33.0.tgz",
|
||||||
"integrity": "sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==",
|
"integrity": "sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ark-ui/react": "^5.31.0",
|
"@ark-ui/react": "^5.31.0",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
@@ -625,6 +627,7 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/memoize": "^0.9.0"
|
"@emotion/memoize": "^0.9.0"
|
||||||
}
|
}
|
||||||
@@ -638,6 +641,7 @@
|
|||||||
"version": "11.14.0",
|
"version": "11.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -1849,6 +1853,7 @@
|
|||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -2700,6 +2705,7 @@
|
|||||||
"version": "0.5.18",
|
"version": "0.5.18",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -2851,6 +2857,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -2906,6 +2913,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
||||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.0",
|
"@typescript-eslint/scope-manager": "8.56.0",
|
||||||
"@typescript-eslint/types": "8.56.0",
|
"@typescript-eslint/types": "8.56.0",
|
||||||
@@ -4240,6 +4248,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4576,6 +4585,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -4662,6 +4672,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5375,6 +5386,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5567,6 +5579,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -7438,6 +7451,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
||||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.0.0",
|
"@next/env": "16.0.0",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@@ -8076,6 +8090,7 @@
|
|||||||
"version": "10.28.3",
|
"version": "10.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -8210,6 +8225,7 @@
|
|||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8218,6 +8234,7 @@
|
|||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -8229,6 +8246,7 @@
|
|||||||
"version": "7.71.1",
|
"version": "7.71.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -9079,6 +9097,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -9246,6 +9265,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -9677,6 +9697,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
|
import { Box, Heading, Input, Text, VStack, HStack } from "@chakra-ui/react";
|
||||||
import { Button } from "@/components/ui/buttons/button";
|
import { Button } from "@/components/ui/buttons/button";
|
||||||
import { Field } from "@/components/ui/forms/field";
|
import { Field } from "@/components/ui/forms/field";
|
||||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||||
@@ -21,35 +21,60 @@ import { signIn } from "next-auth/react";
|
|||||||
import { toaster } from "@/components/ui/feedback/toaster";
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MdMail } from "react-icons/md";
|
import { MdMail } from "react-icons/md";
|
||||||
import { BiLock } from "react-icons/bi";
|
import { BiUser } from "react-icons/bi";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { authService } from "@/lib/api/auth/service";
|
||||||
|
|
||||||
const schema = yup.object({
|
/* ────────────────────────── Schemas ────────────────────────── */
|
||||||
|
|
||||||
|
const loginSchema = yup.object({
|
||||||
email: yup.string().email().required(),
|
email: yup.string().email().required(),
|
||||||
password: yup.string().min(6).required(),
|
password: yup.string().min(6).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginForm = yup.InferType<typeof schema>;
|
const registerSchema = yup.object({
|
||||||
|
name: yup.string().required(),
|
||||||
|
email: yup.string().email().required(),
|
||||||
|
password: yup.string().min(8).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = yup.InferType<typeof loginSchema>;
|
||||||
|
type RegisterForm = yup.InferType<typeof registerSchema>;
|
||||||
|
|
||||||
|
/* ────────────────────────── Props ────────────────────────── */
|
||||||
|
|
||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────── Component ────────────────────────── */
|
||||||
|
|
||||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const {
|
/* ── Login form ── */
|
||||||
handleSubmit,
|
const loginForm = useForm<LoginForm>({
|
||||||
register,
|
resolver: yupResolver(loginSchema),
|
||||||
formState: { errors },
|
|
||||||
} = useForm<LoginForm>({
|
|
||||||
resolver: yupResolver(schema),
|
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (formData: LoginForm) => {
|
/* ── Register form ── */
|
||||||
|
const registerForm = useForm<RegisterForm>({
|
||||||
|
resolver: yupResolver(registerSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Switch mode ── */
|
||||||
|
const switchMode = (newMode: "login" | "register") => {
|
||||||
|
setMode(newMode);
|
||||||
|
loginForm.reset();
|
||||||
|
registerForm.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Handle login ── */
|
||||||
|
const onLogin = async (formData: LoginForm) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await signIn("credentials", {
|
const res = await signIn("credentials", {
|
||||||
@@ -64,12 +89,49 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
toaster.success({
|
toaster.success({
|
||||||
title: t("auth.login-success") || "Login successful!",
|
title: t("auth.login-success") || "Giriş başarılı!",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toaster.error({
|
toaster.error({
|
||||||
title: (error as Error).message || "Login failed!",
|
title: (error as Error).message || "Giriş yapılamadı!",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Handle register ── */
|
||||||
|
const onRegister = async (formData: RegisterForm) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await authService.register({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
firstName: formData.name,
|
||||||
|
lastName: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
toaster.success({
|
||||||
|
title: t("auth.register-success") || "Kayıt başarılı!",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toaster.error({
|
||||||
|
title: (error as Error).message || "Kayıt yapılamadı!",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,23 +140,67 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
<DialogRoot
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(e) => {
|
||||||
|
onOpenChange(e.open);
|
||||||
|
if (!e.open) {
|
||||||
|
// Reset to login when closing
|
||||||
|
setMode("login");
|
||||||
|
loginForm.reset();
|
||||||
|
registerForm.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Heading size="lg" color="primary.500">
|
<Heading size="lg" color="primary.500">
|
||||||
{t("auth.sign-in")}
|
{mode === "login" ? t("auth.sign-in") : t("auth.sign-up")}
|
||||||
</Heading>
|
</Heading>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogCloseTrigger />
|
<DialogCloseTrigger />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
|
{/* ────── Tab Switcher ────── */}
|
||||||
|
<HStack
|
||||||
|
gap={0}
|
||||||
|
mb={5}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={{ base: "gray.200", _dark: "gray.700" }}
|
||||||
|
borderRadius="xl"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "login" ? "solid" : "ghost"}
|
||||||
|
colorPalette={mode === "login" ? "primary" : "gray"}
|
||||||
|
borderRadius="0"
|
||||||
|
onClick={() => switchMode("login")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "register" ? "solid" : "ghost"}
|
||||||
|
colorPalette={mode === "register" ? "primary" : "gray"}
|
||||||
|
borderRadius="0"
|
||||||
|
onClick={() => switchMode("register")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* ────── LOGIN FORM ────── */}
|
||||||
|
{mode === "login" && (
|
||||||
|
<Box as="form" onSubmit={loginForm.handleSubmit(onLogin)}>
|
||||||
<VStack gap={4}>
|
<VStack gap={4}>
|
||||||
<Field
|
<Field
|
||||||
label={t("email")}
|
label={t("email")}
|
||||||
errorText={errors.email?.message}
|
errorText={loginForm.formState.errors.email?.message}
|
||||||
invalid={!!errors.email}
|
invalid={!!loginForm.formState.errors.email}
|
||||||
>
|
>
|
||||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||||
<Input
|
<Input
|
||||||
@@ -102,24 +208,23 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("email")}
|
placeholder={t("email")}
|
||||||
{...register("email")}
|
{...loginForm.register("email")}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("password")}
|
label={t("password")}
|
||||||
errorText={errors.password?.message}
|
errorText={loginForm.formState.errors.password?.message}
|
||||||
invalid={!!errors.password}
|
invalid={!!loginForm.formState.errors.password}
|
||||||
>
|
>
|
||||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
|
rootProps={{ w: "full" }}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
placeholder={t("password")}
|
placeholder={t("password")}
|
||||||
{...register("password")}
|
{...loginForm.register("password")}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -135,18 +240,98 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
|
|
||||||
<Text fontSize="sm" color="fg.muted">
|
<Text fontSize="sm" color="fg.muted">
|
||||||
{t("auth.dont-have-account")}{" "}
|
{t("auth.dont-have-account")}{" "}
|
||||||
<Link
|
<Text
|
||||||
href="/signup"
|
as="span"
|
||||||
style={{
|
color="primary.500"
|
||||||
color: "var(--chakra-colors-primary-500)",
|
fontWeight="bold"
|
||||||
fontWeight: "bold",
|
cursor="pointer"
|
||||||
}}
|
_hover={{ textDecoration: "underline" }}
|
||||||
|
onClick={() => switchMode("register")}
|
||||||
>
|
>
|
||||||
{t("auth.sign-up")}
|
{t("auth.sign-up")}
|
||||||
</Link>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ────── REGISTER FORM ────── */}
|
||||||
|
{mode === "register" && (
|
||||||
|
<Box as="form" onSubmit={registerForm.handleSubmit(onRegister)}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Field
|
||||||
|
label={t("name")}
|
||||||
|
errorText={registerForm.formState.errors.name?.message}
|
||||||
|
invalid={!!registerForm.formState.errors.name}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
|
||||||
|
<Input
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("name")}
|
||||||
|
{...registerForm.register("name")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("email")}
|
||||||
|
errorText={registerForm.formState.errors.email?.message}
|
||||||
|
invalid={!!registerForm.formState.errors.email}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||||
|
<Input
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("email")}
|
||||||
|
{...registerForm.register("email")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("password")}
|
||||||
|
errorText={registerForm.formState.errors.password?.message}
|
||||||
|
invalid={!!registerForm.formState.errors.password}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
rootProps={{ w: "full" }}
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
placeholder={t("password")}
|
||||||
|
{...registerForm.register("password")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={loading}
|
||||||
|
type="submit"
|
||||||
|
bg="primary.400"
|
||||||
|
w="100%"
|
||||||
|
color="white"
|
||||||
|
_hover={{ bg: "primary.500" }}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{t("auth.already-have-an-account")}{" "}
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
color="primary.500"
|
||||||
|
fontWeight="bold"
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{ textDecoration: "underline" }}
|
||||||
|
onClick={() => switchMode("login")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
LuBadgeAlert,
|
LuBadgeAlert,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuCircleHelp,
|
LuCircleHelp,
|
||||||
|
LuDatabase,
|
||||||
LuEye,
|
LuEye,
|
||||||
LuEyeOff,
|
LuEyeOff,
|
||||||
LuLayers3,
|
LuLayers3,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
import { SlideUp } from "@/components/motion";
|
import { SlideUp } from "@/components/motion";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||||
|
import FrequencyPanel from "@/components/coupons/frequency-panel";
|
||||||
import { useSuggestCoupon } from "@/lib/api/coupons/use-hooks";
|
import { useSuggestCoupon } from "@/lib/api/coupons/use-hooks";
|
||||||
import type {
|
import type {
|
||||||
CouponItemDto,
|
CouponItemDto,
|
||||||
@@ -352,6 +354,7 @@ export default function CouponBuilderContent() {
|
|||||||
SmartCouponResultDto | undefined
|
SmartCouponResultDto | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [matchCount, setMatchCount] = React.useState<number>(5); // Default: 5 matches
|
const [matchCount, setMatchCount] = React.useState<number>(5); // Default: 5 matches
|
||||||
|
const [engineMode, setEngineMode] = React.useState<"ai" | "frequency">("ai");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!upcomingQuery.data && !upcomingQuery.isPending) {
|
if (!upcomingQuery.data && !upcomingQuery.isPending) {
|
||||||
@@ -763,6 +766,42 @@ export default function CouponBuilderContent() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body pt={0}>
|
<Card.Body pt={0}>
|
||||||
|
{/* Engine Mode Toggle */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={engineMode === "ai" ? LuSparkles : LuDatabase} color={engineMode === "ai" ? "teal.500" : "cyan.500"} />
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">{t("engine-mode-label")}</Text>
|
||||||
|
<InfoIcon content={t("engine-mode-help")} label={t("engine-mode-label")} />
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Badge
|
||||||
|
colorPalette={engineMode === "ai" ? "teal" : "gray"}
|
||||||
|
variant={engineMode === "ai" ? "solid" : "outline"}
|
||||||
|
cursor="pointer" px={3} py={1}
|
||||||
|
onClick={() => setEngineMode("ai")}
|
||||||
|
>
|
||||||
|
<LuSparkles /> AI
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
colorPalette={engineMode === "frequency" ? "cyan" : "gray"}
|
||||||
|
variant={engineMode === "frequency" ? "solid" : "outline"}
|
||||||
|
cursor="pointer" px={3} py={1}
|
||||||
|
onClick={() => setEngineMode("frequency")}
|
||||||
|
>
|
||||||
|
<LuDatabase /> Frekans
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={engineMode === "ai" ? "teal.500" : "cyan.500"}>
|
||||||
|
{engineMode === "ai" ? t("ai-mode-active") : t("freq-mode-active")}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Separator mb={4} />
|
||||||
|
|
||||||
|
{engineMode === "frequency" ? (
|
||||||
|
<FrequencyPanel />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Text
|
<Text
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
color="fg.muted"
|
color="fg.muted"
|
||||||
@@ -920,6 +959,8 @@ export default function CouponBuilderContent() {
|
|||||||
? t("manual-selection-helper")
|
? t("manual-selection-helper")
|
||||||
: t("automatic-selection-helper")}
|
: t("automatic-selection-helper")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Separator,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LuBadgeAlert,
|
||||||
|
LuChartBar,
|
||||||
|
LuCircleHelp,
|
||||||
|
LuDatabase,
|
||||||
|
LuTrendingUp,
|
||||||
|
LuZap,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||||
|
import { useGenerateFrequencyCoupon } from "@/lib/api/coupons/use-hooks";
|
||||||
|
import type {
|
||||||
|
FrequencyCouponResultDto,
|
||||||
|
FrequencyCouponBetDto,
|
||||||
|
} from "@/lib/api/coupons/types";
|
||||||
|
import { ApiError } from "@/lib/api/create-api-client";
|
||||||
|
import { useCouponStore } from "@/lib/stores/coupon-store";
|
||||||
|
|
||||||
|
const AVAILABLE_MARKETS = ["OU1.5", "OU2.5", "OU3.5", "BTTS", "MS"];
|
||||||
|
|
||||||
|
function InfoIcon({ content, label }: { content: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={content}
|
||||||
|
showArrow
|
||||||
|
positioning={{ placement: "top" }}
|
||||||
|
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label={label}
|
||||||
|
variant="ghost"
|
||||||
|
size="2xs"
|
||||||
|
colorPalette="gray"
|
||||||
|
>
|
||||||
|
<LuCircleHelp />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileColor = (p: string) =>
|
||||||
|
({ GOLCU: "red", DEFANSIF: "blue", NORMAL: "gray" })[p] || "gray";
|
||||||
|
|
||||||
|
const profileLabel = (p: string, t: ReturnType<typeof useTranslations>) =>
|
||||||
|
({
|
||||||
|
GOLCU: t("freq-league-golcu"),
|
||||||
|
DEFANSIF: t("freq-league-defansif"),
|
||||||
|
NORMAL: t("freq-league-normal"),
|
||||||
|
})[p] || p;
|
||||||
|
|
||||||
|
export default function FrequencyPanel() {
|
||||||
|
const t = useTranslations("coupons");
|
||||||
|
const { addItem, clearCoupon } = useCouponStore();
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const mutedBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
|
||||||
|
const freqMutation = useGenerateFrequencyCoupon();
|
||||||
|
|
||||||
|
const [minSignal, setMinSignal] = React.useState(0.65);
|
||||||
|
const [maxMatches, setMaxMatches] = React.useState(3);
|
||||||
|
const [selectedMarkets, setSelectedMarkets] = React.useState<string[]>([]);
|
||||||
|
const [result, setResult] = React.useState<
|
||||||
|
FrequencyCouponResultDto | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const toggleMarket = (m: string) =>
|
||||||
|
setSelectedMarkets((prev) =>
|
||||||
|
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
freqMutation.mutate(
|
||||||
|
{
|
||||||
|
maxMatches,
|
||||||
|
minSignal,
|
||||||
|
markets: selectedMarkets.length > 0 ? selectedMarkets : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const data = (response as any)?.data ?? response;
|
||||||
|
setResult(data as FrequencyCouponResultDto);
|
||||||
|
// Sync to coupon store
|
||||||
|
if (data && Array.isArray((data as any).bets)) {
|
||||||
|
clearCoupon();
|
||||||
|
(data as FrequencyCouponResultDto).bets.forEach(
|
||||||
|
(bet: FrequencyCouponBetDto) =>
|
||||||
|
addItem({
|
||||||
|
matchId: bet.match_id,
|
||||||
|
matchName: bet.match_name,
|
||||||
|
market: bet.market,
|
||||||
|
pick: bet.pick,
|
||||||
|
odd: bet.odds,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => setResult(undefined),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
freqMutation.error instanceof ApiError
|
||||||
|
? freqMutation.error.message
|
||||||
|
: freqMutation.error instanceof Error
|
||||||
|
? freqMutation.error.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
{/* Controls Card */}
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Header>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuDatabase} color="cyan.500" boxSize={4.5} />
|
||||||
|
<Heading as="h3" size="sm">
|
||||||
|
{t("freq-engine-title")}
|
||||||
|
</Heading>
|
||||||
|
<InfoIcon
|
||||||
|
label={t("freq-engine-title")}
|
||||||
|
content={t("freq-engine-subtitle")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="fg.muted" mt={1}>
|
||||||
|
{t("freq-engine-subtitle")}
|
||||||
|
</Text>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
{/* Min Signal Slider */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuTrendingUp} color="cyan.500" />
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("freq-min-signal")}
|
||||||
|
</Text>
|
||||||
|
<InfoIcon
|
||||||
|
content={t("freq-min-signal-help")}
|
||||||
|
label={t("freq-min-signal")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Badge colorPalette="cyan" variant="subtle">
|
||||||
|
{(minSignal * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="50"
|
||||||
|
max="95"
|
||||||
|
value={minSignal * 100}
|
||||||
|
onChange={(e) => setMinSignal(Number(e.target.value) / 100)}
|
||||||
|
style={{ width: "100%", accentColor: "#0891b2", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
||||||
|
<Text>50%</Text>
|
||||||
|
<Text>95%</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Max Matches */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuChartBar} color="purple.500" />
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("match-count-label")}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge colorPalette="purple" variant="subtle">
|
||||||
|
{maxMatches}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="5"
|
||||||
|
value={maxMatches}
|
||||||
|
onChange={(e) => setMaxMatches(Number(e.target.value))}
|
||||||
|
style={{ width: "100%", accentColor: "#9333ea", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
||||||
|
<Text>2</Text>
|
||||||
|
<Text>5</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Separator mb={4} />
|
||||||
|
|
||||||
|
{/* Market Filter */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("freq-markets")}
|
||||||
|
</Text>
|
||||||
|
<InfoIcon
|
||||||
|
content={t("freq-markets-help")}
|
||||||
|
label={t("freq-markets")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={2} flexWrap="wrap">
|
||||||
|
{AVAILABLE_MARKETS.map((m) => {
|
||||||
|
const active = selectedMarkets.includes(m);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
colorPalette={active ? "cyan" : "gray"}
|
||||||
|
variant={active ? "solid" : "outline"}
|
||||||
|
cursor="pointer"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
onClick={() => toggleMarket(m)}
|
||||||
|
_hover={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
{selectedMarkets.length === 0 && (
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Tüm marketler taranacak
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
colorPalette="cyan"
|
||||||
|
size="lg"
|
||||||
|
width="full"
|
||||||
|
borderRadius="xl"
|
||||||
|
loading={freqMutation.isPending}
|
||||||
|
loadingText={t("freq-suggest-loading")}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
>
|
||||||
|
<LuZap />
|
||||||
|
{t("freq-suggest")}
|
||||||
|
</Button>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Results Card */}
|
||||||
|
{result && result.bets.length > 0 && (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Header>
|
||||||
|
<HStack justify="space-between" align="center">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuZap} color="cyan.500" boxSize={4.5} />
|
||||||
|
<Heading as="h3" size="sm">
|
||||||
|
{t("freq-engine-title")}
|
||||||
|
</Heading>
|
||||||
|
</HStack>
|
||||||
|
<Badge
|
||||||
|
colorPalette={result.ev_positive ? "green" : "red"}
|
||||||
|
variant="solid"
|
||||||
|
>
|
||||||
|
{result.ev_positive
|
||||||
|
? t("freq-ev-positive")
|
||||||
|
: t("freq-ev-negative")}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
<VStack align="stretch" gap={3}>
|
||||||
|
{/* EV Stats */}
|
||||||
|
<Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={3}>
|
||||||
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-ev-label")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="lg"
|
||||||
|
color={result.ev_positive ? "green.500" : "red.500"}
|
||||||
|
>
|
||||||
|
{result.expected_value.toFixed(3)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-hit-rate")}
|
||||||
|
</Text>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" color="cyan.500">
|
||||||
|
{(result.expected_hit_rate * 100).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("total-odds")}
|
||||||
|
</Text>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" color="purple.500">
|
||||||
|
{result.total_odds.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Bets */}
|
||||||
|
{result.bets.map((bet: FrequencyCouponBetDto) => (
|
||||||
|
<Box
|
||||||
|
key={`${bet.match_id}-${bet.market}`}
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
bg={mutedBg}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="flex-start" gap={3} mb={3}>
|
||||||
|
<VStack align="flex-start" gap={1}>
|
||||||
|
<Text fontWeight="bold">{bet.match_name}</Text>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{bet.league} • {bet.market}: {bet.pick}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Badge colorPalette="cyan" variant="solid">
|
||||||
|
{bet.odds.toFixed(2)}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
<Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={2}>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-home-signal")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
{(bet.home_signal * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{bet.home_odds_band}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-away-signal")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
{(bet.away_signal * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{bet.away_odds_band}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-combined-signal")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="cyan.500">
|
||||||
|
{(bet.combined_signal * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<HStack gap={2} mt={3} flexWrap="wrap">
|
||||||
|
<Badge
|
||||||
|
colorPalette={profileColor(bet.league_profile)}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t("freq-league-profile")}:{" "}
|
||||||
|
{profileLabel(bet.league_profile, t)}
|
||||||
|
</Badge>
|
||||||
|
<Badge colorPalette="gray" variant="subtle">
|
||||||
|
{t("freq-match-count")}: {bet.home_match_count}/
|
||||||
|
{bet.away_match_count}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Reasoning */}
|
||||||
|
{result.reasoning.length > 0 && (
|
||||||
|
<Box p={4} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontWeight="semibold" fontSize="sm" mb={2}>
|
||||||
|
{t("freq-reasoning-title")}
|
||||||
|
</Text>
|
||||||
|
<VStack align="stretch" gap={1}>
|
||||||
|
{result.reasoning.map((r, i) => (
|
||||||
|
<Text key={i} fontSize="xs" color="fg.muted">
|
||||||
|
• {r}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rejected */}
|
||||||
|
{result.rejected_matches.length > 0 && (
|
||||||
|
<Box p={4} bg="orange.50" borderRadius="xl">
|
||||||
|
<HStack gap={2} mb={2}>
|
||||||
|
<Icon as={LuBadgeAlert} color="orange.500" />
|
||||||
|
<Text fontWeight="semibold">{t("rejected-matches-title")}</Text>
|
||||||
|
</HStack>
|
||||||
|
<VStack align="stretch" gap={1}>
|
||||||
|
{result.rejected_matches.map((entry, i) => (
|
||||||
|
<Text key={i} fontSize="sm" color="fg.muted">
|
||||||
|
{entry.match_name}: {entry.reason}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No result message */}
|
||||||
|
{result && result.bets.length === 0 && (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Body py={8}>
|
||||||
|
<Text textAlign="center" color="fg.muted">
|
||||||
|
{t("freq-no-result")}
|
||||||
|
</Text>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{errorMessage && (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg="red.50"
|
||||||
|
borderRadius="xl"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="red.200"
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="red.700">
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ import type {
|
|||||||
MatchPickDto,
|
MatchPickDto,
|
||||||
MatchPredictionDto,
|
MatchPredictionDto,
|
||||||
SignalTier,
|
SignalTier,
|
||||||
|
V27EngineDto,
|
||||||
} from "@/lib/api/predictions/types";
|
} from "@/lib/api/predictions/types";
|
||||||
import type { SportType } from "@/lib/api/matches/types";
|
import type { SportType } from "@/lib/api/matches/types";
|
||||||
|
import V28OddsBandPanel from "@/components/matches/v28-odds-band-panel";
|
||||||
|
|
||||||
interface PredictionCardProps {
|
interface PredictionCardProps {
|
||||||
prediction: MatchPredictionDto;
|
prediction: MatchPredictionDto;
|
||||||
@@ -1087,6 +1089,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
|||||||
info={uiText("market-board-info", "Modelin her markette gordugu olasilik dagilimi.")}
|
info={uiText("market-board-info", "Modelin her markette gordugu olasilik dagilimi.")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{prediction.v27_engine ? (
|
||||||
|
<V28OddsBandPanel engine={prediction.v27_engine as V27EngineDto} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
<Card.Body gap={4}>
|
<Card.Body gap={4}>
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
|
|||||||
@@ -0,0 +1,669 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import {
|
||||||
|
LuBadgeCheck,
|
||||||
|
LuCircleHelp,
|
||||||
|
LuRectangleVertical,
|
||||||
|
LuShieldAlert,
|
||||||
|
LuTarget,
|
||||||
|
LuTrendingUp,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||||
|
import type {
|
||||||
|
HtftComboKey,
|
||||||
|
OddsBandCardsDto,
|
||||||
|
OddsBandHtftComboDto,
|
||||||
|
TripleValueEntryDto,
|
||||||
|
V27EngineDto,
|
||||||
|
} from "@/lib/api/predictions/types";
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function pct(v: number, d = 0): string {
|
||||||
|
if (!v && v !== 0) return "-";
|
||||||
|
return `${(v * 100).toFixed(d)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeStr(edge: number): string {
|
||||||
|
const sign = edge > 0 ? "+" : "";
|
||||||
|
return `${sign}${(edge * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIPLE_VALUE_LABELS: Record<string, string> = {
|
||||||
|
home: "MS Ev",
|
||||||
|
away: "MS Dep",
|
||||||
|
ou25_over: "ÜST 2.5",
|
||||||
|
btts_yes: "KG Var",
|
||||||
|
ou15_over: "ÜST 1.5",
|
||||||
|
ou35_over: "ÜST 3.5",
|
||||||
|
dc_1x: "ÇŞ 1X",
|
||||||
|
dc_x2: "ÇŞ X2",
|
||||||
|
dc_12: "ÇŞ 12",
|
||||||
|
ht_home: "İY Ev",
|
||||||
|
ht_away: "İY Dep",
|
||||||
|
ht_ou05_over: "İY ÜST 0.5",
|
||||||
|
ht_ou15_over: "İY ÜST 1.5",
|
||||||
|
oe_odd: "Tek",
|
||||||
|
cards_over: "Kart ÜST",
|
||||||
|
htft_11: "İY/MS 1/1",
|
||||||
|
htft_1x: "İY/MS 1/X",
|
||||||
|
htft_12: "İY/MS 1/2",
|
||||||
|
htft_x1: "İY/MS X/1",
|
||||||
|
htft_xx: "İY/MS X/X",
|
||||||
|
htft_x2: "İY/MS X/2",
|
||||||
|
htft_21: "İY/MS 2/1",
|
||||||
|
htft_2x: "İY/MS 2/X",
|
||||||
|
htft_22: "İY/MS 2/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTFT_DISPLAY: Record<HtftComboKey, string> = {
|
||||||
|
"11": "1/1",
|
||||||
|
"1x": "1/X",
|
||||||
|
"12": "1/2",
|
||||||
|
x1: "X/1",
|
||||||
|
xx: "X/X",
|
||||||
|
x2: "X/2",
|
||||||
|
"21": "2/1",
|
||||||
|
"2x": "2/X",
|
||||||
|
"22": "2/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTFT_ROWS: HtftComboKey[][] = [
|
||||||
|
["11", "1x", "12"],
|
||||||
|
["x1", "xx", "x2"],
|
||||||
|
["21", "2x", "22"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function TooltipIcon({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={content}
|
||||||
|
showArrow
|
||||||
|
positioning={{ placement: "top" }}
|
||||||
|
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Bilgi"
|
||||||
|
variant="ghost"
|
||||||
|
size="2xs"
|
||||||
|
colorPalette="gray"
|
||||||
|
>
|
||||||
|
<LuCircleHelp />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
info,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
info?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={icon} boxSize={4.5} color="fg.muted" />
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{info ? <TooltipIcon content={info} /> : null}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Triple Value Card
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function TripleValueCard({
|
||||||
|
label,
|
||||||
|
entry,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
entry: TripleValueEntryDto;
|
||||||
|
}) {
|
||||||
|
const isValue = entry.is_value;
|
||||||
|
const hasSample = entry.band_sample >= 5;
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue(
|
||||||
|
isValue ? "green.50" : "gray.50",
|
||||||
|
isValue ? "green.950" : "whiteAlpha.50",
|
||||||
|
);
|
||||||
|
const borderCol = useColorModeValue(
|
||||||
|
isValue ? "green.300" : "gray.200",
|
||||||
|
isValue ? "green.700" : "gray.700",
|
||||||
|
);
|
||||||
|
const edgeColor = entry.edge > 0.03 ? "green.500" : entry.edge < -0.03 ? "red.400" : "fg.muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderCol}
|
||||||
|
borderRadius="xl"
|
||||||
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{isValue && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
h="3px"
|
||||||
|
bgGradient="to-r"
|
||||||
|
gradientFrom="green.400"
|
||||||
|
gradientTo="teal.400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<VStack align="stretch" gap={1.5}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Text fontSize="xs" fontWeight="semibold" color="fg.muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{isValue ? (
|
||||||
|
<Badge
|
||||||
|
colorPalette="green"
|
||||||
|
variant="solid"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
DEĞER
|
||||||
|
</Badge>
|
||||||
|
) : hasSample ? (
|
||||||
|
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||||||
|
PAS
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
colorPalette="gray"
|
||||||
|
variant="subtle"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
YETERSİZ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="xl" fontWeight="bold" color={edgeColor}>
|
||||||
|
{edgeStr(entry.edge)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<HStack gap={2} flexWrap="wrap">
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Band: {pct(entry.band_rate, 1)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Oran: {pct(entry.implied_prob, 1)}
|
||||||
|
</Text>
|
||||||
|
{entry.confirmations !== undefined && (
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Onay: {entry.confirmations}/2
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{entry.band_sample} maç
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Cards Section
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
value,
|
||||||
|
max,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const trackBg = useColorModeValue("gray.100", "gray.700");
|
||||||
|
const w = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
h="10px"
|
||||||
|
w="full"
|
||||||
|
bg={trackBg}
|
||||||
|
borderRadius="full"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
h="full"
|
||||||
|
w={`${w}%`}
|
||||||
|
bg={color}
|
||||||
|
borderRadius="full"
|
||||||
|
transition="width 0.4s ease"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardsSection({ cards }: { cards: OddsBandCardsDto }) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
const hasData = cards.sample >= 3;
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<HStack gap={2} mb={2}>
|
||||||
|
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
Kart Analizi
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
Yetersiz veri — henüz yeterli maç örneği bulunamadı.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overPct = cards.combined_over_rate * 100;
|
||||||
|
const overColor =
|
||||||
|
overPct >= 65 ? "red.400" : overPct >= 50 ? "orange.400" : "green.400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
Kart Analizi
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||||||
|
{cards.sample} maç
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="stretch" gap={3}>
|
||||||
|
{/* Referee profile */}
|
||||||
|
<Box>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Hakem Profili
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
Ort: {cards.referee_avg.toFixed(1)} kart
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<ProgressBar
|
||||||
|
value={cards.referee_over_rate * 100}
|
||||||
|
max={100}
|
||||||
|
color="purple.400"
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" mt={0.5}>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Üst oranı: {pct(cards.referee_over_rate, 0)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{cards.referee_sample} maç
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Team profile */}
|
||||||
|
<Box>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Takım Profili
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
Ort: {cards.team_avg.toFixed(1)} kart
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<ProgressBar
|
||||||
|
value={cards.team_over_rate * 100}
|
||||||
|
max={100}
|
||||||
|
color="blue.400"
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" mt={0.5}>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Üst oranı: {pct(cards.team_over_rate, 0)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{cards.team_sample} maç
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Combined */}
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<VStack align="start" gap={0}>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
Kombine ÜST Oranı
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
%60 Hakem + %40 Takım ağırlıklı
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color={overColor}>
|
||||||
|
{overPct.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// HTFT 3x3 Grid
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function HtftGrid({
|
||||||
|
htft,
|
||||||
|
}: {
|
||||||
|
htft: Record<HtftComboKey, OddsBandHtftComboDto>;
|
||||||
|
}) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
|
||||||
|
// Find the max rate for highlighting
|
||||||
|
let maxRate = 0;
|
||||||
|
let maxKey: HtftComboKey = "11";
|
||||||
|
let totalSample = 0;
|
||||||
|
for (const [key, val] of Object.entries(htft) as [
|
||||||
|
HtftComboKey,
|
||||||
|
OddsBandHtftComboDto,
|
||||||
|
][]) {
|
||||||
|
if (val.rate > maxRate) {
|
||||||
|
maxRate = val.rate;
|
||||||
|
maxKey = key;
|
||||||
|
}
|
||||||
|
totalSample = Math.max(totalSample, val.sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellColor = (rate: number, isMax: boolean) => {
|
||||||
|
if (isMax) return { bg: "green.500", text: "white" };
|
||||||
|
if (rate >= 0.2) return { bg: "green.100", text: "green.800" };
|
||||||
|
if (rate >= 0.12) return { bg: "yellow.100", text: "yellow.800" };
|
||||||
|
if (rate >= 0.06) return { bg: "orange.50", text: "orange.700" };
|
||||||
|
return { bg: "gray.50", text: "gray.500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellColorDark = (rate: number, isMax: boolean) => {
|
||||||
|
if (isMax) return { bg: "green.600", text: "white" };
|
||||||
|
if (rate >= 0.2) return { bg: "green.900", text: "green.200" };
|
||||||
|
if (rate >= 0.12) return { bg: "yellow.900", text: "yellow.200" };
|
||||||
|
if (rate >= 0.06) return { bg: "orange.900", text: "orange.200" };
|
||||||
|
return { bg: "whiteAlpha.50", text: "gray.500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightMode = useColorModeValue(true, false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuTarget} boxSize={4} color="teal.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
İY/MS Kombinasyonları
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<TooltipIcon content="İlk yarı sonucu ve maç sonucu kombinasyonlarının tarihsel oran bandındaki gerçekleşme oranları." />
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}>
|
||||||
|
<Box />
|
||||||
|
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||||
|
MS 1
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||||
|
MS X
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||||
|
MS 2
|
||||||
|
</Text>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Grid rows */}
|
||||||
|
{HTFT_ROWS.map((row, rowIdx) => {
|
||||||
|
const rowLabels = ["İY 1", "İY X", "İY 2"];
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
key={rowIdx}
|
||||||
|
templateColumns="50px repeat(3, 1fr)"
|
||||||
|
gap={1.5}
|
||||||
|
mb={1.5}
|
||||||
|
>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Text fontSize="2xs" fontWeight="bold" color="fg.muted">
|
||||||
|
{rowLabels[rowIdx]}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{row.map((comboKey) => {
|
||||||
|
const data = htft[comboKey] || { rate: 0, sample: 0 };
|
||||||
|
const isMax = comboKey === maxKey && maxRate > 0.05;
|
||||||
|
const colors = lightMode
|
||||||
|
? getCellColor(data.rate, isMax)
|
||||||
|
: getCellColorDark(data.rate, isMax);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={comboKey}
|
||||||
|
py={2.5}
|
||||||
|
px={2}
|
||||||
|
bg={colors.bg}
|
||||||
|
borderRadius="lg"
|
||||||
|
textAlign="center"
|
||||||
|
position="relative"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ transform: "scale(1.04)" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={colors.text}
|
||||||
|
mb={0.5}
|
||||||
|
>
|
||||||
|
{HTFT_DISPLAY[comboKey]}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="extrabold"
|
||||||
|
color={colors.text}
|
||||||
|
>
|
||||||
|
{pct(data.rate, 0)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
color={isMax ? "whiteAlpha.800" : "fg.muted"}
|
||||||
|
>
|
||||||
|
{data.sample} maç
|
||||||
|
</Text>
|
||||||
|
{isMax && (
|
||||||
|
<Icon
|
||||||
|
as={LuBadgeCheck}
|
||||||
|
position="absolute"
|
||||||
|
top={1}
|
||||||
|
right={1}
|
||||||
|
boxSize={3.5}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Best combo callout */}
|
||||||
|
{maxRate > 0.05 && (
|
||||||
|
<Box
|
||||||
|
mt={2}
|
||||||
|
p={2.5}
|
||||||
|
bg={useColorModeValue("green.50", "green.950")}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuTrendingUp} boxSize={4} color="green.500" />
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
En güçlü:{" "}
|
||||||
|
<Text as="span" color="green.500">
|
||||||
|
{HTFT_DISPLAY[maxKey]} ({pct(maxRate, 0)})
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Main Panel Export
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface V28OddsBandPanelProps {
|
||||||
|
engine: V27EngineDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
|
||||||
|
const tripleValue = engine.triple_value;
|
||||||
|
const cards = engine.odds_band?.cards as OddsBandCardsDto | undefined;
|
||||||
|
const htft = engine.odds_band?.htft as
|
||||||
|
| Record<HtftComboKey, OddsBandHtftComboDto>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Filter out HTFT triple-value entries from the main grid (shown in HTFT section)
|
||||||
|
const mainValueEntries = Object.entries(tripleValue || {}).filter(
|
||||||
|
([key]) => !key.startsWith("htft_"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate value hits from non-hits for priority ordering
|
||||||
|
const valueHits = mainValueEntries.filter(([, e]) => e.is_value);
|
||||||
|
const valueNon = mainValueEntries.filter(([, e]) => !e.is_value);
|
||||||
|
const orderedEntries = [...valueHits, ...valueNon];
|
||||||
|
|
||||||
|
const hasTriple = orderedEntries.length > 0;
|
||||||
|
const hasCards = cards && cards.sample >= 1;
|
||||||
|
const hasHtft = htft && Object.values(htft).some((v) => v.sample > 0);
|
||||||
|
|
||||||
|
if (!hasTriple && !hasCards && !hasHtft) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Body gap={5}>
|
||||||
|
<SectionTitle
|
||||||
|
icon={LuShieldAlert}
|
||||||
|
title="V28 Oran Bandı Analizi"
|
||||||
|
info="Geçmiş maçlarda benzer oranlarda gerçekleşen sonuçların istatistiksel analizi. Triple Value, Kart Profili ve İY/MS kombinasyonlarını içerir."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Engine version badge */}
|
||||||
|
<HStack>
|
||||||
|
<Badge colorPalette="purple" variant="subtle" borderRadius="full" fontSize="2xs">
|
||||||
|
{engine.version}
|
||||||
|
</Badge>
|
||||||
|
{engine.consensus && (
|
||||||
|
<Badge
|
||||||
|
colorPalette={engine.consensus === "AGREE" ? "green" : "orange"}
|
||||||
|
variant="solid"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="2xs"
|
||||||
|
>
|
||||||
|
{engine.consensus === "AGREE" ? "Motorlar Uyumlu" : "Motorlar Farklı"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{valueHits.length > 0 && (
|
||||||
|
<Badge colorPalette="green" variant="outline" borderRadius="full" fontSize="2xs">
|
||||||
|
{valueHits.length} Değer Sinyali
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Triple Value Grid */}
|
||||||
|
{hasTriple && (
|
||||||
|
<Box>
|
||||||
|
<HStack mb={3} gap={2}>
|
||||||
|
<Icon as={LuTarget} boxSize={4} color="blue.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
Değer Tespiti (Triple Value)
|
||||||
|
</Text>
|
||||||
|
<TooltipIcon content="Model olasılığı, oran bandı istatistiği ve piyasa oranı karşılaştırması. Edge pozitifse model avantaj görüyor demektir." />
|
||||||
|
</HStack>
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 3, xl: 4 }} gap={2.5}>
|
||||||
|
{orderedEntries.map(([key, entry]) => (
|
||||||
|
<TripleValueCard
|
||||||
|
key={key}
|
||||||
|
label={TRIPLE_VALUE_LABELS[key] || key}
|
||||||
|
entry={entry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cards + HTFT side by side on large screens */}
|
||||||
|
{(hasCards || hasHtft) && (
|
||||||
|
<Grid
|
||||||
|
templateColumns={{ base: "1fr", xl: hasCards && hasHtft ? "1fr 1fr" : "1fr" }}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
{hasCards && <CardsSection cards={cards} />}
|
||||||
|
{hasHtft && <HtftGrid htft={htft} />}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Table,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { SlideUp, FadeIn } from "@/components/motion";
|
import { SlideUp, FadeIn } from "@/components/motion";
|
||||||
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
||||||
import { LuArrowLeft, LuCalendar, LuTrophy } from "react-icons/lu";
|
import { LuArrowLeft, LuCalendar, LuTrophy, LuChevronDown } from "react-icons/lu";
|
||||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Utility Functions
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function getMatchTimestamp(match: MatchResponseDto): number {
|
function getMatchTimestamp(match: MatchResponseDto): number {
|
||||||
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
|
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
|
||||||
@@ -46,53 +50,118 @@ function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
||||||
return String(team?.logo || team?.logoUrl || fallback || "");
|
return String(team?.logo || (team as Record<string, unknown> | undefined)?.logoUrl || fallback || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLeagueLabel(match: MatchResponseDto): string {
|
function getLeagueLabel(match: MatchResponseDto): string {
|
||||||
return String(match.leagueName || match.league?.name || "");
|
return String(match.leagueName || match.league?.name || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Football season logic: Aug–Jun
|
||||||
|
* If month >= August (8) → season starts this year: "YYYY-(YYYY+1)"
|
||||||
|
* If month < August → season started last year: "(YYYY-1)-YYYY"
|
||||||
|
*/
|
||||||
|
function getSeasonFromTimestamp(timestampMs: number): string {
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1; // 1-indexed
|
||||||
|
|
||||||
|
if (month >= 8) {
|
||||||
|
return `${year}-${year + 1}`;
|
||||||
|
}
|
||||||
|
return `${year - 1}-${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group matches by season string, returning a Map ordered by newest season first.
|
||||||
|
*/
|
||||||
|
function groupMatchesBySeason(matches: MatchResponseDto[]): Map<string, MatchResponseDto[]> {
|
||||||
|
const groups = new Map<string, MatchResponseDto[]>();
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const ts = getMatchTimestamp(match);
|
||||||
|
const season = ts ? getSeasonFromTimestamp(ts) : "Bilinmiyor";
|
||||||
|
if (!groups.has(season)) {
|
||||||
|
groups.set(season, []);
|
||||||
|
}
|
||||||
|
groups.get(season)!.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by season key descending (newest first)
|
||||||
|
const sorted = new Map(
|
||||||
|
[...groups.entries()].sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
|
);
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Main Component
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TeamDetailContent() {
|
export default function TeamDetailContent() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const teamId = params.id as string;
|
const teamId = params.id as string;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
||||||
const { data: matchesData, isLoading: matchesLoading } = useTeamMatches(teamId, { limit: 30 });
|
const {
|
||||||
|
data: matchesResponse,
|
||||||
|
isLoading: matchesLoading,
|
||||||
|
isFetching: matchesFetching,
|
||||||
|
} = useTeamMatches(teamId, { page: currentPage, limit: 20 });
|
||||||
|
|
||||||
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 seasonActiveBg = useColorModeValue("primary.500", "primary.400");
|
||||||
|
const seasonInactiveBg = useColorModeValue("gray.100", "gray.700");
|
||||||
|
|
||||||
const team = teamData?.data;
|
const team = (teamData as Record<string, unknown> | undefined)?.data as Record<string, unknown> | undefined;
|
||||||
const matches: MatchResponseDto[] = matchesData?.data ?? [];
|
const paginationData = matchesResponse;
|
||||||
|
const matches: MatchResponseDto[] = paginationData?.data ?? [];
|
||||||
if (teamLoading) {
|
const totalPages = paginationData?.totalPages ?? 1;
|
||||||
return (
|
const totalMatches = paginationData?.total ?? 0;
|
||||||
<Flex justify="center" align="center" py={20}>
|
|
||||||
<Spinner size="lg" color="primary.500" />
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
return (
|
|
||||||
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
|
||||||
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
|
|
||||||
<Button variant="outline" onClick={() => router.back()}>
|
|
||||||
<LuArrowLeft /> Geri
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate past and upcoming matches
|
// Separate past and upcoming matches
|
||||||
const isFinished = (m: MatchResponseDto) => isMatchFinished(m);
|
const pastMatches = useMemo(
|
||||||
|
() => matches.filter((m) => isMatchFinished(m)),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
const upcomingMatches = useMemo(
|
||||||
|
() => matches.filter((m) => !isMatchFinished(m)),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
|
||||||
const pastMatches = matches.filter((m: MatchResponseDto) => isFinished(m));
|
// Group past matches by season
|
||||||
const upcomingMatches = matches.filter((m: MatchResponseDto) => !isFinished(m));
|
const seasonGroups = useMemo(
|
||||||
|
() => groupMatchesBySeason(pastMatches),
|
||||||
|
[pastMatches]
|
||||||
|
);
|
||||||
|
const seasonKeys = useMemo(() => [...seasonGroups.keys()], [seasonGroups]);
|
||||||
|
|
||||||
|
// Active season selection
|
||||||
|
const [activeSeason, setActiveSeason] = useState<string | null>(null);
|
||||||
|
const displaySeason = activeSeason ?? seasonKeys[0] ?? null;
|
||||||
|
const displayMatches = displaySeason ? seasonGroups.get(displaySeason) ?? [] : [];
|
||||||
|
|
||||||
|
// Pagination handlers
|
||||||
|
const handleNextPage = useCallback(() => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
setCurrentPage((p) => p + 1);
|
||||||
|
setActiveSeason(null); // Reset season on page change
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const handlePrevPage = useCallback(() => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage((p) => p - 1);
|
||||||
|
setActiveSeason(null);
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
const getStatusBadge = (match: MatchResponseDto) => {
|
const getStatusBadge = (match: MatchResponseDto) => {
|
||||||
if (isMatchLive(match))
|
if (isMatchLive(match))
|
||||||
@@ -114,6 +183,25 @@ export default function TeamDetailContent() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (teamLoading) {
|
||||||
|
return (
|
||||||
|
<Flex justify="center" align="center" py={20}>
|
||||||
|
<Spinner size="lg" color="primary.500" />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return (
|
||||||
|
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
||||||
|
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<LuArrowLeft /> Geri
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideUp>
|
<SlideUp>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -127,10 +215,10 @@ export default function TeamDetailContent() {
|
|||||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<HStack gap={6} justify="center" align="center">
|
<HStack gap={6} justify="center" align="center">
|
||||||
{team.logo ? (
|
{(team as Record<string, unknown>).logo ? (
|
||||||
<Image
|
<Image
|
||||||
src={team.logo}
|
src={String((team as Record<string, unknown>).logo)}
|
||||||
alt={team.name}
|
alt={String((team as Record<string, unknown>).name)}
|
||||||
boxSize="80px"
|
boxSize="80px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
@@ -143,23 +231,23 @@ export default function TeamDetailContent() {
|
|||||||
justify="center"
|
justify="center"
|
||||||
>
|
>
|
||||||
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
||||||
{team.name?.charAt(0) || "T"}
|
{String((team as Record<string, unknown>).name || "T").charAt(0)}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<VStack gap={1} align="start">
|
<VStack gap={1} align="start">
|
||||||
<Heading as="h1" size="xl">
|
<Heading as="h1" size="xl">
|
||||||
{team.name}
|
{String((team as Record<string, unknown>).name)}
|
||||||
</Heading>
|
</Heading>
|
||||||
{team.country && (
|
{Boolean((team as Record<string, unknown>).country) && (
|
||||||
<Text fontSize="md" color="fg.muted">
|
<Text fontSize="md" color="fg.muted">
|
||||||
🌍 {team.country}
|
🌍 {String((team as Record<string, unknown>).country)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<HStack gap={4} mt={1}>
|
<HStack gap={4} mt={1}>
|
||||||
<Badge colorPalette="blue" variant="subtle">
|
<Badge colorPalette="blue" variant="subtle">
|
||||||
<LuTrophy style={{ width: 12, height: 12 }} />
|
<LuTrophy style={{ width: 12, height: 12 }} />
|
||||||
{matches.length} Maç
|
{totalMatches} Maç
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge colorPalette="green" variant="subtle">
|
<Badge colorPalette="green" variant="subtle">
|
||||||
<LuCalendar style={{ width: 12, height: 12 }} />
|
<LuCalendar style={{ width: 12, height: 12 }} />
|
||||||
@@ -194,23 +282,65 @@ export default function TeamDetailContent() {
|
|||||||
</FadeIn>
|
</FadeIn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Past Matches */}
|
{/* Past Matches — Season Grouped */}
|
||||||
<FadeIn>
|
<FadeIn>
|
||||||
<Box>
|
<Box>
|
||||||
<Heading as="h2" size="lg" mb={4}>
|
<Flex align="center" justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||||
|
<Heading as="h2" size="lg">
|
||||||
📊 Geçmiş Maçlar
|
📊 Geçmiş Maçlar
|
||||||
</Heading>
|
</Heading>
|
||||||
{matchesLoading ? (
|
{/* Pagination Info */}
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Sayfa {currentPage}/{totalPages} • Toplam {totalMatches} maç
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Season Tabs */}
|
||||||
|
{seasonKeys.length > 0 && (
|
||||||
|
<HStack gap={2} mb={4} flexWrap="wrap">
|
||||||
|
{seasonKeys.map((season) => {
|
||||||
|
const isActive = season === displaySeason;
|
||||||
|
const count = seasonGroups.get(season)?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={season}
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? "solid" : "outline"}
|
||||||
|
bg={isActive ? seasonActiveBg : seasonInactiveBg}
|
||||||
|
color={isActive ? "white" : undefined}
|
||||||
|
borderRadius="full"
|
||||||
|
fontWeight={isActive ? "700" : "500"}
|
||||||
|
fontSize="xs"
|
||||||
|
px={4}
|
||||||
|
onClick={() => setActiveSeason(season)}
|
||||||
|
_hover={{
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
shadow: "sm",
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
🏆 {season} ({count})
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchesLoading || matchesFetching ? (
|
||||||
<Flex justify="center" py={8}>
|
<Flex justify="center" py={8}>
|
||||||
<Spinner size="md" color="primary.500" />
|
<Spinner size="md" color="primary.500" />
|
||||||
</Flex>
|
</Flex>
|
||||||
) : pastMatches.length === 0 ? (
|
) : displayMatches.length === 0 && pastMatches.length === 0 ? (
|
||||||
<Text color="fg.muted" textAlign="center" py={8}>
|
<Text color="fg.muted" textAlign="center" py={8}>
|
||||||
Geçmiş maç bulunamadı
|
Bu sayfada geçmiş maç bulunamadı
|
||||||
|
</Text>
|
||||||
|
) : displayMatches.length === 0 ? (
|
||||||
|
<Text color="fg.muted" textAlign="center" py={8}>
|
||||||
|
Bu sezonda maç bulunamadı
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={2} align="stretch">
|
<VStack gap={2} align="stretch">
|
||||||
{pastMatches.map((match: MatchResponseDto) => (
|
{displayMatches.map((match: MatchResponseDto) => (
|
||||||
<MatchRow
|
<MatchRow
|
||||||
key={match.id}
|
key={match.id}
|
||||||
match={match}
|
match={match}
|
||||||
@@ -222,6 +352,63 @@ export default function TeamDetailContent() {
|
|||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Flex justify="center" gap={3} mt={6} align="center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
← Önceki
|
||||||
|
</Button>
|
||||||
|
<HStack gap={1}>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||||
|
// Show pages around current page
|
||||||
|
let pageNum: number;
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 4) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 3) {
|
||||||
|
pageNum = totalPages - 6 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 3 + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
size="sm"
|
||||||
|
variant={pageNum === currentPage ? "solid" : "ghost"}
|
||||||
|
bg={pageNum === currentPage ? seasonActiveBg : undefined}
|
||||||
|
color={pageNum === currentPage ? "white" : undefined}
|
||||||
|
borderRadius="full"
|
||||||
|
minW="36px"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentPage(pageNum);
|
||||||
|
setActiveSeason(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
Sonraki →
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
MatchAnalysisResultDto,
|
MatchAnalysisResultDto,
|
||||||
DailyBankoResponseDto,
|
DailyBankoResponseDto,
|
||||||
SmartCouponResultDto,
|
SmartCouponResultDto,
|
||||||
|
FrequencyCouponRequestDto,
|
||||||
|
FrequencyCouponResultDto,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,15 @@ const suggestCoupon = (dto: SuggestCouponDto) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateFrequencyCoupon = (dto: FrequencyCouponRequestDto) => {
|
||||||
|
return apiRequest<ApiResponse<FrequencyCouponResultDto>>({
|
||||||
|
url: "/coupon/frequency-coupon",
|
||||||
|
client: "core",
|
||||||
|
method: "post",
|
||||||
|
data: dto,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const couponsService = {
|
export const couponsService = {
|
||||||
analyzeMatch,
|
analyzeMatch,
|
||||||
createCoupon,
|
createCoupon,
|
||||||
@@ -77,4 +88,6 @@ export const couponsService = {
|
|||||||
getHistory,
|
getHistory,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
suggestCoupon,
|
suggestCoupon,
|
||||||
|
generateFrequencyCoupon,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,3 +106,50 @@ export interface SmartCouponResultDto {
|
|||||||
expected_win_rate: number;
|
expected_win_rate: number;
|
||||||
rejected_matches: SuggestedCouponRejectedMatchDto[];
|
rejected_matches: SuggestedCouponRejectedMatchDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Frequency Engine DTOs
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
export interface FrequencyCouponRequestDto {
|
||||||
|
matchIds?: string[];
|
||||||
|
maxMatches?: number;
|
||||||
|
minSignal?: number;
|
||||||
|
markets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrequencyCouponBetDto {
|
||||||
|
match_id: string;
|
||||||
|
match_name: string;
|
||||||
|
league: string;
|
||||||
|
market: string;
|
||||||
|
pick: string;
|
||||||
|
home_signal: number;
|
||||||
|
away_signal: number;
|
||||||
|
combined_signal: number;
|
||||||
|
league_profile: string;
|
||||||
|
historical_hit_rate: number;
|
||||||
|
odds: number;
|
||||||
|
home_odds_band: string;
|
||||||
|
away_odds_band: string;
|
||||||
|
home_match_count: number;
|
||||||
|
away_match_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrequencyCouponRejectedDto {
|
||||||
|
match_id: string;
|
||||||
|
match_name: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrequencyCouponResultDto {
|
||||||
|
strategy: "FREQUENCY";
|
||||||
|
generated_at: string;
|
||||||
|
bets: FrequencyCouponBetDto[];
|
||||||
|
total_odds: number;
|
||||||
|
expected_hit_rate: number;
|
||||||
|
expected_value: number;
|
||||||
|
ev_positive: boolean;
|
||||||
|
reasoning: string[];
|
||||||
|
rejected_matches: FrequencyCouponRejectedDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CreateCouponDto,
|
CreateCouponDto,
|
||||||
SuggestCouponDto,
|
SuggestCouponDto,
|
||||||
AnalyzeMatchDto,
|
AnalyzeMatchDto,
|
||||||
|
FrequencyCouponRequestDto,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const CouponsQueryKeys = {
|
export const CouponsQueryKeys = {
|
||||||
@@ -55,3 +56,11 @@ export const useSuggestCoupon = () => {
|
|||||||
mutationFn: (dto: SuggestCouponDto) => couponsService.suggestCoupon(dto),
|
mutationFn: (dto: SuggestCouponDto) => couponsService.suggestCoupon(dto),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGenerateFrequencyCoupon = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: FrequencyCouponRequestDto) =>
|
||||||
|
couponsService.generateFrequencyCoupon(dto),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
TeamSearchParams,
|
TeamSearchParams,
|
||||||
HeadToHeadParams,
|
HeadToHeadParams,
|
||||||
TeamMatchesParams,
|
TeamMatchesParams,
|
||||||
|
PaginatedMatchesResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +60,7 @@ const getTeamById = (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTeamMatches = (id: string, params?: TeamMatchesParams) => {
|
const getTeamMatches = (id: string, params?: TeamMatchesParams) => {
|
||||||
return apiRequest<ApiResponse<MatchResponseDto[]>>({
|
return apiRequest<PaginatedMatchesResponse>({
|
||||||
url: `/leagues/teams/${id}/matches`,
|
url: `/leagues/teams/${id}/matches`,
|
||||||
client: "core",
|
client: "core",
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|||||||
@@ -21,9 +21,18 @@ export interface HeadToHeadParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamMatchesParams {
|
export interface TeamMatchesParams {
|
||||||
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginatedMatchesResponse {
|
||||||
|
data: import("@/lib/api/matches/types").MatchResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
// ========================
|
// ========================
|
||||||
|
|||||||
@@ -141,6 +141,93 @@ export interface MarketBoardEntryDto {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// V28 Odds-Band Engine DTOs
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
export interface OddsBandEntryDto {
|
||||||
|
win_rate?: number;
|
||||||
|
draw_rate?: number;
|
||||||
|
lose_rate?: number;
|
||||||
|
over_rate?: number;
|
||||||
|
under_rate?: number;
|
||||||
|
yes_rate?: number;
|
||||||
|
no_rate?: number;
|
||||||
|
odd_rate?: number;
|
||||||
|
even_rate?: number;
|
||||||
|
"1x_rate"?: number;
|
||||||
|
"x2_rate"?: number;
|
||||||
|
"12_rate"?: number;
|
||||||
|
"1x_sample"?: number;
|
||||||
|
"x2_sample"?: number;
|
||||||
|
"12_sample"?: number;
|
||||||
|
sample: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OddsBandCardsDto {
|
||||||
|
referee_avg: number;
|
||||||
|
referee_over_rate: number;
|
||||||
|
referee_sample: number;
|
||||||
|
team_avg: number;
|
||||||
|
team_over_rate: number;
|
||||||
|
team_sample: number;
|
||||||
|
combined_over_rate: number;
|
||||||
|
sample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OddsBandHtftComboDto {
|
||||||
|
rate: number;
|
||||||
|
sample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripleValueEntryDto {
|
||||||
|
v27_prob?: number;
|
||||||
|
band_rate: number;
|
||||||
|
implied_prob: number;
|
||||||
|
combined_prob?: number;
|
||||||
|
edge: number;
|
||||||
|
band_sample: number;
|
||||||
|
confirmations?: number;
|
||||||
|
is_value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HtftComboKey =
|
||||||
|
| "11" | "1x" | "12"
|
||||||
|
| "x1" | "xx" | "x2"
|
||||||
|
| "21" | "2x" | "22";
|
||||||
|
|
||||||
|
export interface V27EngineDto {
|
||||||
|
version: string;
|
||||||
|
approach?: string;
|
||||||
|
consensus?: "AGREE" | "DISAGREE";
|
||||||
|
predictions?: Record<string, Record<string, number>>;
|
||||||
|
divergence?: Record<string, Record<string, number>>;
|
||||||
|
value_edge?: Record<string, Record<string, unknown>>;
|
||||||
|
odds_band?: {
|
||||||
|
ms_home?: OddsBandEntryDto;
|
||||||
|
ms_away?: OddsBandEntryDto;
|
||||||
|
ou25?: OddsBandEntryDto;
|
||||||
|
ou15?: OddsBandEntryDto;
|
||||||
|
ou35?: OddsBandEntryDto;
|
||||||
|
btts?: OddsBandEntryDto;
|
||||||
|
dc?: OddsBandEntryDto;
|
||||||
|
ht_home?: OddsBandEntryDto;
|
||||||
|
ht_away?: OddsBandEntryDto;
|
||||||
|
ht_ou05?: OddsBandEntryDto;
|
||||||
|
ht_ou15?: OddsBandEntryDto;
|
||||||
|
oe?: OddsBandEntryDto;
|
||||||
|
cards?: OddsBandCardsDto;
|
||||||
|
htft?: Record<HtftComboKey, OddsBandHtftComboDto>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
triple_value?: Record<string, TripleValueEntryDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Main Prediction DTOs
|
||||||
|
// ========================
|
||||||
|
|
||||||
export interface MatchPredictionDto {
|
export interface MatchPredictionDto {
|
||||||
model_version: string;
|
model_version: string;
|
||||||
match_info: MatchInfoDto;
|
match_info: MatchInfoDto;
|
||||||
@@ -157,6 +244,7 @@ export interface MatchPredictionDto {
|
|||||||
market_board: Record<string, MarketBoardEntryDto>;
|
market_board: Record<string, MarketBoardEntryDto>;
|
||||||
reasoning_factors: string[];
|
reasoning_factors: string[];
|
||||||
ai_commentary?: string | null;
|
ai_commentary?: string | null;
|
||||||
|
v27_engine?: V27EngineDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValueBetDto {
|
export interface ValueBetDto {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user