Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab5864df2f | |||
| bff5ea7b5f | |||
| 14159911f0 | |||
| 96b9653a7e | |||
| 30592394ef | |||
| c450661cf8 | |||
| 4bf0ab52f9 | |||
| 105c10699f | |||
| 10e521e382 | |||
| 9e04ca5627 | |||
| 4896323e04 |
@@ -1 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.next
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|||||||
+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"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --webpack --experimental-https -p 3001",
|
"dev": "next dev -p 6195",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { authOptions } from "@/lib/auth/auth-options";
|
||||||
import { isAdminRole } from "@/lib/auth/roles";
|
import { isAdminRole } from "@/lib/auth/roles";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|||||||
@@ -1,124 +1,5 @@
|
|||||||
import { authService } from "@/lib/api/auth/service";
|
import { authOptions } from "@/lib/auth/auth-options";
|
||||||
import { normalizeRoles } from "@/lib/auth/roles";
|
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import type { NextAuthOptions } from "next-auth";
|
|
||||||
import type { JWT } from "next-auth/jwt";
|
|
||||||
import type { Session, User } from "next-auth";
|
|
||||||
import Credentials from "next-auth/providers/credentials";
|
|
||||||
|
|
||||||
function randomToken() {
|
|
||||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
|
||||||
providers: [
|
|
||||||
Credentials({
|
|
||||||
name: "Credentials",
|
|
||||||
credentials: {
|
|
||||||
email: { label: "Email", type: "text" },
|
|
||||||
password: { label: "Password", type: "password" },
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
try {
|
|
||||||
console.log("Starting authorization with:", {
|
|
||||||
email: credentials?.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!credentials?.email || !credentials?.password) {
|
|
||||||
throw new Error("Email ve şifre gereklidir.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eğer mock mod aktifse backend'e gitme
|
|
||||||
if (isMockMode) {
|
|
||||||
console.log("Mock mode active, bypassing backend");
|
|
||||||
return {
|
|
||||||
id: credentials.email,
|
|
||||||
name: credentials.email.split("@")[0],
|
|
||||||
email: credentials.email,
|
|
||||||
accessToken: randomToken(),
|
|
||||||
refreshToken: randomToken(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal mod: backend'e istek at
|
|
||||||
console.log("Sending login request to backend...");
|
|
||||||
const res = await authService.login({
|
|
||||||
email: credentials.email,
|
|
||||||
password: credentials.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Backend response received:",
|
|
||||||
JSON.stringify(res, null, 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = res;
|
|
||||||
|
|
||||||
// Backend returns ApiResponse<TokenResponseDto>
|
|
||||||
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
|
||||||
if (!res.success || !response?.data?.accessToken) {
|
|
||||||
console.error("Login failed or no access token in response");
|
|
||||||
throw new Error(response?.message || "Giriş başarısız");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { accessToken, refreshToken, user } = response.data;
|
|
||||||
const normalizedRoles = normalizeRoles(user.roles);
|
|
||||||
|
|
||||||
console.log("Login successful, creating user session object");
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
name: user.firstName
|
|
||||||
? `${user.firstName} ${user.lastName || ""}`.trim()
|
|
||||||
: user.email.split("@")[0],
|
|
||||||
email: user.email,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
roles: normalizedRoles,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error("Authorize error detailed:", error);
|
|
||||||
const err = error as Error & {
|
|
||||||
response?: { data: unknown; status: number };
|
|
||||||
};
|
|
||||||
if (err.response) {
|
|
||||||
console.error("Error response data:", err.response.data);
|
|
||||||
console.error("Error response status:", err.response.status);
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
err.message || "An error occurred during authentication",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
async jwt({ token, user }: { token: JWT; user?: User }) {
|
|
||||||
if (user) {
|
|
||||||
token.accessToken = user.accessToken;
|
|
||||||
token.refreshToken = user.refreshToken;
|
|
||||||
token.id = user.id;
|
|
||||||
token.roles = normalizeRoles(user.roles);
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
async session({ session, token }: { session: Session; token: JWT }) {
|
|
||||||
session.user.id = token.id;
|
|
||||||
session.user.roles = normalizeRoles(token.roles);
|
|
||||||
session.accessToken = token.accessToken;
|
|
||||||
session.refreshToken = token.refreshToken;
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: "/signin",
|
|
||||||
error: "/signin",
|
|
||||||
},
|
|
||||||
session: { strategy: "jwt" },
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handler = NextAuth(authOptions);
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -19,37 +19,70 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
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";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } 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;
|
||||||
|
initialMode?: "login" | "register";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
/* ────────────────────────── Component ────────────────────────── */
|
||||||
|
|
||||||
|
export function LoginModal({ open, onOpenChange, initialMode = "login" }: LoginModalProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">(initialMode);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const {
|
// Update mode when modal opens
|
||||||
handleSubmit,
|
useEffect(() => {
|
||||||
register,
|
if (open) {
|
||||||
formState: { errors },
|
setMode(initialMode);
|
||||||
} = useForm<LoginForm>({
|
}
|
||||||
resolver: yupResolver(schema),
|
}, [open, initialMode]);
|
||||||
|
|
||||||
|
/* ── Login form ── */
|
||||||
|
const loginForm = useForm<LoginForm>({
|
||||||
|
resolver: yupResolver(loginSchema),
|
||||||
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 +97,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 +148,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 +216,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 +248,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export default function Header() {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">("login");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
@@ -63,10 +64,15 @@ export default function Header() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut({ redirect: false });
|
await signOut({ redirect: false });
|
||||||
if (authConfig.isAuthRequired) {
|
if (authConfig.isAuthRequired) {
|
||||||
router.replace("/signin");
|
router.replace("/home");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAuthModal = (mode: "login" | "register") => {
|
||||||
|
setLoginModalMode(mode);
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Desktop auth section
|
// Desktop auth section
|
||||||
const renderAuthSection = () => {
|
const renderAuthSection = () => {
|
||||||
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
|
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
|
||||||
@@ -97,16 +103,27 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
colorPalette="gray"
|
||||||
|
size="sm"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => openAuthModal("register")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
colorPalette="primary"
|
colorPalette="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
onClick={() => setLoginModalOpen(true)}
|
onClick={() => openAuthModal("login")}
|
||||||
>
|
>
|
||||||
<LuLogIn />
|
<LuLogIn />
|
||||||
{t("auth.sign-in")}
|
{t("auth.sign-in")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,17 +167,29 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<VStack gap={2} w="full">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
colorPalette="gray"
|
||||||
|
size="sm"
|
||||||
|
width="full"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => openAuthModal("register")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
colorPalette="primary"
|
colorPalette="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
width="full"
|
width="full"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
onClick={() => setLoginModalOpen(true)}
|
onClick={() => openAuthModal("login")}
|
||||||
>
|
>
|
||||||
<LuLogIn />
|
<LuLogIn />
|
||||||
{t("auth.sign-in")}
|
{t("auth.sign-in")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -296,7 +325,7 @@ export default function Header() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Login Modal */}
|
{/* Login Modal */}
|
||||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} initialMode={loginModalMode} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
SimpleGrid,
|
||||||
|
Icon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { LuUsers, LuUser, LuInfo, LuShieldCheck, LuClock } from "react-icons/lu";
|
||||||
|
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||||
|
import type { MatchPredictionDto } from "@/lib/api/predictions/types";
|
||||||
|
|
||||||
|
interface LineupsCardProps {
|
||||||
|
match: MatchResponseDto;
|
||||||
|
prediction?: MatchPredictionDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lineup source metadata used for title, badge, and informational banners.
|
||||||
|
*/
|
||||||
|
function getLineupSourceMeta(source?: string) {
|
||||||
|
switch (source) {
|
||||||
|
case "confirmed_live":
|
||||||
|
return {
|
||||||
|
title: "Resmi İlk 11",
|
||||||
|
badge: "Onaylı Kadro",
|
||||||
|
badgeColor: "green" as const,
|
||||||
|
icon: LuShieldCheck,
|
||||||
|
description: "Kadro resmi olarak onaylandı.",
|
||||||
|
};
|
||||||
|
case "confirmed_participation":
|
||||||
|
return {
|
||||||
|
title: "Onaylı Kadro",
|
||||||
|
badge: "Onaylı",
|
||||||
|
badgeColor: "green" as const,
|
||||||
|
icon: LuShieldCheck,
|
||||||
|
description: "Kadro maç katılım verilerinden alındı.",
|
||||||
|
};
|
||||||
|
case "probable_xi":
|
||||||
|
return {
|
||||||
|
title: "Muhtemel Kadro",
|
||||||
|
badge: "Muhtemel",
|
||||||
|
badgeColor: "orange" as const,
|
||||||
|
icon: LuUsers,
|
||||||
|
description:
|
||||||
|
"Son maçlardaki ilk 11 verilerine dayalı muhtemel kadro. AI analizi bu kadro üzerinden yapılmaktadır.",
|
||||||
|
};
|
||||||
|
case "none":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "Kadro Bilgisi",
|
||||||
|
badge: "Kadro Bekleniyor",
|
||||||
|
badgeColor: "gray" as const,
|
||||||
|
icon: LuClock,
|
||||||
|
description:
|
||||||
|
"Kadro henüz açıklanmadı. AI analizi, takımların genel güç dengesi ve istatistiklerine dayalı olarak üretilmiştir.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LineupsCard({ match, prediction }: LineupsCardProps) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||||
|
const headerBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||||||
|
const infoBg = useColorModeValue("blue.50", "whiteAlpha.100");
|
||||||
|
const infoBorder = useColorModeValue("blue.200", "blue.800");
|
||||||
|
|
||||||
|
let homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
|
||||||
|
let awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
|
||||||
|
|
||||||
|
// Determine lineup source from prediction data quality
|
||||||
|
const source = prediction?.data_quality?.lineup_source;
|
||||||
|
const meta = getLineupSourceMeta(source);
|
||||||
|
|
||||||
|
// Fallback: If no starting players are marked, but we have players, treat them as probable XI
|
||||||
|
if (homeLineups.length === 0 && match.lineups?.home && match.lineups.home.length > 0) {
|
||||||
|
homeLineups = match.lineups.home.slice(0, 11);
|
||||||
|
}
|
||||||
|
if (awayLineups.length === 0 && match.lineups?.away && match.lineups.away.length > 0) {
|
||||||
|
awayLineups = match.lineups.away.slice(0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLineups = homeLineups.length > 0 || awayLineups.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||||
|
<Card.Body>
|
||||||
|
{/* ── Header ────────────────────────────────── */}
|
||||||
|
<Flex justify="space-between" align="center" mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={meta.icon} boxSize={5} color="fg.muted" />
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{meta.title}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge
|
||||||
|
colorPalette={meta.badgeColor}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{meta.badge}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* ── Info Banner ───────────────────────────── */}
|
||||||
|
{source !== "confirmed_live" && (
|
||||||
|
<Flex
|
||||||
|
bg={infoBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={infoBorder}
|
||||||
|
borderRadius="md"
|
||||||
|
p={3}
|
||||||
|
mb={4}
|
||||||
|
align="center"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Icon as={LuInfo} color="blue.500" flexShrink={0} />
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{meta.description}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Lineups Grid ─────────────────────────── */}
|
||||||
|
{hasLineups ? (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={6}>
|
||||||
|
{/* Home Team Lineup */}
|
||||||
|
<Box>
|
||||||
|
<Flex
|
||||||
|
bg={headerBg}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
mb={3}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Text fontWeight="bold">{match.homeTeamName}</Text>
|
||||||
|
<Badge size="sm" variant="outline" colorPalette="blue">
|
||||||
|
Ev Sahibi
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
{homeLineups.length > 0 ? (
|
||||||
|
<VStack align="stretch" gap={2}>
|
||||||
|
{homeLineups.map((p, idx) => (
|
||||||
|
<HStack
|
||||||
|
key={p.player?.id || idx}
|
||||||
|
p={2}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<Icon as={LuUser} color="fg.muted" />
|
||||||
|
{p.shirtNumber && (
|
||||||
|
<Text fontSize="xs" fontWeight="bold" w="20px">
|
||||||
|
{p.shirtNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{p.player?.name || "Bilinmiyor"}
|
||||||
|
</Text>
|
||||||
|
{p.position && (
|
||||||
|
<Badge ml="auto" size="sm" variant="surface">
|
||||||
|
{p.position}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
direction="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||||
|
Kadro henüz belli değil
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.subtle">
|
||||||
|
Maç saatine yakın güncellenecek
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Away Team Lineup */}
|
||||||
|
<Box>
|
||||||
|
<Flex
|
||||||
|
bg={headerBg}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
mb={3}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Text fontWeight="bold">{match.awayTeamName}</Text>
|
||||||
|
<Badge size="sm" variant="outline" colorPalette="red">
|
||||||
|
Deplasman
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
{awayLineups.length > 0 ? (
|
||||||
|
<VStack align="stretch" gap={2}>
|
||||||
|
{awayLineups.map((p, idx) => (
|
||||||
|
<HStack
|
||||||
|
key={p.player?.id || idx}
|
||||||
|
p={2}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<Icon as={LuUser} color="fg.muted" />
|
||||||
|
{p.shirtNumber && (
|
||||||
|
<Text fontSize="xs" fontWeight="bold" w="20px">
|
||||||
|
{p.shirtNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{p.player?.name || "Bilinmiyor"}
|
||||||
|
</Text>
|
||||||
|
{p.position && (
|
||||||
|
<Badge ml="auto" size="sm" variant="surface">
|
||||||
|
{p.position}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
direction="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||||
|
Kadro henüz belli değil
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.subtle">
|
||||||
|
Maç saatine yakın güncellenecek
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
/* ── Empty State: No lineups at all ─────── */
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
py={8}
|
||||||
|
gap={3}
|
||||||
|
>
|
||||||
|
<Icon as={LuClock} boxSize={8} color="fg.subtle" />
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text fontWeight="semibold" color="fg.muted">
|
||||||
|
Kadro Henüz Açıklanmadı
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="fg.subtle" textAlign="center" maxW="sm">
|
||||||
|
{match.homeTeamName} ve {match.awayTeamName} kadroları maç saatine
|
||||||
|
yakın güncellenecektir. AI analizi, takım istatistikleri ve güç
|
||||||
|
dengesi üzerinden yapılmaktadır.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { useMatchDetails } from "@/lib/api/matches/use-hooks";
|
|||||||
import { usePrediction } from "@/lib/api/predictions/use-hooks";
|
import { usePrediction } from "@/lib/api/predictions/use-hooks";
|
||||||
import PredictionCard from "@/components/matches/prediction-card";
|
import PredictionCard from "@/components/matches/prediction-card";
|
||||||
import OddsCard from "@/components/matches/odds-card";
|
import OddsCard from "@/components/matches/odds-card";
|
||||||
|
import LineupsCard from "@/components/matches/lineups-card";
|
||||||
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
|
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
|
||||||
|
|
||||||
export default function MatchDetailContent() {
|
export default function MatchDetailContent() {
|
||||||
@@ -237,6 +238,9 @@ export default function MatchDetailContent() {
|
|||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Lineups Section */}
|
||||||
|
<LineupsCard match={match} prediction={prediction} />
|
||||||
|
|
||||||
{/* Prediction Section */}
|
{/* Prediction Section */}
|
||||||
<Box>
|
<Box>
|
||||||
<Flex justify="space-between" align="center" mb={4}>
|
<Flex justify="space-between" align="center" mb={4}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,89 @@ 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 || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEASONS = (() => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const startYear = currentMonth >= 8 ? currentYear : currentYear - 1;
|
||||||
|
return Array.from({ length: 5 }, (_, i) => `${startYear - i}-${startYear - i + 1}`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 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 [activeSeason, setActiveSeason] = useState<string>(SEASONS[0]);
|
||||||
|
|
||||||
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, season: activeSeason });
|
||||||
|
|
||||||
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;
|
// Backend ResponseInterceptor wraps all responses in { success, status, message, data }
|
||||||
const matches: MatchResponseDto[] = matchesData?.data ?? [];
|
const teamWrapper = teamData as Record<string, unknown> | undefined;
|
||||||
|
const team = teamWrapper?.data as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (teamLoading) {
|
// matchesResponse = { success, status, message, data: { data: [...], total, page, limit, totalPages } }
|
||||||
return (
|
const paginationWrapper = matchesResponse as Record<string, unknown> | undefined;
|
||||||
<Flex justify="center" align="center" py={20}>
|
const paginationData = paginationWrapper?.data as Record<string, unknown> | undefined;
|
||||||
<Spinner size="lg" color="primary.500" />
|
const matches: MatchResponseDto[] = (Array.isArray(paginationData?.data) ? paginationData.data : paginationData?.data ? [] : []) as MatchResponseDto[];
|
||||||
</Flex>
|
const totalPages = (paginationData?.totalPages as number) ?? 1;
|
||||||
);
|
const totalMatches = (paginationData?.total as number) ?? 0;
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
// Pagination handlers
|
||||||
const upcomingMatches = matches.filter((m: MatchResponseDto) => !isFinished(m));
|
const handleNextPage = useCallback(() => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
setCurrentPage((p) => p + 1);
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const handlePrevPage = useCallback(() => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage((p) => p - 1);
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
const getStatusBadge = (match: MatchResponseDto) => {
|
const getStatusBadge = (match: MatchResponseDto) => {
|
||||||
if (isMatchLive(match))
|
if (isMatchLive(match))
|
||||||
@@ -114,6 +154,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 +186,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 +202,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,19 +253,59 @@ 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 */}
|
||||||
|
{SEASONS.length > 0 && (
|
||||||
|
<HStack gap={2} mb={4} flexWrap="wrap">
|
||||||
|
{SEASONS.map((season) => {
|
||||||
|
const isActive = season === activeSeason;
|
||||||
|
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);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
_hover={{
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
shadow: "sm",
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
🏆 {season}
|
||||||
|
</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 ? (
|
) : pastMatches.length === 0 ? (
|
||||||
<Text color="fg.muted" textAlign="center" py={8}>
|
<Text color="fg.muted" textAlign="center" py={8}>
|
||||||
Geçmiş maç bulunamadı
|
Bu sezonda geçmiş maç bulunamadı
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={2} align="stretch">
|
<VStack gap={2} align="stretch">
|
||||||
@@ -222,6 +321,62 @@ 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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
Sonraki →
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +21,17 @@ export interface HeadToHeadParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamMatchesParams {
|
export interface TeamMatchesParams {
|
||||||
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
season?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedMatchesResponse {
|
||||||
|
data: import("@/lib/api/matches/types").MatchResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ export interface MatchResponseDto {
|
|||||||
country?: { name: string; flag?: string };
|
country?: { name: string; flag?: string };
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
lineups?: {
|
||||||
|
home: Array<{
|
||||||
|
player?: { name: string; id: string; [key: string]: unknown };
|
||||||
|
position?: string | null;
|
||||||
|
shirtNumber?: number | null;
|
||||||
|
isStarting?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
away: Array<{
|
||||||
|
player?: { name: string; id: string; [key: string]: unknown };
|
||||||
|
position?: string | null;
|
||||||
|
shirtNumber?: number | null;
|
||||||
|
isStarting?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,116 @@
|
|||||||
|
import { authService } from "@/lib/api/auth/service";
|
||||||
|
import { normalizeRoles } from "@/lib/auth/roles";
|
||||||
|
import type { NextAuthOptions } from "next-auth";
|
||||||
|
import type { JWT } from "next-auth/jwt";
|
||||||
|
import type { Session, User } from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
|
||||||
|
function randomToken() {
|
||||||
|
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "text" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
try {
|
||||||
|
console.log("Starting authorization with:", {
|
||||||
|
email: credentials?.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credentials?.email || !credentials?.password) {
|
||||||
|
throw new Error("Email ve şifre gereklidir.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eğer mock mod aktifse backend'e gitme
|
||||||
|
if (isMockMode) {
|
||||||
|
console.log("Mock mode active, bypassing backend");
|
||||||
|
return {
|
||||||
|
id: credentials.email,
|
||||||
|
name: credentials.email.split("@")[0],
|
||||||
|
email: credentials.email,
|
||||||
|
accessToken: randomToken(),
|
||||||
|
refreshToken: randomToken(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mod: backend'e istek at
|
||||||
|
console.log("Sending login request to backend...");
|
||||||
|
const res = await authService.login({
|
||||||
|
email: credentials.email,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Backend response received:",
|
||||||
|
JSON.stringify(res, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = res;
|
||||||
|
|
||||||
|
// Backend returns ApiResponse<TokenResponseDto>
|
||||||
|
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
||||||
|
if (!res.success || !response?.data?.accessToken) {
|
||||||
|
console.error("Login failed or no access token in response");
|
||||||
|
throw new Error(response?.message || "Giriş başarısız");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken, refreshToken, user } = response.data;
|
||||||
|
const normalizedRoles = normalizeRoles(user.roles);
|
||||||
|
|
||||||
|
console.log("Login successful, creating user session object");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.firstName
|
||||||
|
? `${user.firstName} ${user.lastName || ""}`.trim()
|
||||||
|
: user.email.split("@")[0],
|
||||||
|
email: user.email,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
roles: normalizedRoles,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Authorize error detailed:", error);
|
||||||
|
const err = error as Error & {
|
||||||
|
response?: { data: unknown; status: number };
|
||||||
|
};
|
||||||
|
if (err.response) {
|
||||||
|
console.error("Error response data:", err.response.data);
|
||||||
|
console.error("Error response status:", err.response.status);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
err.message || "An error occurred during authentication",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }: { token: JWT; user?: User }) {
|
||||||
|
if (user) {
|
||||||
|
token.accessToken = user.accessToken;
|
||||||
|
token.refreshToken = user.refreshToken;
|
||||||
|
token.id = user.id;
|
||||||
|
token.roles = normalizeRoles(user.roles);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }: { session: Session; token: JWT }) {
|
||||||
|
session.user.id = token.id;
|
||||||
|
session.user.roles = normalizeRoles(token.roles);
|
||||||
|
session.accessToken = token.accessToken;
|
||||||
|
session.refreshToken = token.refreshToken;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user