From 66877b88ca13c39044646834fbf9bc1e0290c7a4 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Tue, 12 May 2026 17:41:16 +0300 Subject: [PATCH] main --- messages/en.json | 96 ++++- messages/tr.json | 104 +++++- next-env.d.ts | 2 +- src/components/admin/admin-content.tsx | 143 ++++++-- src/components/admin/edit-user-modal.tsx | 168 +++++---- src/components/h2h/h2h-content.tsx | 8 +- src/components/matches/prediction-card.tsx | 387 ++++++++++++++------- 7 files changed, 666 insertions(+), 242 deletions(-) diff --git a/messages/en.json b/messages/en.json index 47b0328..c9541e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -235,14 +235,21 @@ "HTFT": "Half Time / Full Time", "HT/FT": "Half Time / Full Time", "OE": "Odd / Even", - "HT_OU05": "First Half 0.5 Goals" + "HT_OU05": "First Half 0.5 Goals", + "HT_OU15": "First Half 1.5 Goals", + "CARDS": "Cards 4.5", + "HCAP": "Handicap Result" }, "ui": { "summary-title": "Prediction Summary", "summary-info": "Shows model signals and uncertainty in a conservative summary.", + "model-signal-disclaimer": "This is a model signal; it is not a guaranteed result, guarantee, or hit-rate promise. Signal score can be wrong because of in-match variance, lineups, and data quality.", "main-recommendation": "Highlighted Signal", "best-market-copy": "is the strongest option in this market.", "confidence-label": "Confidence", + "confidence-interval": "Confidence Interval", + "confidence-interval-warning": "The confidence interval is wide. Even with a signal, it is not recommended as a standalone pick.", + "confidence-band": "Band", "odds-label": "Odds", "edge-label": "Theoretical Edge", "edge-info": "The theoretical gap between model probability and market probability; it is not a guarantee or a certain profit expectation.", @@ -253,8 +260,22 @@ "playability-label": "Model signal", "quick-read": "Quick read", "lineup-source": "Lineup Source", + "lineup-confirmed-live": "Confirmed starting XI", + "lineup-probable-xi": "Probable starting XI", + "unknown": "Unknown", "model-label": "Model", "engine-info": "Shows which components influence the prediction the most.", + "engine-team-football": "Team Strength", + "engine-team-basketball": "Team Form", + "engine-player-football": "Player Impact", + "engine-player-basketball": "Lineup Impact", + "engine-odds": "Odds Analysis", + "engine-referee-football": "Referee Impact", + "engine-referee-basketball": "Supporting Signals", + "engine-label-high": "High", + "engine-label-medium": "Medium", + "engine-label-low": "Low", + "engine-label-very-low": "Very Low", "best-single-pick": "Strongest Signal", "alternative-markets": "Alternative Markets", "alternative-markets-info": "Options outside the main recommendation.", @@ -264,7 +285,48 @@ "all-markets-info": "Compares every option in a single table.", "market-board-info": "The probability distribution the model sees for each market.", "bet-advice-info": "The model's final action recommendation.", - "recommended-stake-inline": "Suggested size" + "recommended-stake-inline": "Suggested size", + "model-probability-short": "Model", + "market-probability-short": "Market", + "theoretical-edge-inline": "Theoretical edge", + "playable": "Playable", + "risky": "Risky", + "hit-probability": "Hit Probability", + "calibrated-confidence": "Calibrated Confidence", + "score-scenario-football": "Score Scenario", + "score-scenario-basketball": "Points Scenario", + "score-scenario-info-football": "Expected score and the most likely scenarios.", + "score-scenario-info-basketball": "Expected points distribution and the most likely match scenarios.", + "full-time-football": "Full Time", + "full-time-basketball": "Full-Time Points", + "half-time-football": "Half Time", + "half-time-basketball": "Half-Time Points", + "expected-total-football": "Total xG", + "expected-total-basketball": "Expected Total Points", + "live": "LIVE", + "pre-match-prediction": "Pre-match prediction", + "prediction-contradictions": "Prediction Contradictions", + "data-quality": "Data Quality", + "data-quality-info": "How reliable the lineup, odds, and match data are.", + "risk-info": "Upset probability and uncertainty level.", + "risk-commentary": "Risk Commentary", + "risk-default-comment": "The model asks for extra caution on this match.", + "surprise-score": "Upset score", + "match-commentary-title": "Match Commentary", + "match-commentary-info": "The model's human-readable summary of the match.", + "reasoning-info": "High-level summary of why the model reads this match this way.", + "bet-advice-play": "PLAY", + "bet-advice-pass": "PASS", + "signal-tier-core": "Core", + "signal-tier-value": "Value", + "signal-tier-lean": "Lean", + "signal-tier-longshot": "Longshot", + "signal-tier-pass": "Pass", + "confidence-high": "High", + "confidence-medium": "Medium", + "confidence-low": "Low", + "confidence-unknown": "Unknown", + "info": "Info" } }, "coupons": { @@ -324,6 +386,9 @@ "candidate-pool-help": "Only football matches that have not started yet are listed here. Finished and live matches are excluded.", "candidate-pool-subtitle": "Source: live_matches table • sport: football • status: not started", "match-count-suffix": "matches", + "match-count-label": "Coupon Match Count", + "match-count-help": "How many matches should the AI coupon include? You can choose between 2 and 15. If you do not select any matches, the full bulletin is scanned.", + "match-count-auto": "Full bulletin ({count} matches)", "upcoming-badge": "Upcoming", "upcoming-reference": "Upcoming pool", "finished-badge": "Finished", @@ -419,7 +484,8 @@ "countries": "Countries", "leagues": "Leagues", "countries-leagues": "Countries & Leagues", - "search-at-least-2": "Type at least 2 characters to search teams." + "search-at-least-2": "Type at least 2 characters to search teams.", + "all": "All" }, "h2h": { "title": "Head to Head", @@ -475,7 +541,9 @@ "analytics": "Analytics Overview", "user-management": "User Management", "users": "Users", + "premium-users": "Premium Users", "settings": "Settings", + "subscription": "Subscription", "usage-limits": "Usage Limits", "total-users": "Total Users", "active-users": "Active Users", @@ -495,7 +563,25 @@ "user-email": "Email", "user-role": "Role", "user-status": "Status", - "no-users": "No users found." + "no-users": "No users found.", + "restricted": "Restricted", + "admin-access-required": "Admin access required", + "admin-access-description": "This area is only available to superadmin accounts.", + "search-users-placeholder": "Search by email or name...", + "all-roles": "View All Roles", + "standard-user": "Standard User", + "superadmin": "System Administrator (Admin)", + "all-plans": "View All Plans", + "plan-free": "Free", + "plan-plus": "Plus Plan", + "plan-premium": "Premium Plan", + "plan-past-due": "Past Due", + "plan-cancelled": "Cancelled", + "edit-user-title": "Edit User: {email}", + "user-role-field": "User Role", + "subscription-plan-field": "Subscription Plan", + "subscription-end-date": "Subscription End Date (Optional)", + "account-active-question": "Is the account active?" }, "common": { "limits": { @@ -689,4 +775,4 @@ "remaining": "remaining" } } -} \ No newline at end of file +} diff --git a/messages/tr.json b/messages/tr.json index 4db8b94..7f90dfb 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -235,14 +235,21 @@ "HTFT": "İlk Yarı / Maç Sonu", "HT/FT": "İlk Yarı / Maç Sonu", "OE": "Tek / Çift", - "HT_OU05": "İlk Yarı 0.5 Gol" + "HT_OU05": "İlk Yarı 0.5 Gol", + "HT_OU15": "İlk Yarı 1.5 Gol", + "CARDS": "Kartlar 4.5", + "HCAP": "Handikap Sonucu" }, "ui": { "summary-title": "Tahmin Özeti", "summary-info": "Model sinyallerini ve belirsizlikleri sade şekilde gösterir.", + "model-signal-disclaimer": "Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.", "main-recommendation": "Öne Çıkan Sinyal", "best-market-copy": "marketinde en güçlü seçim.", "confidence-label": "Güven", + "confidence-interval": "Güven Aralığı", + "confidence-interval-warning": "Güven aralığı geniş. Sinyal olsa bile tek başına oynanması önerilmez.", + "confidence-band": "Band", "odds-label": "Oran", "edge-label": "Teorik Avantaj", "edge-info": "Model olasılığı ile piyasa olasılığı arasındaki teorik farktır; tutma garantisi veya kesin kazanç beklentisi değildir.", @@ -253,8 +260,22 @@ "playability-label": "Model sinyali", "quick-read": "Hızlı yorum", "lineup-source": "Kadronun Kaynağı", + "lineup-confirmed-live": "Onaylı ilk 11", + "lineup-probable-xi": "Muhtemel ilk 11", + "unknown": "Bilinmiyor", "model-label": "Model", "engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.", + "engine-team-football": "Takım Gücü", + "engine-team-basketball": "Takım Formu", + "engine-player-football": "Oyuncu Etkisi", + "engine-player-basketball": "Kadro Etkisi", + "engine-odds": "Oran Analizi", + "engine-referee-football": "Hakem Etkisi", + "engine-referee-basketball": "Yardımcı Sinyaller", + "engine-label-high": "Yüksek", + "engine-label-medium": "Orta", + "engine-label-low": "Düşük", + "engine-label-very-low": "Çok Düşük", "best-single-pick": "En Güçlü Sinyal", "alternative-markets": "Alternatif Marketler", "alternative-markets-info": "Ana tahmin dışındaki seçenekler.", @@ -264,7 +285,48 @@ "all-markets-info": "Bütün seçenekleri tek tabloda karşılaştırır.", "market-board-info": "Modelin her markette gördüğü olasılık dağılımı.", "bet-advice-info": "Modelin nihai aksiyon önerisi.", - "recommended-stake-inline": "Önerilen miktar" + "recommended-stake-inline": "Önerilen miktar", + "model-probability-short": "Model", + "market-probability-short": "Piyasa", + "theoretical-edge-inline": "Teorik avantaj", + "playable": "Oynanabilir", + "risky": "Riskli", + "hit-probability": "Tutma Olasılığı", + "calibrated-confidence": "Kalibre Güven", + "score-scenario-football": "Skor Senaryosu", + "score-scenario-basketball": "Sayı Senaryosu", + "score-scenario-info-football": "Beklenen skor ve en olası senaryolar.", + "score-scenario-info-basketball": "Beklenen sayı dağılımı ve en olası maç senaryoları.", + "full-time-football": "Maç Sonu", + "full-time-basketball": "Maç Sonu Sayı", + "half-time-football": "İlk Yarı", + "half-time-basketball": "İlk Yarı Sayı", + "expected-total-football": "Toplam xG", + "expected-total-basketball": "Beklenen Toplam Sayı", + "live": "CANLI", + "pre-match-prediction": "Maç öncesi tahmin", + "prediction-contradictions": "Tahmin Çelişkileri", + "data-quality": "Veri Kalitesi", + "data-quality-info": "Kadro, oran ve maç verisinin ne kadar güvenilir olduğu.", + "risk-info": "Sürpriz ihtimali ve belirsizlik seviyesi.", + "risk-commentary": "Risk Yorumu", + "risk-default-comment": "Model bu maçta ekstra dikkat istiyor.", + "surprise-score": "Sürpriz skoru", + "match-commentary-title": "Maç Yorumu", + "match-commentary-info": "Modelin maç hakkındaki insan okunabilir özeti.", + "reasoning-info": "Modelin bu maçı neden bu şekilde okuduğunun üst seviye özeti.", + "bet-advice-play": "OYNA", + "bet-advice-pass": "OYNAMA", + "signal-tier-core": "Çekirdek", + "signal-tier-value": "Değer", + "signal-tier-lean": "Yorum", + "signal-tier-longshot": "Sürpriz", + "signal-tier-pass": "Pas", + "confidence-high": "Yüksek", + "confidence-medium": "Orta", + "confidence-low": "Düşük", + "confidence-unknown": "Belirsiz", + "info": "Bilgi" } }, "coupons": { @@ -312,6 +374,8 @@ "coupon": "Kupon", "candidate-match-count": "Aday Maç", "candidate-match-count-help": "Kupon oluşturmak için şu anda uygun olan yaklaşan futbol maçı sayısı.", + "finished-match-count": "Biten Maç", + "finished-match-count-help": "Biten futbol maçları için isteğe bağlı referans listesi. Bunlar kupon tahmininde asla kullanılmaz.", "selected-match-count": "Seçilen Maç", "selected-match-count-help": "Maçları siz seçerseniz AI kuponu sadece bu havuzdan üretir.", "suggested-bet-count": "Önerilen Bahis", @@ -323,12 +387,24 @@ "candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış", "match-count-suffix": "maç", "upcoming-badge": "Yaklaşan", + "upcoming-reference": "Yaklaşan havuz", + "finished-badge": "Bitti", + "prediction-locked": "Tahmine Kapalı", + "read-only-short": "Salt okunur", "selected-short": "Seçildi", "select-match": "Seç", + "match-state": "Maç Durumu", "selection-mode": "AI Havuzu", "manual-pool": "Manuel havuz", "auto-pool": "Otomatik havuz", + "finished-reference-only": "Sadece referans", "no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.", + "finished-matches-title": "Biten Maçlar", + "finished-matches-help": "Bu maçlar sadece referans için gösterilir. Seçilemezler ve kupon tahmini oluşturulmadan önce backend tarafından filtrelenirler.", + "finished-matches-subtitle": "İsteğe bağlı arşiv görünümü. Skorlar ve maç sonu istatistikleri kupon tahmin akışına gönderilmez.", + "show-finished-matches": "Biten maçları göster", + "hide-finished-matches": "Biten maçları gizle", + "no-finished-matches": "Geçerli görünüm için biten futbol maçı bulunamadı.", "manual-selection-active": "AI yalnızca aşağıda seçtiğiniz maçları kullanacak.", "automatic-selection-active": "Henüz manuel seçim yok. AI tüm yaklaşan maç havuzundan seçecek.", "selected-matches-panel-title": "Seçili Maç Havuzu", @@ -465,7 +541,9 @@ "analytics": "Analitik Genel Bakış", "user-management": "Kullanıcı Yönetimi", "users": "Kullanıcılar", + "premium-users": "Premium Kullanıcı", "settings": "Ayarlar", + "subscription": "Abonelik", "usage-limits": "Kullanım Limitleri", "total-users": "Toplam Kullanıcı", "active-users": "Aktif Kullanıcı", @@ -485,7 +563,25 @@ "user-email": "E-Posta", "user-role": "Rol", "user-status": "Durum", - "no-users": "Kullanıcı bulunamadı." + "no-users": "Kullanıcı bulunamadı.", + "restricted": "Kısıtlı", + "admin-access-required": "Admin erişimi gerekli", + "admin-access-description": "Bu alan yalnızca superadmin hesapları tarafından kullanılabilir.", + "search-users-placeholder": "E-posta veya isim ara...", + "all-roles": "Tüm Rolleri Gör", + "standard-user": "Standart Kullanıcı", + "superadmin": "Sistem Yöneticisi (Admin)", + "all-plans": "Tüm Paketleri Gör", + "plan-free": "Ücretsiz (Free)", + "plan-plus": "Plus Paketi", + "plan-premium": "Premium Paketi", + "plan-past-due": "Ödeme Gecikti (Past Due)", + "plan-cancelled": "İptal Edildi (Cancelled)", + "edit-user-title": "Kullanıcı Düzenle: {email}", + "user-role-field": "Kullanıcı Rolü", + "subscription-plan-field": "Abonelik Paketi", + "subscription-end-date": "Abonelik Bitiş Tarihi (Opsiyonel)", + "account-active-question": "Hesap Aktif mi?" }, "common": { "limits": { @@ -679,4 +775,4 @@ "remaining": "kalan" } } -} \ No newline at end of file +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/admin/admin-content.tsx b/src/components/admin/admin-content.tsx index 6d615d2..a18fffc 100644 --- a/src/components/admin/admin-content.tsx +++ b/src/components/admin/admin-content.tsx @@ -15,7 +15,10 @@ import { Separator, Input, } from "@chakra-ui/react"; -import { NativeSelectRoot, NativeSelectField } from "@/components/ui/forms/native-select"; +import { + NativeSelectRoot, + NativeSelectField, +} from "@/components/ui/forms/native-select"; import { useTranslations, useFormatter } from "next-intl"; import { useColorModeValue } from "@/components/ui/color-mode"; import { @@ -27,7 +30,13 @@ import { import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks"; import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types"; import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles"; -import { LuUsers, LuChartBar, LuActivity, LuShield, LuPencil } from "react-icons/lu"; +import { + LuUsers, + LuChartBar, + LuActivity, + LuShield, + LuPencil, +} from "react-icons/lu"; import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; import { EditUserModal } from "./edit-user-modal"; @@ -88,13 +97,19 @@ export default function AdminContent() { const format = useFormatter(); const [activeTab, setActiveTab] = useState("overview"); const [editingUser, setEditingUser] = useState(null); - const [searchParams, setSearchParams] = useState({ search: "", role: "", subscriptionStatus: "", page: 1, limit: 10 }); + const [searchParams, setSearchParams] = useState({ + search: "", + role: "", + subscriptionStatus: "", + page: 1, + limit: 10, + }); const [debouncedSearch, setDebouncedSearch] = useState(""); useEffect(() => { const handler = setTimeout(() => { setDebouncedSearch(searchParams.search); - setSearchParams(prev => ({ ...prev, page: 1 })); + setSearchParams((prev) => ({ ...prev, page: 1 })); }, 500); return () => clearTimeout(handler); }, [searchParams.search]); @@ -113,7 +128,7 @@ export default function AdminContent() { role: searchParams.role, subscriptionStatus: searchParams.subscriptionStatus, page: searchParams.page, - limit: searchParams.limit + limit: searchParams.limit, }, canAccessAdmin, ); @@ -150,13 +165,13 @@ export default function AdminContent() { - Restricted + {t("restricted")} - Admin access required + {t("admin-access-required")} - This area is only available to superadmin accounts. + {t("admin-access-description")} @@ -236,7 +251,7 @@ export default function AdminContent() { } colorPalette="purple" @@ -272,32 +287,49 @@ export default function AdminContent() { setSearchParams({ ...searchParams, search: e.target.value })} + onChange={(e) => + setSearchParams({ + ...searchParams, + search: e.target.value, + }) + } /> setSearchParams({ ...searchParams, role: e.target.value, page: 1 })} + onChange={(e) => + setSearchParams({ + ...searchParams, + role: e.target.value, + page: 1, + }) + } items={[ - { label: "Standart Kullanıcı", value: "user" }, - { label: "Admin", value: "superadmin" } + { label: t("standard-user"), value: "user" }, + { label: t("superadmin"), value: "superadmin" }, ]} /> setSearchParams({ ...searchParams, subscriptionStatus: e.target.value, page: 1 })} + onChange={(e) => + setSearchParams({ + ...searchParams, + subscriptionStatus: e.target.value, + page: 1, + }) + } items={[ - { label: "Ücretsiz (Free)", value: "free" }, + { label: t("plan-free"), value: "free" }, { label: "Plus", value: "plus" }, { label: "Premium", value: "premium" }, - { label: "Gecikmiş", value: "past_due" }, - { label: "İptal", value: "cancelled" } + { label: t("plan-past-due"), value: "past_due" }, + { label: t("plan-cancelled"), value: "cancelled" }, ]} /> @@ -310,7 +342,11 @@ export default function AdminContent() { ) : users.length > 0 ? ( - + {/* Table Header */} @@ -330,7 +366,7 @@ export default function AdminContent() { {t("user-role")} - {t("subscription", { fallback: "Subscription" })} + {t("subscription")} {t("user-status")} @@ -357,7 +393,12 @@ export default function AdminContent() { > {getUserDisplayName(user)} - + {user.email} @@ -372,9 +413,20 @@ export default function AdminContent() { {formatRoleLabel(user.role)} - + {user.subscriptionExpiresAt ? ( - {format.dateTime(new Date(user.subscriptionExpiresAt), { year: 'numeric', month: '2-digit', day: '2-digit' })} + {format.dateTime( + new Date(user.subscriptionExpiresAt), + { + year: "numeric", + month: "2-digit", + day: "2-digit", + }, + )} ) : ( @@ -436,25 +495,45 @@ export default function AdminContent() { {/* Pagination */} {meta && meta.totalPages > 1 && ( - + - Sayfa {meta.page} / {meta.totalPages} + + {tCommon("page")} {meta.page} / {meta.totalPages} + )} diff --git a/src/components/admin/edit-user-modal.tsx b/src/components/admin/edit-user-modal.tsx index d31c88c..95fe36c 100644 --- a/src/components/admin/edit-user-modal.tsx +++ b/src/components/admin/edit-user-modal.tsx @@ -1,10 +1,6 @@ "use client"; -import { - Button, - VStack, - Input, -} from "@chakra-ui/react"; +import { Button, VStack, Input } from "@chakra-ui/react"; import { DialogRoot, DialogContent, @@ -15,11 +11,14 @@ import { DialogCloseTrigger, } from "@/components/ui/overlays/dialog"; import { Field } from "@/components/ui/forms/field"; -import { NativeSelectRoot, NativeSelectField } from "@/components/ui/forms/native-select"; +import { + NativeSelectRoot, + NativeSelectField, +} from "@/components/ui/forms/native-select"; import { Switch } from "@/components/ui/forms/switch"; import { useTranslations } from "next-intl"; import { AdminUserDto } from "@/lib/api/admin/types"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useUpdateUserRole, useUpdateUserSubscription, @@ -33,52 +32,73 @@ interface EditUserModalProps { } export function EditUserModal({ user, isOpen, onClose }: EditUserModalProps) { + if (!user) return null; + + return ( + + ); +} + +function formatDateInputValue(value?: string | null): string { + if (!value) return ""; + try { + return new Date(value).toISOString().split("T")[0]; + } catch { + return ""; + } +} + +function EditUserModalContent({ + user, + isOpen, + onClose, +}: { + user: AdminUserDto; + isOpen: boolean; + onClose: () => void; +}) { const t = useTranslations("admin"); const tCommon = useTranslations("common"); - const [role, setRole] = useState("user"); - const [plan, setPlan] = useState("free"); - const [expiresAt, setExpiresAt] = useState(""); - const [isActive, setIsActive] = useState(true); + const [role, setRole] = useState(user.role || "user"); + const [plan, setPlan] = useState(user.subscriptionStatus || "free"); + const [expiresAt, setExpiresAt] = useState( + formatDateInputValue(user.subscriptionExpiresAt), + ); + const [isActive, setIsActive] = useState(user.isActive); - const { mutateAsync: updateRole, isPending: rolePending } = useUpdateUserRole(); - const { mutateAsync: updateSub, isPending: subPending } = useUpdateUserSubscription(); - const { mutateAsync: toggleActive, isPending: togglePending } = useToggleUserActive(); - - useEffect(() => { - if (user) { - setRole(user.role || "user"); - setPlan(user.subscriptionStatus || "free"); - setIsActive(user.isActive); - if (user.subscriptionExpiresAt) { - try { - const date = new Date(user.subscriptionExpiresAt); - setExpiresAt(date.toISOString().split('T')[0]); - } catch(e) { setExpiresAt(""); } - } else { - setExpiresAt(""); - } - } - }, [user]); + const { mutateAsync: updateRole, isPending: rolePending } = + useUpdateUserRole(); + const { mutateAsync: updateSub, isPending: subPending } = + useUpdateUserSubscription(); + const { mutateAsync: toggleActive, isPending: togglePending } = + useToggleUserActive(); const handleSave = async () => { - if (!user) return; try { if (role !== user.role) { await updateRole({ id: user.id, dto: { role } }); } - - const currentExpiresAtStr = user.subscriptionExpiresAt - ? new Date(user.subscriptionExpiresAt).toISOString().split('T')[0] + + const currentExpiresAtStr = user.subscriptionExpiresAt + ? new Date(user.subscriptionExpiresAt).toISOString().split("T")[0] : ""; - - if (plan !== user.subscriptionStatus || expiresAt !== currentExpiresAtStr) { - await updateSub({ - id: user.id, - dto: { + + if ( + plan !== user.subscriptionStatus || + expiresAt !== currentExpiresAtStr + ) { + await updateSub({ + id: user.id, + dto: { plan, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null - } + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + }, }); } if (isActive !== user.isActive) { @@ -92,78 +112,92 @@ export function EditUserModal({ user, isOpen, onClose }: EditUserModalProps) { const isPending = rolePending || subPending || togglePending; - if (!user) return null; - return ( !e.open && onClose()}> - Kullanıcı Düzenle: {user.email} + + {t("edit-user-title", { email: user.email })} + - + setRole(e.target.value)} items={[ - { label: "Standart Kullanıcı", value: "user" }, - { label: "Sistem Yöneticisi (Admin)", value: "superadmin" }, + { label: t("standard-user"), value: "user" }, + { label: t("superadmin"), value: "superadmin" }, ]} /> - + { const newPlan = e.target.value; setPlan(newPlan); - if ((newPlan === "premium" || newPlan === "plus") && !expiresAt) { + if ( + (newPlan === "premium" || newPlan === "plus") && + !expiresAt + ) { const d = new Date(); d.setDate(d.getDate() + 30); - setExpiresAt(d.toISOString().split('T')[0]); - } else if (newPlan === "free" || newPlan === "cancelled" || newPlan === "past_due") { + setExpiresAt(d.toISOString().split("T")[0]); + } else if ( + newPlan === "free" || + newPlan === "cancelled" || + newPlan === "past_due" + ) { setExpiresAt(""); } }} items={[ - { label: "Ücretsiz (Free)", value: "free" }, - { label: "Plus Paketi", value: "plus" }, - { label: "Premium Paketi", value: "premium" }, - { label: "Ödeme Gecikti (Past Due)", value: "past_due" }, - { label: "İptal Edildi (Cancelled)", value: "cancelled" }, + { label: t("plan-free"), value: "free" }, + { label: t("plan-plus"), value: "plus" }, + { label: t("plan-premium"), value: "premium" }, + { label: t("plan-past-due"), value: "past_due" }, + { label: t("plan-cancelled"), value: "cancelled" }, ]} /> {plan !== "free" && ( - - setExpiresAt(e.target.value)} + + setExpiresAt(e.target.value)} /> )} - - setIsActive(e.checked)}> - {isActive ? "Aktif" : "Pasif"} + + setIsActive(e.checked)} + > + {isActive ? tCommon("active") : tCommon("inactive")} - diff --git a/src/components/h2h/h2h-content.tsx b/src/components/h2h/h2h-content.tsx index f530122..14ad691 100644 --- a/src/components/h2h/h2h-content.tsx +++ b/src/components/h2h/h2h-content.tsx @@ -18,10 +18,10 @@ import { useTranslations } from "next-intl"; import { useColorModeValue } from "@/components/ui/color-mode"; import { SlideUp } from "@/components/motion"; import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks"; -import type { TeamDto, HeadToHeadDto } from "@/lib/api/leagues/types"; +import type { TeamDto } from "@/lib/api/leagues/types"; import type { MatchResponseDto } from "@/lib/api/matches/types"; import { LuSearch, LuArrowLeftRight } from "react-icons/lu"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useDebounce } from "@/hooks/use-debounce"; function TeamSearchInput({ @@ -134,7 +134,7 @@ export default function H2HContent() { ?.data ? [ { - label: team1?.name || t("team1"), + label: team1?.name || t("team-1"), value: h2h.data.data.team1Wins, color: "green", }, @@ -144,7 +144,7 @@ export default function H2HContent() { color: "gray", }, { - label: team2?.name || t("team2"), + label: team2?.name || t("team-2"), value: h2h.data.data.team2Wins, color: "blue", }, diff --git a/src/components/matches/prediction-card.tsx b/src/components/matches/prediction-card.tsx index 6463508..550f115 100644 --- a/src/components/matches/prediction-card.tsx +++ b/src/components/matches/prediction-card.tsx @@ -46,6 +46,16 @@ interface PredictionCardProps { prediction: MatchPredictionDto; } +type PredictionUiMessages = Record; + +function getUiText( + ui: PredictionUiMessages | undefined, + key: string, + fallback: string, +): string { + return ui?.[key] || fallback; +} + function formatReasonFallback(reason: string): string { if (reason.startsWith("risk:")) return formatReasonFallback(reason.slice(5)); const evMatch = reason.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/); @@ -158,16 +168,16 @@ function getEngineLabelPalette(label?: string): string { } } -function getEngineLabelText(label?: string): string { +function getEngineLabelText(label?: string, ui?: PredictionUiMessages): string { switch ((label || "").toUpperCase()) { case "YUKSEK": - return "Yüksek"; + return getUiText(ui, "engine-label-high", "Yüksek"); case "ORTA": - return "Orta"; + return getUiText(ui, "engine-label-medium", "Orta"); case "DUSUK": - return "Düşük"; + return getUiText(ui, "engine-label-low", "Düşük"); case "COK_DUSUK": - return "Çok Düşük"; + return getUiText(ui, "engine-label-very-low", "Çok Düşük"); default: return label || ""; } @@ -214,23 +224,30 @@ function getConfidenceBandPalette(band?: string) { } } -function getConfidenceBandLabel(band?: string) { +function getConfidenceBandLabel(band?: string, ui?: PredictionUiMessages) { switch ((band || "").toUpperCase()) { case "HIGH": - return "Yüksek"; + return getUiText(ui, "confidence-high", "Yüksek"); case "MEDIUM": - return "Orta"; + return getUiText(ui, "confidence-medium", "Orta"); case "LOW": - return "Düşük"; + return getUiText(ui, "confidence-low", "Düşük"); default: - return "Belirsiz"; + return getUiText(ui, "confidence-unknown", "Belirsiz"); } } -function getLineupSourceLabel(source?: string): string { - if (source === "confirmed_live") return "Onayli ilk 11"; - if (source === "probable_xi") return "Muhtemel ilk 11"; - return source ? formatReasonFallback(source) : "Bilinmiyor"; +function getLineupSourceLabel( + source?: string, + ui?: PredictionUiMessages, +): string { + if (source === "confirmed_live") + return getUiText(ui, "lineup-confirmed-live", "Onaylı ilk 11"); + if (source === "probable_xi") + return getUiText(ui, "lineup-probable-xi", "Muhtemel ilk 11"); + return source + ? formatReasonFallback(source) + : getUiText(ui, "unknown", "Bilinmiyor"); } function formatInterval( @@ -359,22 +376,28 @@ function getSignalTierPalette(tier?: SignalTier) { } } -function getSignalTierLabel(tier?: SignalTier) { +function getSignalTierLabel(tier?: SignalTier, ui?: PredictionUiMessages) { switch (tier) { case "CORE": - return "Çekirdek"; + return getUiText(ui, "signal-tier-core", "Çekirdek"); case "VALUE": - return "Değer"; + return getUiText(ui, "signal-tier-value", "Değer"); case "LEAN": - return "Yorum"; + return getUiText(ui, "signal-tier-lean", "Yorum"); case "LONGSHOT": - return "Sürpriz"; + return getUiText(ui, "signal-tier-longshot", "Sürpriz"); default: - return "Pas"; + return getUiText(ui, "signal-tier-pass", "Pas"); } } -function TooltipIcon({ content }: { content: string }) { +function TooltipIcon({ + content, + ariaLabel = "Bilgi", +}: { + content: string; + ariaLabel?: string; +}) { return ( - Model {formatProbability(modelProb, 0)} + {labels.model} {formatProbability(modelProb, 0)} - Piyasa {formatProbability(impliedProb, 0)} + {labels.market} {formatProbability(impliedProb, 0)} @@ -538,6 +566,7 @@ function PickCard({ palette, marketLabels, labels, + ui, }: { pick: MatchPickDto; stakeFallback?: number; @@ -545,12 +574,19 @@ function PickCard({ resolveReason: (reason: string) => string; palette: string; marketLabels?: Record; + ui?: PredictionUiMessages; labels: { confidence: string; odds: string; recommendedStake: string; playScore: string; playability: string; + confidenceInterval: string; + confidenceBand: string; + confidenceIntervalWarning: string; + theoreticalEdgeInline: string; + modelProbability: string; + marketProbability: string; }; }) { const bg = useColorModeValue(`${palette}.50`, `${palette}.950`); @@ -591,16 +627,16 @@ function PickCard({ colorPalette={getSignalTierPalette(pick.signal_tier)} variant="subtle" > - {getSignalTierLabel(pick.signal_tier)} + {getSignalTierLabel(pick.signal_tier, ui)} - {getConfidenceBandLabel(pick.confidence_interval?.band)} + {getConfidenceBandLabel(pick.confidence_interval?.band, ui)} - Teorik avantaj {formatEdgeSignal(pick.ev_edge)} + {labels.theoreticalEdgeInline} {formatEdgeSignal(pick.ev_edge)} @@ -619,12 +655,12 @@ function PickCard({ value={formatSignalScore(pick.play_score)} /> @@ -632,6 +668,10 @@ function PickCard({ @@ -661,8 +701,7 @@ function PickCard({ borderColor={intervalWarningBorder} > - Guven araligi genis. Sinyal olsa bile tek basina oynanmasi - onerilmez. + {labels.confidenceIntervalWarning} ) : null} @@ -676,11 +715,13 @@ function SummaryTable({ marketLabels, title, info, + ui, }: { items: MatchBetSummaryItemDto[]; marketLabels?: Record; title: string; info: string; + ui?: PredictionUiMessages; }) { const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); @@ -728,7 +769,7 @@ function SummaryTable({ colorPalette={getSignalTierPalette(item.signal_tier)} variant="subtle" > - {getSignalTierLabel(item.signal_tier)} + {getSignalTierLabel(item.signal_tier, ui)} {getMarketLabel(item.market, marketLabels)} @@ -753,7 +794,7 @@ function SummaryTable({ )} variant="subtle" > - {getConfidenceBandLabel(item.confidence_interval?.band)} + {getConfidenceBandLabel(item.confidence_interval?.band, ui)} {formatUnits(item.stake_units)} @@ -812,7 +853,6 @@ function SummaryTable({ {formatUnits(item.stake_units)} */} - @@ -825,12 +865,14 @@ function MarketBoardSection({ marketLabels, title, info, + ui, }: { marketBoard?: Record; betSummary?: MatchBetSummaryItemDto[]; marketLabels?: Record; title: string; info: string; + ui?: PredictionUiMessages; }) { const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); @@ -881,7 +923,9 @@ function MarketBoardSection({ colorPalette={summary.playable ? "green" : "gray"} variant="subtle" > - {summary.playable ? "Oynanabilir" : "Riskli"} + {summary.playable + ? getUiText(ui, "playable", "Oynanabilir") + : getUiText(ui, "risky", "Riskli")} ) : null} {summary?.signal_tier ? ( @@ -891,7 +935,7 @@ function MarketBoardSection({ )} variant="subtle" > - {getSignalTierLabel(summary.signal_tier)} + {getSignalTierLabel(summary.signal_tier, ui)} ) : null} {summary?.bet_grade ? ( @@ -913,12 +957,16 @@ function MarketBoardSection({ {interval ? ( - Guven araligi: {formatInterval(interval)} + {getUiText(ui, "confidence-interval", "Güven Aralığı")}:{" "} + {formatInterval(interval)} ) : null} @@ -951,7 +1000,7 @@ function MarketBoardSection({ value={probability * 100} color={ entry.pick === outcome || - entry.pick?.toUpperCase() === outcome.toUpperCase() + entry.pick?.toUpperCase() === outcome.toUpperCase() ? "green.400" : "blue.400" } @@ -973,9 +1022,11 @@ function MarketBoardSection({ function ScoreCard({ prediction, sport, + ui, }: { prediction: MatchPredictionDto; sport: SportType; + ui?: PredictionUiMessages; }) { const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); @@ -987,24 +1038,52 @@ function ScoreCard({ @@ -1053,6 +1132,16 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { const pageBg = useColorModeValue("gray.50", "gray.900"); const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); + const liveBg = useColorModeValue("red.50", "red.950"); + const liveBorderColor = useColorModeValue("red.300", "red.800"); + const warningBg = useColorModeValue("yellow.50", "yellow.950"); + const warningBorderColor = useColorModeValue("yellow.300", "yellow.800"); + const orangeBg = useColorModeValue("orange.50", "orange.950"); + const orangeBorderColor = useColorModeValue("orange.200", "orange.800"); + const greenBg = useColorModeValue("green.50", "green.950"); + const greenBorderColor = useColorModeValue("green.200", "green.800"); + const statCardBg = useColorModeValue("gray.50", "whiteAlpha.50"); + const trackBgColor = useColorModeValue("gray.100", "gray.700"); const riskPalette = getRiskPalette(prediction.risk.level); const qualityPalette = getQualityPalette(prediction.data_quality.label); const recommendedPick = prediction.main_pick; @@ -1067,7 +1156,9 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { { key: "team", icon: LuGauge, - label: isBasketball ? "Takim Formu" : "Takim Gucu", + label: isBasketball + ? uiText("engine-team-basketball", "Takım Formu") + : uiText("engine-team-football", "Takım Gücü"), value: prediction.engine_breakdown.team, color: "blue.400", detail: engineDetail?.team, @@ -1075,7 +1166,9 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { { key: "player", icon: LuSparkles, - label: isBasketball ? "Kadro Etkisi" : "Oyuncu Etkisi", + label: isBasketball + ? uiText("engine-player-basketball", "Kadro Etkisi") + : uiText("engine-player-football", "Oyuncu Etkisi"), value: prediction.engine_breakdown.player, color: "green.400", detail: engineDetail?.player, @@ -1083,14 +1176,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { { key: "odds", icon: LuTrendingUp, - label: "Oran Analizi", - value: prediction.engine_breakdown.odds, - color: "orange.400", - }, - { - key: "odds", - icon: LuTrendingUp, - label: "Oran Analizi", + label: uiText("engine-odds", "Oran Analizi"), value: prediction.engine_breakdown.odds, color: "orange.400", detail: engineDetail?.odds, @@ -1098,7 +1184,9 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { { key: "referee", icon: LuShieldAlert, - label: isBasketball ? "Yardimci Sinyaller" : "Hakem Etkisi", + label: isBasketball + ? uiText("engine-referee-basketball", "Yardımcı Sinyaller") + : uiText("engine-referee-football", "Hakem Etkisi"), value: prediction.engine_breakdown.referee, color: "purple.400", detail: engineDetail?.referee, @@ -1110,33 +1198,49 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { const isLive = Boolean(prediction.match_info?.is_live); const isStale = Boolean(prediction.prediction_freshness?.is_stale_for_live); const contradictions = prediction.match_commentary?.contradictions || []; + const pickCardLabels = { + confidence: uiText("confidence-label", "Güven"), + odds: uiText("odds-label", "Oran"), + recommendedStake: uiText("stake-label-short", "Stake"), + playScore: uiText("play-score-label", "Model Sinyali"), + playability: uiText("playability-label", "Model sinyali"), + confidenceInterval: uiText("confidence-interval", "Güven Aralığı"), + confidenceBand: uiText("confidence-band", "Band"), + confidenceIntervalWarning: uiText( + "confidence-interval-warning", + "Güven aralığı geniş. Sinyal olsa bile tek başına oynanması önerilmez.", + ), + theoreticalEdgeInline: uiText("theoretical-edge-inline", "Teorik avantaj"), + modelProbability: uiText("model-probability-short", "Model"), + marketProbability: uiText("market-probability-short", "Piyasa"), + }; return ( {isLive ? ( - 🔴 CANLI + 🔴 {uiText("live", "CANLI")} {liveScoreHome != null && liveScoreAway != null ? ( - {prediction.match_info.home_team} {liveScoreHome} - {liveScoreAway}{" "} - {prediction.match_info.away_team} + {prediction.match_info.home_team} {liveScoreHome} -{" "} + {liveScoreAway} {prediction.match_info.away_team} ) : null} {isStale ? ( - Maç öncesi tahmin + {uiText("pre-match-prediction", "Maç öncesi tahmin")} ) : null} @@ -1146,15 +1250,17 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { {contradictions.length ? ( - Tahmin Çelişkileri + + {uiText("prediction-contradictions", "Tahmin Çelişkileri")} + {contradictions.map((text, idx) => ( • {text} @@ -1169,17 +1275,17 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { @@ -1190,9 +1296,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { mt={0.5} /> - Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi - değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi - nedeniyle yanılabilir. + {uiText( + "model-signal-disclaimer", + "Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.", + )} @@ -1201,9 +1308,9 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { @@ -1220,12 +1327,13 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { {getMarketLabel(recommendedPick.market, marketLabels)}{" "} - {uiText("best-market-copy", "marketinde en guclu secim.")} + {uiText("best-market-copy", "marketinde en güçlü seçim.")} {getConfidenceBandLabel( prediction.bet_advice.confidence_band, + ui, )} {recommendedPick.confidence_interval ? ( @@ -1239,7 +1347,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { @@ -1284,7 +1392,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { borderRadius="2xl" > - {uiText("quick-read", "Hizli yorum")} + {uiText("quick-read", "Hızlı yorum")} {prediction.risk.is_surprise_risk || - prediction.risk.warnings?.length ? ( + prediction.risk.warnings?.length ? ( @@ -1339,12 +1454,17 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { mt={0.5} /> - Risk Yorumu + + {uiText("risk-commentary", "Risk Yorumu")} + {prediction.risk.surprise_comment || (prediction.risk.surprise_type ? `${resolveReason(prediction.risk.surprise_type)}` - : "Model bu maçta ekstra dikkat istiyor.")} + : uiText( + "risk-default-comment", + "Model bu maçta ekstra dikkat istiyor.", + ))} {prediction.risk.surprise_score !== undefined ? ( - Sürpriz skoru:{" "} + {uiText("surprise-score", "Sürpriz skoru")}:{" "} {formatPercent(prediction.risk.surprise_score, 0)} ) : null} @@ -1361,7 +1481,13 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { {prediction.risk.surprise_breakdown.map((entry) => ( = 15 ? "red" : entry.points >= 8 ? "orange" : "yellow"} + colorPalette={ + entry.points >= 15 + ? "red" + : entry.points >= 8 + ? "orange" + : "yellow" + } variant="subtle" > +{entry.points.toFixed(0)} @@ -1395,7 +1521,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { title={t("engine-breakdown-title")} info={uiText( "engine-info", - "Tahmini en cok hangi bilesenlerin etkiledigini gosterir.", + "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.", )} /> @@ -1403,7 +1529,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { - {getEngineLabelText(item.detail.label)} + {getEngineLabelText(item.detail.label, ui)} ) : null} @@ -1432,9 +1558,8 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { - {item.detail?.interpretation ? ( {item.detail.interpretation} @@ -1454,13 +1579,8 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { palette="green" stakeFallback={prediction.bet_advice.suggested_stake_units} marketLabels={marketLabels} - labels={{ - confidence: uiText("confidence-label", "Guven"), - odds: uiText("odds-label", "Oran"), - recommendedStake: uiText("stake-label-short", "Stake"), - playScore: uiText("play-score-label", "Model Sinyali"), - playability: uiText("playability-label", "Model sinyali"), - }} + labels={pickCardLabels} + ui={ui} /> ) : null} @@ -1472,7 +1592,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { title={uiText("alternative-markets", "Alternatif Marketler")} info={uiText( "alternative-markets-info", - "Ana tahmin disindaki secenekler.", + "Ana tahmin dışındaki seçenekler.", )} /> @@ -1483,18 +1603,13 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { title={ pick.playable ? uiText("alternative", "Alternatif") - : uiText("pass-market", "PASS market") + : uiText("pass-market", "Elenen Market") } resolveReason={resolveReason} palette={pick.ev_edge > 0 ? "blue" : "orange"} marketLabels={marketLabels} - labels={{ - confidence: uiText("confidence-label", "Guven"), - odds: uiText("odds-label", "Oran"), - recommendedStake: uiText("stake-label-short", "Stake"), - playScore: uiText("play-score-label", "Model Sinyali"), - playability: uiText("playability-label", "Model sinyali"), - }} + labels={pickCardLabels} + ui={ui} /> ))} @@ -1505,19 +1620,24 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { - {prediction.match_commentary?.headline || prediction.match_commentary?.summary ? ( + {prediction.match_commentary?.headline || + prediction.match_commentary?.summary ? ( {prediction.match_commentary.headline ? ( @@ -1541,7 +1661,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { ) : null} - + {prediction.v27_engine ? ( @@ -1562,7 +1683,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) { - {prediction.bet_advice.playable ? "OYNA" : "OYNAMA"} + {prediction.bet_advice.playable + ? uiText("bet-advice-play", "OYNA") + : uiText("bet-advice-pass", "OYNAMA")} - {getConfidenceBandLabel(prediction.bet_advice.confidence_band)} + {getConfidenceBandLabel( + prediction.bet_advice.confidence_band, + ui, + )} - {getSignalTierLabel(prediction.bet_advice.signal_tier)} + {getSignalTierLabel(prediction.bet_advice.signal_tier, ui)} {resolveReason(prediction.bet_advice.reason)} - {uiText("recommended-stake-inline", "Onerilen miktar")}:{" "} + {uiText("recommended-stake-inline", "Önerilen miktar")}:{" "} {formatUnits(prediction.bet_advice.suggested_stake_units)} @@ -1616,7 +1742,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {