@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Mac/OS files
|
||||
.DS_Store
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Ignore test coverage
|
||||
coverage/
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,36 @@
|
||||
# --- STAGE 1: BUILDER ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build Next.js app
|
||||
# NEXT_PUBLIC_API_URL should be set during build if used in static generation
|
||||
# For production, we usually point to the domain name
|
||||
ENV NEXT_PUBLIC_API_URL=https://api.iddaai.com/api
|
||||
RUN npm run build
|
||||
|
||||
# --- STAGE 2: RUNNER ---
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy only necessary files
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
# Copy messages for internationalization
|
||||
COPY --from=builder /app/messages ./messages
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Next.js
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,341 @@
|
||||
# 🚀 Enterprise Next.js Boilerplate (Antigravity Edition)
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://chakra-ui.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://next-intl-docs.vercel.app/)
|
||||
|
||||
> **FOR AI AGENTS & DEVELOPERS:** This documentation is structured to provide deep context, architectural decisions, and operational details to ensure seamless handover to any AI coding assistant (like Antigravity) or human developer.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Project Context & Architecture (Read Me First)
|
||||
|
||||
This is an **opinionated, production-ready** frontend boilerplate built with Next.js 16 App Router. It is designed to work seamlessly with the companion **typescript-boilerplate-be** NestJS backend.
|
||||
|
||||
### 🏗️ Core Philosophy
|
||||
|
||||
- **Type Safety First:** Strict TypeScript configuration. DTOs and typed API responses connect frontend to backend.
|
||||
- **App Router Native:** Built entirely on Next.js App Router with Server Components and React Server Actions support.
|
||||
- **i18n Native:** Localization is baked into routing, API calls, and UI components via `next-intl`.
|
||||
- **Theme-First Design:** Chakra UI v3 with custom theme system for consistent, accessible UI.
|
||||
- **Auth by Default:** NextAuth.js integration with JWT token management and automatic session handling.
|
||||
|
||||
### 📐 Architectural Decision Records (ADR)
|
||||
|
||||
_To understand WHY things are the way they are:_
|
||||
|
||||
1. **Locale-Based Routing:**
|
||||
- **Mechanism:** All routes are prefixed with locale (e.g., `/tr/dashboard`, `/en/login`).
|
||||
- **Implementation:** `next-intl` plugin with `[locale]` dynamic segment in `app/` directory.
|
||||
- **Config:** `localePrefix: 'always'` ensures consistent URL structure.
|
||||
|
||||
2. **API Client with Auto-Auth & Locale:**
|
||||
- **Location:** `src/lib/api/create-api-client.ts`
|
||||
- **Feature:** Automatically injects JWT `Authorization` header from NextAuth session.
|
||||
- **Feature:** Automatically sets `Accept-Language` header based on current locale (cookie or URL path).
|
||||
- **Auto-Logout:** 401 responses trigger automatic signOut and redirect.
|
||||
|
||||
3. **Backend Proxy (CORS Solution):**
|
||||
- **Problem:** Local development CORS issues when frontend (port 3001) calls backend (port 3000).
|
||||
- **Solution:** Next.js `rewrites` in `next.config.ts` proxies `/api/backend/*` → `http://localhost:3000/api/*`.
|
||||
- **Usage:** API clients use `/api/backend/...` paths in development.
|
||||
|
||||
4. **Provider Composition:**
|
||||
- **Location:** `src/components/ui/provider.tsx`
|
||||
- **Stack:** `SessionProvider` (NextAuth) → `ChakraProvider` (UI) → `ColorModeProvider` (Dark/Light) → `Toaster` (Notifications)
|
||||
- **Why:** Single import in layout provides all necessary contexts.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start for AI & Humans
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
- **Node.js:** v20.19+ (LTS)
|
||||
- **Package Manager:** `npm` (Lockfile: `package-lock.json`)
|
||||
- **Backend:** Companion NestJS backend running on port 3000 (see `typescript-boilerplate-be`)
|
||||
|
||||
### 2. Environment Setup
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your configuration
|
||||
```
|
||||
|
||||
**Required Environment Variables:**
|
||||
|
||||
| Variable | Description | Example |
|
||||
| --------------------------- | --------------------------------------------------------------- | --------------------------- |
|
||||
| `NEXTAUTH_URL` | NextAuth callback URL | `http://localhost:3001` |
|
||||
| `NEXTAUTH_SECRET` | Authentication secret (generate with `openssl rand -base64 32`) | `abc123...` |
|
||||
| `NEXT_PUBLIC_API_URL` | Backend API base URL | `http://localhost:3001/api` |
|
||||
| `NEXT_PUBLIC_AUTH_REQUIRED` | Require login for all pages | `true` or `false` |
|
||||
|
||||
### 3. Installation & Running
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm ci
|
||||
|
||||
# Development Mode (HTTPS enabled, port 3001)
|
||||
npm run dev
|
||||
|
||||
# Production Build
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
> **Note:** Dev server runs on port 3001 with experimental HTTPS for secure cookie handling.
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Internationalization (i18n) Guide
|
||||
|
||||
Deep integration with `next-intl` provides locale-aware routing and translations.
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Supported Locales:** `['en', 'tr']`
|
||||
- **Default Locale:** `tr`
|
||||
- **Locale Detection:** Cookie (`NEXT_LOCALE`) → URL path → Default
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
messages/
|
||||
├── en.json # English translations
|
||||
└── tr.json # Turkish translations
|
||||
|
||||
src/i18n/
|
||||
├── routing.ts # Locale routing configuration
|
||||
├── navigation.ts # Typed navigation helpers (Link, useRouter)
|
||||
└── request.ts # Server-side i18n setup
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
```tsx
|
||||
// Client Component
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function MyComponent() {
|
||||
const t = useTranslations("common");
|
||||
return <h1>{t("welcome")}</h1>;
|
||||
}
|
||||
|
||||
// Navigation with Locale
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
<Link href="/dashboard">Dashboard</Link>; // Auto-prefixes with current locale
|
||||
```
|
||||
|
||||
### Adding a New Locale
|
||||
|
||||
1. Add locale code to `src/i18n/routing.ts`:
|
||||
```ts
|
||||
export const locales = ["en", "tr", "de"]; // Add 'de'
|
||||
```
|
||||
2. Create `messages/de.json` with translations.
|
||||
3. Update `getLocale()` in `create-api-client.ts` if needed.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication System
|
||||
|
||||
Built on NextAuth.js with JWT strategy for seamless backend integration.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Login:** User submits credentials → `/api/auth/[...nextauth]` → Backend validates → JWT returned.
|
||||
2. **Session:** JWT stored in encrypted cookie, accessible via `useSession()` hook.
|
||||
3. **API Calls:** `createApiClient()` automatically adds `Authorization: Bearer {token}` header.
|
||||
4. **Auto-Refresh:** Token refreshed before expiry (managed by NextAuth).
|
||||
|
||||
### Protected Routes
|
||||
|
||||
```tsx
|
||||
// Use middleware.ts or check session in layout
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export default async function ProtectedLayout({ children }) {
|
||||
const session = await getServerSession();
|
||||
if (!session) redirect("/login");
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Mode Toggle
|
||||
|
||||
Set `NEXT_PUBLIC_AUTH_REQUIRED=true` for mandatory authentication across all pages.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI System (Chakra UI v3)
|
||||
|
||||
### Theme Configuration
|
||||
|
||||
- **Location:** `src/theme/theme.ts`
|
||||
- **Font:** Bricolage Grotesque (Google Fonts, variable font)
|
||||
- **Color Mode:** Light/Dark with system preference detection
|
||||
|
||||
### Component Organization
|
||||
|
||||
```
|
||||
src/components/
|
||||
├── ui/ # Chakra UI wrapper components
|
||||
│ ├── provider.tsx # Root provider (Session + Chakra + Theme)
|
||||
│ ├── color-mode.tsx # Dark/Light mode toggle
|
||||
│ ├── feedback/ # Toaster, alerts, etc.
|
||||
│ └── ...
|
||||
├── layout/ # Page layout components (Header, Sidebar, Footer)
|
||||
├── auth/ # Authentication forms and guards
|
||||
└── site/ # Site-specific feature components
|
||||
```
|
||||
|
||||
### Toast Notifications
|
||||
|
||||
```tsx
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
|
||||
// Success toast
|
||||
toaster.success({
|
||||
title: "Saved!",
|
||||
description: "Changes saved successfully.",
|
||||
});
|
||||
|
||||
// Error toast
|
||||
toaster.error({ title: "Error", description: "Something went wrong." });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API Integration
|
||||
|
||||
### Creating API Clients
|
||||
|
||||
```tsx
|
||||
// src/lib/api/auth/login/queries.ts
|
||||
import { createApiClient } from "../create-api-client";
|
||||
|
||||
const api = createApiClient("/api/backend/auth");
|
||||
|
||||
export const loginUser = async (data: LoginDto) => {
|
||||
const response = await api.post("/login", data);
|
||||
return response.data;
|
||||
};
|
||||
```
|
||||
|
||||
### React Query Integration
|
||||
|
||||
```tsx
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
|
||||
// Query hook
|
||||
export const useUsers = () =>
|
||||
useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: () => apiClient.get("/users"),
|
||||
});
|
||||
|
||||
// Mutation hook
|
||||
export const useCreateUser = () =>
|
||||
useMutation({
|
||||
mutationFn: (data: CreateUserDto) => apiClient.post("/users", data),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 System Map (Directory Structure)
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── [locale]/ # Locale-prefixed routes
|
||||
│ │ ├── (auth)/ # Auth pages (login, register)
|
||||
│ │ ├── (site)/ # Main site pages
|
||||
│ │ ├── (error)/ # Error pages
|
||||
│ │ ├── layout.tsx # Root layout with providers
|
||||
│ │ └── page.tsx # Home page
|
||||
│ └── api/ # Next.js API routes
|
||||
│ └── auth/ # NextAuth endpoints
|
||||
├── components/ # React components
|
||||
│ ├── ui/ # Chakra UI wrappers & base components
|
||||
│ ├── layout/ # Layout components (Header, Sidebar)
|
||||
│ ├── auth/ # Auth-related components
|
||||
│ └── site/ # Feature-specific components
|
||||
├── config/ # App configuration
|
||||
├── hooks/ # Custom React hooks
|
||||
├── i18n/ # Internationalization setup
|
||||
├── lib/ # Utilities & services
|
||||
│ ├── api/ # API clients (organized by module)
|
||||
│ │ ├── auth/ # Auth API (login, register)
|
||||
│ │ ├── admin/ # Admin API (roles, users)
|
||||
│ │ └── create-api-client.ts # Axios factory with interceptors
|
||||
│ ├── services/ # Business logic services
|
||||
│ └── utils/ # Helper functions
|
||||
├── theme/ # Chakra UI theme configuration
|
||||
├── types/ # TypeScript type definitions
|
||||
└── proxy.ts # API proxy utilities
|
||||
|
||||
messages/ # Translation JSON files
|
||||
public/ # Static assets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing & Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (HTTPS, port 3001)
|
||||
npm run build # Production build
|
||||
npm run start # Start production server
|
||||
npm run lint # Run ESLint
|
||||
```
|
||||
|
||||
### Development Tools
|
||||
|
||||
- **React Compiler:** Enabled for automatic optimization.
|
||||
- **Top Loader:** Visual loading indicator for route transitions.
|
||||
- **ESLint + Prettier:** Consistent code formatting.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting (Known Issues)
|
||||
|
||||
**1. HTTPS Certificate Warnings in Development**
|
||||
|
||||
- **Context:** Dev server uses `--experimental-https` for secure cookies.
|
||||
- **Fix:** Accept the self-signed certificate warning in your browser.
|
||||
|
||||
**2. Backend Connection Refused**
|
||||
|
||||
- **Fix:** Ensure NestJS backend is running on port 3000.
|
||||
- **Check:** Run `curl http://localhost:3000/api/health` to verify.
|
||||
|
||||
**3. Session Not Persisting**
|
||||
|
||||
- **Fix:** Ensure `NEXTAUTH_SECRET` is set and consistent between restarts.
|
||||
- **Fix:** Check that cookies are not blocked by browser settings.
|
||||
|
||||
**4. Locale Not Detected**
|
||||
|
||||
- **Context:** Default falls back to `tr` if cookie/URL detection fails.
|
||||
- **Fix:** Clear `NEXT_LOCALE` cookie and refresh.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Projects
|
||||
|
||||
- **Backend:** [typescript-boilerplate-be](../typescript-boilerplate-be) - NestJS backend with Prisma, JWT auth, and i18n.
|
||||
|
||||
---
|
||||
|
||||
## 📃 License
|
||||
|
||||
This project is proprietary and confidential.
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC38ryU+6/sNDQR
|
||||
OL1pWCKrIms5cTMoSS8UOKhVaHB2n5J4f8pIuAX/sNBycI4/9oFztGGp3UIs+HD4
|
||||
dIsOmFZmHJCpAVhivyPD0MeXYq1t6V5VSNMOnH0y7huvWYmFuAWZ9/41QKSkM/an
|
||||
oYQQvcvLkuS0vbrYCr0jmndbfBIF4zYQEPyBR/fcS5W4lFCPUojhXlfSOvWN/tuY
|
||||
MU5RxOzHbC4DuKnj1Mj/WNBVzpN4BNkBqUakrjhQw2DYhFFlCTxez9+gFPfZXtrm
|
||||
EfZjAvOn+BMDUJhNzMZvBoU5rKAEGYmxhT2NHj0rGoEIUPBuiLguvngWl3OXdAQE
|
||||
brdyG507AgMBAAECggEAAJnET+A6hNTuzpeW1r847cIhA4EBH8KNas18jzrWEy0W
|
||||
N1qDeJVRP7J+G8GOVVsitRQDtaBJVQhCpi0LPzL0JUU2m7araTcikMMfw7jIxDEc
|
||||
475nIgcUyZPJd1sdfdhJ/GS46ceaQgcBaS631a4o+jMyl/x+nbH7SCB6/0t6a5Z+
|
||||
7cMe/NoDbypGyEo8sEZw6idHBdogZO1E+aLOBfGTkc42jzfV4UCbcuWtpGa3QHDw
|
||||
scXEIwHRza5XO8kdn064tHb6JWyjkXh3abeyZU2uoOGFyEZxR0FjxxC579pA2LoB
|
||||
qmmXeFo8uVFIs4L0fXSj3ohW1i+I10qSFvFY7SgSIQKBgQDe7RsY5QbuwQxfgeIa
|
||||
R8VNHLC5ux02q3bEqCr8UoPZaop4Ckg7gIiHumU6/YK6Qke0w0XEkAy2Fhv2Vby5
|
||||
RehgmSZ5+LSXpsW3uutgTMOw/4HaXLlW51icPK0rsdBth6AaOI3uX1j3XzSpgXyI
|
||||
6yYQtJnmvDGtNsfkC2+t6uCpWQKBgQDTPTf7kXyBtxti/nLV9z8/15atfJ6lsxVd
|
||||
oVWuaQEPUS3VwfBQYFKX/jhSQlXVbu4GrklMhSG8P0Q6glyjk/NiuhRUbQRFv7cu
|
||||
6TmXSGWfSvkEQdX+xVsA+rfaCNQu6+cFs0ZnK7pqN41LzwRAvdiyCXHiEi2EyqWw
|
||||
GypCWJRUswKBgQCtuDn4kWlwnxHET5PiBPH04Jm7ctwWIVJBeAdfb/H9eLAFUYXu
|
||||
kIBUvOVsLeg0u7fjXpS808CEGQCbWz7hZl/q/w3j2PLqhvTm84u/FLMe+E252642
|
||||
0bvUrNgKB9wzrpAOPuojyzuqMg/408Y3cH/OXt7b1uYjZGArDtptvm5qqQKBgQC1
|
||||
8lgDDshAbnhfZy2AkMtg8RAu9FUuAjeYAzvq0zT/fXvOT5LvmFfr5SOb7tlB0p+h
|
||||
D4PBLjblj1T0VI74spoD4qVaJuB0N3LQLEDXxpsJfqlIenCZVmJRUKMFYW9pzvWZ
|
||||
WlZ8zRRvItRIhNJz9VHt3+bAw8mDRI08R9m5ddSlswKBgGETkel47kg3l1oR++9s
|
||||
RExiQgTPM9mnFXMJhXpTKqTFZ7ETrNCQMui/ghbnBSpGmYRzrQEsftEMIp9rU7Z4
|
||||
q6m0F28CtJd3QUazE4t/Y62gUrTpQYGpW9fNqjtY8tEyzjxae5cY3zssB49yYfpQ
|
||||
h2KRQnPO3vzLdJMq+PRp2//o
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,26 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEXTCCAsWgAwIBAgIRAJwh1nDeNCaehj5TSbwpPpEwDQYJKoZIhvcNAQELBQAw
|
||||
gYkxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEvMC0GA1UECwwmcGl0
|
||||
b25AUGl0b25zLU1hY0Jvb2stQWlyLmxvY2FsIChQaXRvbikxNjA0BgNVBAMMLW1r
|
||||
Y2VydCBwaXRvbkBQaXRvbnMtTWFjQm9vay1BaXIubG9jYWwgKFBpdG9uKTAeFw0y
|
||||
NjA0MTQxMzQ1MjZaFw0yODA3MTQxMzQ1MjZaMFIxJzAlBgNVBAoTHm1rY2VydCBk
|
||||
ZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEnMCUGA1UECwwecGl0b25AUGl0b25zLU1h
|
||||
Y0Jvb2stQWlyLmxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
|
||||
t/K8lPuv7DQ0ETi9aVgiqyJrOXEzKEkvFDioVWhwdp+SeH/KSLgF/7DQcnCOP/aB
|
||||
c7Rhqd1CLPhw+HSLDphWZhyQqQFYYr8jw9DHl2KtbeleVUjTDpx9Mu4br1mJhbgF
|
||||
mff+NUCkpDP2p6GEEL3Ly5LktL262Aq9I5p3W3wSBeM2EBD8gUf33EuVuJRQj1KI
|
||||
4V5X0jr1jf7bmDFOUcTsx2wuA7ip49TI/1jQVc6TeATZAalGpK44UMNg2IRRZQk8
|
||||
Xs/foBT32V7a5hH2YwLzp/gTA1CYTczGbwaFOaygBBmJsYU9jR49KxqBCFDwboi4
|
||||
Lr54Fpdzl3QEBG63chudOwIDAQABo3YwdDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0l
|
||||
BAwwCgYIKwYBBQUHAwEwHwYDVR0jBBgwFoAU+PxiJ531CXgmujenTGLWFtdGwW8w
|
||||
LAYDVR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0G
|
||||
CSqGSIb3DQEBCwUAA4IBgQAMw+91DrGNCdqLngTCvG8fPU6ikAOBNvuB7Q0tf/q4
|
||||
adfgTse/pU7e9lkgChdYSYifh3FStmkaHmYNZg1ljgpMJICUxT2zL7rmOE9GlUqv
|
||||
2/umlzZcHE3yC3fLqS8Ik7D5qhAES0HM3WbJLrs4OzRY77iEkDYptgzmZJqMA72j
|
||||
CEyfuqRaAMR/QR0D4Lrt8xZlrMA19t8mkdc0GixzlKD0naIISbVyNmXz4Dc2uqv2
|
||||
asGWByPm/m4UmocO9rBX/WlylqC7hLffKRiO1sXdIYWjc2GyGCWt5MrVBanXyXFz
|
||||
SElBFF5XJbVY5gtw+9sGWXyDOiLaTVOd55Td5Rf1Lst6QKWMMk3vdpUAIXMciAPh
|
||||
UiAipbDFwl5Vxjri/nZoCuQWlEOQ6rthKDZJz4qAu4GN1WFeB8pgIPHKkGA9v6Nn
|
||||
1ZvnewsNqq6jYy9WUE/Y4NgZPtdoH8dHQiKav7KXu2yVpbR0iaDJP8oRUNhiE8fe
|
||||
x41Iim7YWjwoYtc97L194WQ=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,25 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
|
||||
{
|
||||
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,110 @@
|
||||
|
||||
> temp_project@0.1.0 lint
|
||||
> eslint
|
||||
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/actions/auth-actions.ts
|
||||
42:19 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
85:19 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/app/(auth)/login/page.tsx
|
||||
15:3 warning 'InputGroup' is defined but never used @typescript-eslint/no-unused-vars
|
||||
16:3 warning 'Stack' is defined but never used @typescript-eslint/no-unused-vars
|
||||
33:19 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
83:20 error `'` can be escaped with `'`, `‘`, `'`, `’` react/no-unescaped-entities
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/app/(auth)/register/page.tsx
|
||||
37:19 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/app/admin/users/page.tsx
|
||||
14:3 warning 'IconButton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:10 warning 'FaTrash' is defined but never used @typescript-eslint/no-unused-vars
|
||||
32:38 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
36:52 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
77:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
84:30 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/app/page.tsx
|
||||
124:53 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
135:39 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
139:50 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/app/predictions/page.tsx
|
||||
13:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
139:62 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
158:39 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
168:45 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/app/profile/page.tsx
|
||||
24:50 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
38:6 warning React Hook useEffect has a missing dependency: 'fetchProfile'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
47:17 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
65:17 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/auth/login-form.tsx
|
||||
6:3 warning 'Box' is defined but never used @typescript-eslint/no-unused-vars
|
||||
10:3 warning 'Stack' is defined but never used @typescript-eslint/no-unused-vars
|
||||
54:16 error `'` can be escaped with `'`, `‘`, `'`, `’` react/no-unescaped-entities
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/auth/register-form.tsx
|
||||
6:3 warning 'Box' is defined but never used @typescript-eslint/no-unused-vars
|
||||
10:3 warning 'Stack' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/matches/league-match-list.tsx
|
||||
4:10 warning 'Box' is defined but never used @typescript-eslint/no-unused-vars
|
||||
15:42 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
45:49 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/matches/match-card.tsx
|
||||
5:19 warning 'FaTrophy' is defined but never used @typescript-eslint/no-unused-vars
|
||||
9:47 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
19:17 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/matches/match-card.tsx:19:17
|
||||
17 | try {
|
||||
18 | const date = new Date(Number(match.mst_utc));
|
||||
> 19 | setFormattedDate(date.toLocaleTimeString("tr-TR", { hour: '2-digit', minute: '2-digit' }));
|
||||
| ^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
20 | } catch (e) {
|
||||
21 | setFormattedDate("-");
|
||||
22 | } react-hooks/set-state-in-effect
|
||||
20:22 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
33:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
51:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
71:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/predictions/prediction-modal.tsx
|
||||
8:3 warning 'Select' is defined but never used @typescript-eslint/no-unused-vars
|
||||
10:3 warning 'Badge' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/components/predictions/prediction-view.tsx
|
||||
3:85 warning 'Separator' is defined but never used @typescript-eslint/no-unused-vars
|
||||
12:36 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
45:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/contexts/AuthContext.tsx
|
||||
39:7 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
/Users/piton/Documents/GitHub/Suggest-Bet-FE/src/contexts/AuthContext.tsx:39:7
|
||||
37 |
|
||||
38 | if (storedToken && storedUser) {
|
||||
> 39 | setToken(storedToken);
|
||||
| ^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
40 | setUser(JSON.parse(storedUser));
|
||||
41 | // Optional: Verify token validity with a /me endpoint if available
|
||||
42 | } react-hooks/set-state-in-effect
|
||||
|
||||
✖ 45 problems (27 errors, 18 warnings)
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
{
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"solutions": "Solutions",
|
||||
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||
"artificial-intelligence": "Artificial Intelligence",
|
||||
"error": {
|
||||
"not-found": "Oops! The page you're looking for doesn't exist.",
|
||||
"404": "404",
|
||||
"back-to-home": "Go back home",
|
||||
"generic": "An unexpected error occurred.",
|
||||
"network": "Network error. Please check your connection.",
|
||||
"unauthorized": "Your session has expired. Please sign in again.",
|
||||
"forbidden": "You don't have permission to access this resource.",
|
||||
"server": "Server error. Please try again later.",
|
||||
"match-not-found": "Match not found.",
|
||||
"prediction-not-found": "Prediction not available for this match."
|
||||
},
|
||||
"email": "E-Mail",
|
||||
"password": "Password",
|
||||
"auth": {
|
||||
"remember-me": "Remember Me",
|
||||
"dont-have-account": "Don't have an account?",
|
||||
"sign-out": "Sign Out",
|
||||
"sign-up": "Sign Up",
|
||||
"sign-in": "Sign In",
|
||||
"welcome-back": "Welcome Back",
|
||||
"subtitle": "Enter your email and password to sign in",
|
||||
"already-have-an-account": "Already have an account?",
|
||||
"create-an-account-now": "Create an account now",
|
||||
"first-name": "First Name",
|
||||
"last-name": "Last Name",
|
||||
"confirm-password": "Confirm Password",
|
||||
"forgot-password": "Forgot Password?",
|
||||
"or-continue-with": "Or continue with",
|
||||
"logging-in": "Signing in...",
|
||||
"registering": "Creating account..."
|
||||
},
|
||||
"all-right-reserved": "All rights reserved.",
|
||||
"privacy-policy": "Privacy Policy",
|
||||
"terms-of-service": "Terms of Service",
|
||||
"name": "Name",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"dashboard": "Dashboard",
|
||||
"matches": "Matches",
|
||||
"predictions": "Predictions",
|
||||
"coupon-builder": "Coupon Builder",
|
||||
"coupon-history": "Coupon History",
|
||||
"profile": "Profile",
|
||||
"admin": "Admin Panel",
|
||||
"teams": "Teams",
|
||||
"leagues": "Leagues",
|
||||
"h2h": "Head to Head",
|
||||
"analysis": "Analysis",
|
||||
"spor-toto": "Spor Toto",
|
||||
"how-it-works": "How It Works",
|
||||
"pricing": "Pricing",
|
||||
"contact": "Contact",
|
||||
"coupons": "Coupons",
|
||||
"tools": "Tools"
|
||||
},
|
||||
|
||||
"landing": {
|
||||
"hero-title": "AI-Powered Betting Predictions",
|
||||
"hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.",
|
||||
"get-started": "Get Started",
|
||||
"learn-more": "Learn More",
|
||||
"features-title": "Why Choose Suggest Bet?",
|
||||
"feature-ai": "AI Predictions",
|
||||
"feature-ai-desc": "Powered by V20 ensemble model with 95%+ data quality scoring.",
|
||||
"feature-value": "Value Bets",
|
||||
"feature-value-desc": "Find EV+ betting opportunities with expected value analysis.",
|
||||
"feature-coupon": "Smart Coupons",
|
||||
"feature-coupon-desc": "AI builds optimized coupons with 5 different strategies.",
|
||||
"feature-live": "Live Tracking",
|
||||
"feature-live-desc": "Real-time match tracking with live score updates.",
|
||||
"stats-predictions": "Predictions Made",
|
||||
"stats-accuracy": "Accuracy Rate",
|
||||
"stats-users": "Active Users",
|
||||
"stats-matches": "Matches Analyzed"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome back",
|
||||
"todays-matches": "Today's Matches",
|
||||
"daily-banko": "Daily Banko",
|
||||
"upcoming-predictions": "Upcoming Predictions",
|
||||
"value-bets": "Value Bets",
|
||||
"your-stats": "Your Stats",
|
||||
"view-all": "View All",
|
||||
"no-matches": "No matches available today.",
|
||||
"no-predictions": "No predictions available."
|
||||
},
|
||||
|
||||
"matches": {
|
||||
"title": "Matches",
|
||||
"filter-sport": "Sport",
|
||||
"football": "Football",
|
||||
"basketball": "Basketball",
|
||||
"all-leagues": "All Leagues",
|
||||
"active-leagues": "Active Leagues",
|
||||
"live": "LIVE",
|
||||
"not-started": "Not Started",
|
||||
"finished": "Finished",
|
||||
"no-matches": "No matches found.",
|
||||
"search-teams": "Search teams...",
|
||||
"view-prediction": "View Prediction",
|
||||
"match-details": "Match Details",
|
||||
"lineups": "Lineups",
|
||||
"statistics": "Statistics",
|
||||
"odds": "Odds",
|
||||
"head-to-head": "Head to Head",
|
||||
"recent-matches": "Recent Matches",
|
||||
"home-team": "Home",
|
||||
"away-team": "Away",
|
||||
"vs": "vs"
|
||||
},
|
||||
|
||||
"predictions": {
|
||||
"title": "Predictions",
|
||||
"upcoming": "Upcoming",
|
||||
"value-bets": "Value Bets",
|
||||
"history": "History",
|
||||
"model-version": "Model Version",
|
||||
"confidence": "Win Probability",
|
||||
"risk": "Risk",
|
||||
"risk-level": "Risk Level",
|
||||
"main-pick": "Main Pick",
|
||||
"supporting-picks": "Supporting Picks",
|
||||
"aggressive-pick": "Aggressive Pick",
|
||||
"score-prediction": "Score Prediction",
|
||||
"market-board": "Market Board",
|
||||
"reasoning": "Reasoning Factors",
|
||||
"data-quality": "Data Quality",
|
||||
"engine-breakdown": "Engine Breakdown",
|
||||
"bet-advice": "Bet Advice",
|
||||
"bet-summary": "Bet Summary",
|
||||
"expected-value": "Expected Value",
|
||||
"no-predictions": "No predictions available.",
|
||||
"accuracy": "Accuracy",
|
||||
"total-predictions": "Total Predictions",
|
||||
"correct-predictions": "Correct Predictions",
|
||||
"scenario-top5": "Top 5 Score Scenarios",
|
||||
"prediction-reasons": {
|
||||
"market_signal_dominant": "Market signals strongly support this pick.",
|
||||
"lineup_signal_strong": "Lineup analysis and missing players strength this prediction.",
|
||||
"below_calibrated_conf_threshold": "Calculated confidence level is below the risk threshold.",
|
||||
"negative_model_edge": "The model did not find sufficient value (EV+) in this bet.",
|
||||
"market_passed_all_gates": "Passed all safety and risk checks successfully.",
|
||||
"high_volatility_risk": "High volatility and uncertainty risk detected.",
|
||||
"low_liquidity_risk": "Odds may be unreliable due to low liquidity.",
|
||||
"odds_movement_suspicious": "Suspicious odds movements detected, proceed with caution.",
|
||||
"model_confidence_low": "AI model measured a low probability of success.",
|
||||
"data_quality_issue": "Analysis limited due to missing or inconsistent data.",
|
||||
"playable_pick_found": "High-value playable betting opportunity detected.",
|
||||
"odds_anomaly_detected": "Odds anomaly detected (Bookmaker Traps).",
|
||||
"heavy_favorite_vulnerable": "Favorite team metrics look weak compared to implied odds.",
|
||||
"momentum_loss_detected": "Serious momentum loss and form dip detected for the favorite.",
|
||||
"safe_btts_confirmed": "Both teams to score potential statistically confirmed.",
|
||||
"team_form_signal_dominant": "Team form data is the primary driver for this pick.",
|
||||
"basketball_points_model": "Calculated based on basketball points projection model.",
|
||||
"High Upset Potential detected by UpsetEngine": "High upset potential detected (UpsetEngine).",
|
||||
"market_odds_missing": "Market odds are missing, analysis is limited.",
|
||||
"high_risk_low_data_quality": "High risk: Low data quality.",
|
||||
"lineup_insufficient_for_market": "Lineup data is insufficient for this market.",
|
||||
"lineup_not_confirmed": "Lineup is not officially confirmed yet.",
|
||||
"player_form_signal_strong": "Strong individual player form signals detected.",
|
||||
"player_form_signal_limited": "Insufficient player statistics available.",
|
||||
"limited_data_confidence": "Confidence score reduced due to limited data.",
|
||||
"lineup_signal_weak": "Signal from lineup interaction is weak.",
|
||||
"lineup_probable_xi_used": "Analysis based on probable starting XI.",
|
||||
"missing_full_ms_odds": "Main match result odds are missing.",
|
||||
"lineup_probable_not_confirmed": "Lineup is not confirmed, carrying inherent risk.",
|
||||
"lineup_unavailable": "Pre-match lineup data is unavailable.",
|
||||
"lineup_incomplete": "Some player data in the lineup is missing.",
|
||||
"missing_referee": "Referee information is missing.",
|
||||
"missing_moneyline_odds": "Moneyline odds are missing.",
|
||||
"missing_total_odds": "Over/Under odds are missing.",
|
||||
"missing_spread_odds": "Spread (Handicap) odds are missing.",
|
||||
"no_bet_conditions_met": "The algorithm could not find a safe/valuable bet for this match.",
|
||||
"insufficient_play_score": "Play score is below the playability threshold.",
|
||||
"no_ev_edge_minimum_stake": "Passed safety gates but no mathematical edge — minimum stake applied.",
|
||||
"upset_risk_detected": "High upset risk detected, proceed with caution."
|
||||
},
|
||||
"ev-edge": "EV Edge",
|
||||
"implied-prob": "Market Probability",
|
||||
"model-prob": "Model Probability",
|
||||
"kelly-stake": "Kelly Stake",
|
||||
"edge-positive": "Value Detected",
|
||||
"edge-negative": "No Value",
|
||||
"quant-analysis": "Quantitative Analysis",
|
||||
"vs-bookie": "Model vs Bookie",
|
||||
"engine-breakdown-title": "Engine Breakdown",
|
||||
"engine-team": "Team Strength",
|
||||
"engine-player": "Player Impact",
|
||||
"engine-odds": "Odds Analysis",
|
||||
"engine-referee": "Referee Factor",
|
||||
"bet-advice-play": "PLAY",
|
||||
"bet-advice-pass": "PASS",
|
||||
"market-labels": {
|
||||
"ML": "Moneyline",
|
||||
"MS": "Match Result",
|
||||
"DC": "Double Chance",
|
||||
"TOTAL": "Total Points",
|
||||
"SPREAD": "Spread",
|
||||
"OU15": "Total Goals 1.5",
|
||||
"OU25": "Total Goals 2.5",
|
||||
"OU35": "Total Goals 3.5",
|
||||
"BTTS": "Both Teams To Score",
|
||||
"HT": "Half Time Result",
|
||||
"HTFT": "Half Time / Full Time",
|
||||
"HT/FT": "Half Time / Full Time",
|
||||
"OE": "Odd / Even",
|
||||
"HT_OU05": "First Half 0.5 Goals"
|
||||
},
|
||||
"ui": {
|
||||
"summary-title": "Prediction Summary",
|
||||
"summary-info": "Shows what stands out first and then explains why it stands out.",
|
||||
"main-recommendation": "Main Recommendation",
|
||||
"best-market-copy": "is the strongest option in this market.",
|
||||
"confidence-label": "Confidence",
|
||||
"odds-label": "Odds",
|
||||
"edge-label": "Expected Advantage (Edge)",
|
||||
"edge-info": "Edge is the gap between the model probability and the market probability. If it is positive, the model sees value in this price.",
|
||||
"stake-label": "Suggested Bet Size (Stake)",
|
||||
"stake-label-short": "Bet Size",
|
||||
"stake-info": "Stake is the suggested bet size. 2.0u means a 2-unit bet in your own bankroll plan.",
|
||||
"play-score-label": "Playability Score",
|
||||
"playability-label": "Playability",
|
||||
"quick-read": "Quick read",
|
||||
"lineup-source": "Lineup Source",
|
||||
"model-label": "Model",
|
||||
"engine-info": "Shows which components influence the prediction the most.",
|
||||
"best-single-pick": "Best Single Pick",
|
||||
"alternative-markets": "Alternative Markets",
|
||||
"alternative-markets-info": "Options outside the main recommendation.",
|
||||
"alternative": "Alternative",
|
||||
"pass-market": "Rejected Market",
|
||||
"all-markets-title": "All Markets",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
"coupons": {
|
||||
"title": "Coupon Builder",
|
||||
"builder-title": "Coupon Builder",
|
||||
"builder-subtitle": "Build a coupon from upcoming football matches. Select your own pool or let AI scan the live_matches table automatically.",
|
||||
"history-title": "Coupon History",
|
||||
"step-select": "Select Matches",
|
||||
"step-strategy": "Choose Strategy",
|
||||
"step-suggest": "AI Suggestion",
|
||||
"step-review": "Review & Save",
|
||||
"strategy": "Strategy",
|
||||
"strategy-safe": "Safe",
|
||||
"strategy-balanced": "Balanced",
|
||||
"strategy-risky": "Risky",
|
||||
"strategy-aggressive": "Aggressive",
|
||||
"strategy-value": "Value",
|
||||
"strategy-miracle": "Miracle",
|
||||
"strategy-safe-desc": "Low risk, lower odds, higher win probability",
|
||||
"strategy-balanced-desc": "Balanced risk/reward approach",
|
||||
"strategy-aggressive-desc": "Higher risk, higher potential returns",
|
||||
"strategy-value-desc": "Focus on EV+ value opportunities",
|
||||
"strategy-miracle-desc": "Maximum risk, maximum potential reward",
|
||||
"selected-matches": "Selected Matches",
|
||||
"available-matches": "Available Matches",
|
||||
"total-odd": "Total Odd",
|
||||
"generate-coupon": "Generate AI Coupon",
|
||||
"save-coupon": "Save Coupon",
|
||||
"clear-all": "Clear All",
|
||||
"no-items": "No matches selected yet.",
|
||||
"no-history": "No coupons found.",
|
||||
"no-coupons": "No coupons found.",
|
||||
"no-matches": "No matches available.",
|
||||
"my-stats": "My Betting Stats",
|
||||
"total-coupons": "Total Coupons",
|
||||
"win-rate": "Win Rate",
|
||||
"won": "Won",
|
||||
"lost": "Lost",
|
||||
"pending": "Pending",
|
||||
"my-coupon": "My Coupon",
|
||||
"my-coupon-help": "This panel shows the strategy, the selected match pool, and the bets returned by AI.",
|
||||
"ai-suggest": "AI Suggest",
|
||||
"suggestion-ready": "AI suggestion ready!",
|
||||
"empty-coupon": "Select matches to add to your coupon.",
|
||||
"coupon": "Coupon",
|
||||
"candidate-match-count": "Candidate Matches",
|
||||
"candidate-match-count-help": "How many upcoming football matches are currently available for coupon generation.",
|
||||
"finished-match-count": "Finished Matches",
|
||||
"finished-match-count-help": "Optional reference list of finished football matches. These are never used for coupon prediction.",
|
||||
"selected-match-count": "Selected Matches",
|
||||
"selected-match-count-help": "If you choose matches manually, AI will only build the coupon from this pool.",
|
||||
"suggested-bet-count": "Suggested Bets",
|
||||
"suggested-bet-count-help": "How many bets the AI placed into the coupon suggestion.",
|
||||
"total-odds": "Total Odds",
|
||||
"total-odds-help": "The combined odds of all suggested bets in the coupon.",
|
||||
"candidate-pool-title": "Coupon Candidate Pool",
|
||||
"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",
|
||||
"upcoming-badge": "Upcoming",
|
||||
"upcoming-reference": "Upcoming pool",
|
||||
"finished-badge": "Finished",
|
||||
"prediction-locked": "Prediction Locked",
|
||||
"read-only-short": "Read only",
|
||||
"selected-short": "Selected",
|
||||
"select-match": "Select",
|
||||
"match-state": "Match State",
|
||||
"selection-mode": "AI Pool",
|
||||
"manual-pool": "Manual pool",
|
||||
"auto-pool": "Automatic pool",
|
||||
"finished-reference-only": "Reference only",
|
||||
"no-upcoming-matches": "There are no upcoming football matches available for coupon generation right now.",
|
||||
"finished-matches-title": "Finished Matches",
|
||||
"finished-matches-help": "These matches are shown only for reference. They cannot be selected and are filtered out on the backend before any coupon prediction is created.",
|
||||
"finished-matches-subtitle": "Optional archive view. Scores and post-match stats are never sent into the coupon prediction flow.",
|
||||
"show-finished-matches": "Show finished matches",
|
||||
"hide-finished-matches": "Hide finished matches",
|
||||
"no-finished-matches": "No finished football matches were found for the current view.",
|
||||
"manual-selection-active": "AI will only use the matches you selected below.",
|
||||
"automatic-selection-active": "No manual selection yet. AI will pick from the full upcoming match pool.",
|
||||
"selected-matches-panel-title": "Selected Match Pool",
|
||||
"selected-matches-empty": "You have not selected any matches yet. If you continue like this, AI will build from the full upcoming pool.",
|
||||
"remove-match": "Remove",
|
||||
"manual-selection-helper": "Manual mode is active. AI will only evaluate the matches you selected.",
|
||||
"automatic-selection-helper": "Automatic mode is active. AI will evaluate the full upcoming football match pool.",
|
||||
"suggested-bets-title": "AI Suggested Bets",
|
||||
"suggested-bets-title-help": "These are the bets returned by the coupon engine according to your chosen strategy.",
|
||||
"expected-win-rate": "Expected Win Rate",
|
||||
"bet-count": "Bet Count",
|
||||
"confidence-label": "Confidence",
|
||||
"probability-label": "Probability",
|
||||
"risk-label": "Risk",
|
||||
"data-quality-label": "Data Quality",
|
||||
"rejected-matches-title": "Rejected Matches",
|
||||
"no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest."
|
||||
},
|
||||
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"account-settings": "Account Settings",
|
||||
"account-info": "Account Information",
|
||||
"personal-info": "Personal Information",
|
||||
"usage-limits": "Usage Limits",
|
||||
"subscription": "Subscription",
|
||||
"save-changes": "Save Changes",
|
||||
"change-password": "Change Password",
|
||||
"change-password-desc": "Keep your account secure. Use a strong password with at least 8 characters.",
|
||||
"current-password": "Current Password",
|
||||
"new-password": "New Password",
|
||||
"confirm-password": "Confirm Password",
|
||||
"first-name": "First Name",
|
||||
"last-name": "Last Name",
|
||||
"full-name": "Full Name",
|
||||
"email": "Email Address",
|
||||
"role": "Role",
|
||||
"member-since": "Member Since",
|
||||
"betting-stats": "Betting Statistics",
|
||||
"total-coupons": "Total Coupons",
|
||||
"win-rate": "Win Rate",
|
||||
"total-profit": "Total Profit"
|
||||
},
|
||||
|
||||
"leagues": {
|
||||
"title": "Leagues & Teams",
|
||||
"countries": "Countries",
|
||||
"leagues": "Leagues",
|
||||
"countries-leagues": "Countries & Leagues",
|
||||
"search-at-least-2": "Type at least 2 characters to search teams."
|
||||
},
|
||||
|
||||
"h2h": {
|
||||
"title": "Head to Head",
|
||||
"team-1": "Team 1",
|
||||
"team-2": "Team 2",
|
||||
"search-team": "Search for a team...",
|
||||
"compare": "Compare",
|
||||
"draws": "Draws",
|
||||
"no-matches-found": "No head-to-head matches found between these teams."
|
||||
},
|
||||
|
||||
"analysis": {
|
||||
"title": "Multi-Match Analysis",
|
||||
"select-matches": "Select Matches",
|
||||
"analyze-matches": "Analyze Matches",
|
||||
"history": "Analysis History",
|
||||
"selected": "selected",
|
||||
"select-at-least-2": "Select at least 2 matches to analyze.",
|
||||
"analysis-complete": "Analysis complete!",
|
||||
"matches-analyzed": "matches analyzed",
|
||||
"no-history": "No analysis history yet."
|
||||
},
|
||||
|
||||
"spor-toto": {
|
||||
"title": "Spor Toto",
|
||||
"sync-bulletins": "Sync Bulletins",
|
||||
"sync-success": "Bulletins synced successfully!",
|
||||
"select-bulletin": "Select Bulletin",
|
||||
"choose-bulletin": "Choose a bulletin...",
|
||||
"bulletin-label": "Cycle #{cycle} - {date}",
|
||||
"cycle-no": "Cycle No.",
|
||||
"cycle-no-short": "Cycle #{cycle}",
|
||||
"draw-date": "Draw Date",
|
||||
"status": "Status",
|
||||
"matches": "Matches",
|
||||
"choose-strategy": "Choose Strategy",
|
||||
"generate-prediction": "Generate AI Prediction",
|
||||
"selected": "Selected",
|
||||
"strategy-conservative": "Conservative",
|
||||
"strategy-conservative-desc": "100 columns, low risk approach",
|
||||
"strategy-balanced": "Balanced",
|
||||
"strategy-balanced-desc": "500 columns, balanced risk/reward",
|
||||
"strategy-aggressive": "Aggressive",
|
||||
"strategy-aggressive-desc": "2500 columns, maximum coverage",
|
||||
"strategy-formula": "Formula 6%",
|
||||
"strategy-formula-desc": "6% sampling, cost-efficient",
|
||||
"bulletin-history": "Bulletin History",
|
||||
"rollover-stats": "Rollover Stats",
|
||||
"prediction-generated": "Prediction generated successfully!"
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
"subtitle": "Manage users, monitor predictions, and system overview.",
|
||||
"overview": "Overview",
|
||||
"analytics": "Analytics Overview",
|
||||
"user-management": "User Management",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"usage-limits": "Usage Limits",
|
||||
"total-users": "Total Users",
|
||||
"active-users": "Active Users",
|
||||
"active-matches": "Active Matches",
|
||||
"total-predictions": "Total Predictions",
|
||||
"total-coupons": "Total Coupons",
|
||||
"toggle-active": "Toggle Active",
|
||||
"update-role": "Update Role",
|
||||
"update-subscription": "Update Subscription",
|
||||
"delete-user": "Delete User",
|
||||
"reset-limits": "Reset All Limits",
|
||||
"setting-key": "Setting Key",
|
||||
"setting-value": "Setting Value",
|
||||
"update-setting": "Update",
|
||||
"admin-badge": "Admin",
|
||||
"user-name": "Name",
|
||||
"user-email": "Email",
|
||||
"user-role": "Role",
|
||||
"user-status": "Status",
|
||||
"no-users": "No users found."
|
||||
},
|
||||
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"back": "Back",
|
||||
"refresh": "Refresh",
|
||||
"clear": "Clear",
|
||||
"all": "All",
|
||||
"no-data": "No data available.",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"date": "Date",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"page": "Page",
|
||||
"of": "of",
|
||||
"items-per-page": "Items per page",
|
||||
"showing": "Showing",
|
||||
"results": "results"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
{
|
||||
"home": "Anasayfa",
|
||||
"about": "Hakkında",
|
||||
"solutions": "Çözümler",
|
||||
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
|
||||
"artificial-intelligence": "Yapay Zeka",
|
||||
"error": {
|
||||
"not-found": "Aradığınız sayfa bulunamadı.",
|
||||
"404": "404",
|
||||
"back-to-home": "Ana sayfaya dön",
|
||||
"generic": "Beklenmeyen bir hata oluştu.",
|
||||
"network": "Ağ hatası. Lütfen bağlantınızı kontrol edin.",
|
||||
"unauthorized": "Oturumunuz sona erdi. Lütfen tekrar giriş yapın.",
|
||||
"forbidden": "Bu kaynağa erişim yetkiniz yok.",
|
||||
"server": "Sunucu hatası. Lütfen daha sonra tekrar deneyin.",
|
||||
"match-not-found": "Maç bulunamadı.",
|
||||
"prediction-not-found": "Bu maç için tahmin mevcut değil."
|
||||
},
|
||||
"email": "E-Posta",
|
||||
"password": "Şifre",
|
||||
"auth": {
|
||||
"remember-me": "Beni Hatırla",
|
||||
"dont-have-account": "Hesabınız yok mu?",
|
||||
"sign-out": "Çıkış Yap",
|
||||
"sign-up": "Kayıt Ol",
|
||||
"sign-in": "Giriş Yap",
|
||||
"welcome-back": "Tekrar Hoş Geldiniz",
|
||||
"subtitle": "E-posta ve şifrenizle giriş yapın",
|
||||
"already-have-an-account": "Zaten hesabınız var mı?",
|
||||
"create-an-account-now": "Hemen hesap oluşturun",
|
||||
"first-name": "Ad",
|
||||
"last-name": "Soyad",
|
||||
"confirm-password": "Şifreyi Onayla",
|
||||
"forgot-password": "Şifremi Unuttum?",
|
||||
"or-continue-with": "Veya şununla devam edin",
|
||||
"logging-in": "Giriş yapılıyor...",
|
||||
"registering": "Hesap oluşturuluyor..."
|
||||
},
|
||||
"all-right-reserved": "Tüm hakları saklıdır.",
|
||||
"privacy-policy": "Gizlilik Politikası",
|
||||
"terms-of-service": "Kullanım Koşulları",
|
||||
"name": "Ad",
|
||||
"low": "Düşük",
|
||||
"medium": "Orta",
|
||||
"high": "Yüksek",
|
||||
|
||||
"nav": {
|
||||
"home": "Anasayfa",
|
||||
"dashboard": "Kontrol Paneli",
|
||||
"matches": "Maçlar",
|
||||
"predictions": "Tahminler",
|
||||
"coupon-builder": "Kupon Oluşturucu",
|
||||
"coupon-history": "Kupon Geçmişi",
|
||||
"profile": "Profil",
|
||||
"admin": "Yönetim Paneli",
|
||||
"teams": "Takımlar",
|
||||
"leagues": "Ligler",
|
||||
"h2h": "Karşılıklı Karşılaşma",
|
||||
"analysis": "Analiz",
|
||||
"spor-toto": "Spor Toto",
|
||||
"how-it-works": "Nasıl Çalışır",
|
||||
"pricing": "Fiyatlandırma",
|
||||
"contact": "İletişim",
|
||||
"coupons": "Kuponlar",
|
||||
"tools": "Araçlar"
|
||||
},
|
||||
|
||||
"landing": {
|
||||
"hero-title": "Yapay Zeka Destekli Bahis Tahminleri",
|
||||
"hero-subtitle": "Gelişmiş yapay zeka tahmin motorumuz ile daha akıllı bahisler yapın. Maçları analiz edin, değerli bahisleri keşfedin ve kazanan kuponlar oluşturun.",
|
||||
"get-started": "Başla",
|
||||
"learn-more": "Daha Fazla",
|
||||
"features-title": "Neden Suggest Bet?",
|
||||
"feature-ai": "Yapay Zeka Tahminleri",
|
||||
"feature-ai-desc": "%95+ veri kalitesi puanlama ile V20 ensemble modeli tarafından desteklenmektedir.",
|
||||
"feature-value": "Değerli Bahisler",
|
||||
"feature-value-desc": "Beklenen değer analizi ile EV+ bahis fırsatlarını bulun.",
|
||||
"feature-coupon": "Akıllı Kuponlar",
|
||||
"feature-coupon-desc": "Yapay zeka 5 farklı strateji ile optimize edilmiş kuponlar oluşturur.",
|
||||
"feature-live": "Canlı Takip",
|
||||
"feature-live-desc": "Canlı skor güncellemeleri ile gerçek zamanlı maç takibi.",
|
||||
"stats-predictions": "Yapılan Tahmin",
|
||||
"stats-accuracy": "Doğruluk Oranı",
|
||||
"stats-users": "Aktif Kullanıcı",
|
||||
"stats-matches": "Analiz Edilen Maç"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"title": "Kontrol Paneli",
|
||||
"welcome": "Tekrar hoş geldiniz",
|
||||
"todays-matches": "Bugünkü Maçlar",
|
||||
"daily-banko": "Günün Bankosu",
|
||||
"upcoming-predictions": "Yaklaşan Tahminler",
|
||||
"value-bets": "Değerli Bahisler",
|
||||
"your-stats": "İstatistikleriniz",
|
||||
"view-all": "Tümünü Gör",
|
||||
"no-matches": "Bugün maç bulunmuyor.",
|
||||
"no-predictions": "Tahmin bulunmuyor."
|
||||
},
|
||||
|
||||
"matches": {
|
||||
"title": "Maçlar",
|
||||
"filter-sport": "Spor",
|
||||
"football": "Futbol",
|
||||
"basketball": "Basketbol",
|
||||
"all-leagues": "Tüm Ligler",
|
||||
"active-leagues": "Aktif Ligler",
|
||||
"live": "CANLI",
|
||||
"not-started": "Başlamadı",
|
||||
"finished": "Bitti",
|
||||
"no-matches": "Maç bulunamadı.",
|
||||
"search-teams": "Takım ara...",
|
||||
"view-prediction": "Tahmini Gör",
|
||||
"match-details": "Maç Detayları",
|
||||
"lineups": "Kadrolar",
|
||||
"statistics": "İstatistikler",
|
||||
"odds": "Oranlar",
|
||||
"head-to-head": "Karşılıklı Sonuçlar",
|
||||
"recent-matches": "Son Maçlar",
|
||||
"home-team": "Ev Sahibi",
|
||||
"away-team": "Deplasman",
|
||||
"vs": "vs"
|
||||
},
|
||||
|
||||
"predictions": {
|
||||
"title": "Tahminler",
|
||||
"upcoming": "Yaklaşan",
|
||||
"value-bets": "Değerli Bahisler",
|
||||
"history": "Geçmiş",
|
||||
"model-version": "Model Versiyonu",
|
||||
"confidence": "Kazanma Olasılığı",
|
||||
"risk": "Risk",
|
||||
"risk-level": "Risk Seviyesi",
|
||||
"main-pick": "Ana Tahmin",
|
||||
"supporting-picks": "Destekleyici Tahminler",
|
||||
"aggressive-pick": "Agresif Tahmin",
|
||||
"score-prediction": "Skor Tahmini",
|
||||
"market-board": "Market Panosu",
|
||||
"reasoning": "Analiz Faktörleri",
|
||||
"data-quality": "Veri Kalitesi",
|
||||
"engine-breakdown": "Motor Kırılımı",
|
||||
"bet-advice": "Bahis Tavsiyesi",
|
||||
"bet-summary": "Bahis Özeti",
|
||||
"expected-value": "Beklenen Değer",
|
||||
"no-predictions": "Tahmin bulunmuyor.",
|
||||
"accuracy": "Doğruluk",
|
||||
"total-predictions": "Toplam Tahmin",
|
||||
"correct-predictions": "Doğru Tahmin",
|
||||
"scenario-top5": "En Olası 5 Skor Senaryosu",
|
||||
"prediction-reasons": {
|
||||
"market_signal_dominant": "Piyasa sinyalleri bu tercihi güçlü şekilde destekliyor.",
|
||||
"lineup_signal_strong": "Kadro analizi ve eksikler bu tahmini güçlendiriyor.",
|
||||
"below_calibrated_conf_threshold": "Hesaplanan güven seviyesi risk eşiğinin altında kaldı.",
|
||||
"negative_model_edge": "Model, bu bahiste yeterli değer (value) görmedi.",
|
||||
"market_passed_all_gates": "Tüm güvenlik ve risk kontrollerinden başarıyla geçti.",
|
||||
"high_volatility_risk": "Maçta yüksek volatilite ve belirsizlik riski var.",
|
||||
"low_liquidity_risk": "Düşük işlem hacmi nedeniyle oranlar güvenilmez olabilir.",
|
||||
"odds_movement_suspicious": "Anormal oran hareketleri tespit edildi, dikkatli olun.",
|
||||
"model_confidence_low": "Yapay zeka modelinin ölçtüğü kazanma olasılığı düşük.",
|
||||
"data_quality_issue": "Eksik veya tutarsız veri nedeniyle analiz kısıtlı.",
|
||||
"playable_pick_found": "Oynanabilir, değeri yüksek bir bahis fırsatı tespit edildi.",
|
||||
"odds_anomaly_detected": "Oranlarda anomali tespit edildi (Bookmaker Traps).",
|
||||
"heavy_favorite_vulnerable": "Favori takımın verileri beklenen oranlara göre zayıf görünüyor.",
|
||||
"momentum_loss_detected": "Favori takımda ciddi momentum kaybı ve form düşüşü var.",
|
||||
"safe_btts_confirmed": "Karşılıklı gol potansiyeli istatistiksel olarak doğrulandı.",
|
||||
"team_form_signal_dominant": "Takım form verileri bu tercihin ana dayanağıdır.",
|
||||
"upset_risk_detected": "Sürpriz sonuç riski yüksek, dikkatli olun.",
|
||||
"basketball_points_model": "Basketbol sayı projeksiyon modeline göre hesaplandı.",
|
||||
"High Upset Potential detected by UpsetEngine": "Yüksek sürpriz potansiyeli tespit edildi (UpsetEngine).",
|
||||
"market_odds_missing": "Piyasa oranları eksik, analiz kısıtlı.",
|
||||
"high_risk_low_data_quality": "Yüksek risk: Düşük veri kalitesi.",
|
||||
"lineup_insufficient_for_market": "Kadro verisi bu pazar için yetersiz.",
|
||||
"lineup_not_confirmed": "Kadro bilgisi henüz resmi olarak onaylanmadı.",
|
||||
"player_form_signal_strong": "Bireysel oyuncu form sinyalleri güçlü.",
|
||||
"player_form_signal_limited": "Yeterli oyuncu istatistiği bulunmuyor.",
|
||||
"limited_data_confidence": "Sınırlı veri nedeniyle güven skoru yansıtmaları düşürüldü.",
|
||||
"lineup_signal_weak": "Kadro etkileşiminden gelen sinyal zayıf.",
|
||||
"lineup_probable_xi_used": "Tahmini muhtemel 11 kullanılarak analiz edildi.",
|
||||
"missing_full_ms_odds": "Maç sonucu ana hata oranları eksik.",
|
||||
"lineup_probable_not_confirmed": "Kadro henüz netleşmediği için risk içeriyor.",
|
||||
"lineup_unavailable": "Maç öncesi kadro bilgisine ulaşılamadı.",
|
||||
"lineup_incomplete": "Kadrodaki bazı oyuncu verileri eksik.",
|
||||
"missing_referee": "Hakem bilgisi eksik.",
|
||||
"missing_moneyline_odds": "Taraf bahsi oranları eksik.",
|
||||
"missing_total_odds": "Alt/Üst oranları eksik.",
|
||||
"missing_spread_odds": "Handikap oranları eksik.",
|
||||
"no_bet_conditions_met": "Algoritma bu maç için güvenli/değerli bir bahis önerisi bulamadı.",
|
||||
"insufficient_play_score": "Oynanabilirlik skoru eşiğin altında kaldı.",
|
||||
"no_ev_edge_minimum_stake": "Güvenlik kontrollerini geçti ancak matematik avantaj yok — minimum bahis uygulandı."
|
||||
},
|
||||
"ev-edge": "EV Edge",
|
||||
"implied-prob": "Piyasa Olasılığı",
|
||||
"model-prob": "Model Olasılığı",
|
||||
"kelly-stake": "Kelly Bahis",
|
||||
"edge-positive": "Değer Keşfedildi",
|
||||
"edge-negative": "Değer Yok",
|
||||
"quant-analysis": "Kantitatif Analiz",
|
||||
"vs-bookie": "Model vs Bahisçi",
|
||||
"engine-breakdown-title": "Motor Kırılımı",
|
||||
"engine-team": "Takım Gücü",
|
||||
"engine-player": "Oyuncu Etkisi",
|
||||
"engine-odds": "Oran Analizi",
|
||||
"engine-referee": "Hakem Faktörü",
|
||||
"bet-advice-play": "OYNA",
|
||||
"bet-advice-pass": "OYNAMA",
|
||||
"market-labels": {
|
||||
"ML": "Moneyline",
|
||||
"MS": "Maç Sonucu",
|
||||
"DC": "Çifte Şans",
|
||||
"TOTAL": "Toplam Sayı",
|
||||
"SPREAD": "Handikap",
|
||||
"OU15": "Toplam Gol 1.5",
|
||||
"OU25": "Toplam Gol 2.5",
|
||||
"OU35": "Toplam Gol 3.5",
|
||||
"BTTS": "Karşılıklı Gol",
|
||||
"HT": "İlk Yarı Sonucu",
|
||||
"HTFT": "İlk Yarı / Maç Sonu",
|
||||
"HT/FT": "İlk Yarı / Maç Sonu",
|
||||
"OE": "Tek / Çift",
|
||||
"HT_OU05": "İlk Yarı 0.5 Gol"
|
||||
},
|
||||
"ui": {
|
||||
"summary-title": "Tahmin Özeti",
|
||||
"summary-info": "Önce neyin oynanabileceğini, sonra bunun neden öne çıktığını gösterir.",
|
||||
"main-recommendation": "Ana Öneri",
|
||||
"best-market-copy": "marketinde en güçlü seçim.",
|
||||
"confidence-label": "Güven",
|
||||
"odds-label": "Oran",
|
||||
"edge-label": "Beklenen Avantaj (Edge)",
|
||||
"edge-info": "Edge, model olasılığı ile piyasa olasılığı arasındaki farktır. Pozitifse model bu oranı avantajlı görüyor demektir.",
|
||||
"stake-label": "Önerilen Miktar (Stake)",
|
||||
"stake-label-short": "Bahis Miktarı",
|
||||
"stake-info": "Stake, bu bahis için önerilen bahis birimidir. 2.0u, kendi bankroll planınızdaki 2 birimlik bahis anlamına gelir.",
|
||||
"play-score-label": "Oynanabilirlik Puanı",
|
||||
"playability-label": "Oynanabilirlik",
|
||||
"quick-read": "Hızlı yorum",
|
||||
"lineup-source": "Kadronun Kaynağı",
|
||||
"model-label": "Model",
|
||||
"engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.",
|
||||
"best-single-pick": "En İyi Tekli Seçim",
|
||||
"alternative-markets": "Alternatif Marketler",
|
||||
"alternative-markets-info": "Ana tahmin dışındaki seçenekler.",
|
||||
"alternative": "Alternatif",
|
||||
"pass-market": "Elenen Market",
|
||||
"all-markets-title": "Tüm Marketler",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
"coupons": {
|
||||
"title": "Kupon Oluşturucu",
|
||||
"builder-title": "Kupon Oluşturucu",
|
||||
"builder-subtitle": "Kuponu, henüz başlamamış futbol maçlarından oluşturun. İsterseniz kendi maç havuzunuzu seçin, isterseniz AI live_matches tablosunu otomatik tarasın.",
|
||||
"history-title": "Kupon Geçmişi",
|
||||
"step-select": "Maç Seç",
|
||||
"step-strategy": "Strateji Seç",
|
||||
"step-suggest": "AI Önerisi",
|
||||
"step-review": "İncele ve Kaydet",
|
||||
"strategy": "Strateji",
|
||||
"strategy-safe": "Güvenli",
|
||||
"strategy-balanced": "Dengeli",
|
||||
"strategy-risky": "Riskli",
|
||||
"strategy-aggressive": "Agresif",
|
||||
"strategy-value": "Değer",
|
||||
"strategy-miracle": "Mucize",
|
||||
"strategy-safe-desc": "Düşük risk, düşük oran, yüksek kazanma olasılığı",
|
||||
"strategy-balanced-desc": "Dengeli risk/ödül yaklaşımı",
|
||||
"strategy-aggressive-desc": "Yüksek risk, yüksek potansiyel getiri",
|
||||
"strategy-value-desc": "EV+ değer fırsatlarına odaklanır",
|
||||
"strategy-miracle-desc": "Maksimum risk, maksimum potansiyel ödül",
|
||||
"selected-matches": "Seçilen Maçlar",
|
||||
"available-matches": "Mevcut Maçlar",
|
||||
"total-odd": "Toplam Oran",
|
||||
"generate-coupon": "AI Kuponu Oluştur",
|
||||
"save-coupon": "Kuponu Kaydet",
|
||||
"clear-all": "Tümünü Temizle",
|
||||
"no-items": "Henüz maç seçilmedi.",
|
||||
"no-history": "Kupon bulunamadı.",
|
||||
"no-coupons": "Kupon bulunamadı.",
|
||||
"no-matches": "Maç bulunmuyor.",
|
||||
"my-stats": "Bahis İstatistiklerim",
|
||||
"total-coupons": "Toplam Kupon",
|
||||
"win-rate": "Kazanma Oranı",
|
||||
"won": "Kazandı",
|
||||
"lost": "Kaybetti",
|
||||
"pending": "Beklemede",
|
||||
"my-coupon": "Kuponum",
|
||||
"my-coupon-help": "Bu panelde stratejiyi, seçtiğiniz maç havuzunu ve AI'ın döndürdüğü bahisleri görürsünüz.",
|
||||
"ai-suggest": "AI Öner",
|
||||
"suggestion-ready": "AI önerisi hazır!",
|
||||
"empty-coupon": "Kuponunuza eklemek için maç seçin.",
|
||||
"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ı.",
|
||||
"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",
|
||||
"suggested-bet-count-help": "AI'ın oluşturduğu kupon önerisinde kaç bahis olduğu.",
|
||||
"total-odds": "Toplam Oran",
|
||||
"total-odds-help": "Kupondaki tüm önerilen bahislerin birleşik oranı.",
|
||||
"candidate-pool-title": "Kupon Aday Havuzu",
|
||||
"candidate-pool-help": "Burada sadece henüz başlamamış futbol maçları listelenir. Canlı ve bitmiş maçlar dahil edilmez.",
|
||||
"candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış",
|
||||
"match-count-suffix": "maç",
|
||||
"upcoming-badge": "Yaklaşan",
|
||||
"selected-short": "Seçildi",
|
||||
"select-match": "Seç",
|
||||
"selection-mode": "AI Havuzu",
|
||||
"manual-pool": "Manuel havuz",
|
||||
"auto-pool": "Otomatik havuz",
|
||||
"no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.",
|
||||
"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",
|
||||
"selected-matches-empty": "Henüz maç seçmediniz. Bu şekilde devam ederseniz AI, tüm yaklaşan maç havuzundan kupon üretir.",
|
||||
"remove-match": "Kaldır",
|
||||
"manual-selection-helper": "Manuel mod aktif. AI sadece sizin seçtiğiniz maçları değerlendirecek.",
|
||||
"automatic-selection-helper": "Otomatik mod aktif. AI tüm yaklaşan futbol maçlarını değerlendirecek.",
|
||||
"match-count-label": "Kupon Maç Sayısı",
|
||||
"match-count-help": "AI'nin oluşturacağı kupon kaç maç içermeli? 2-15 arası seçebilirsiniz. Hiç maç seçmezseniz tüm bülten taranır.",
|
||||
"match-count-auto": "Tüm bülten ({count} maç)",
|
||||
"suggested-bets-title": "AI Önerilen Bahisler",
|
||||
"suggested-bets-title-help": "Bunlar, seçtiğiniz stratejiye göre kupon motorunun döndürdüğü bahislerdir.",
|
||||
"expected-win-rate": "Beklenen Kazanma Oranı",
|
||||
"bet-count": "Bahis Sayısı",
|
||||
"confidence-label": "Güven",
|
||||
"probability-label": "Olasılık",
|
||||
"risk-label": "Risk",
|
||||
"data-quality-label": "Veri Kalitesi",
|
||||
"rejected-matches-title": "Elenen Maçlar",
|
||||
"no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"account-settings": "Hesap Ayarları",
|
||||
"account-info": "Hesap Bilgileri",
|
||||
"personal-info": "Kişisel Bilgiler",
|
||||
"usage-limits": "Kullanım Limitleri",
|
||||
"subscription": "Abonelik",
|
||||
"save-changes": "Değişiklikleri Kaydet",
|
||||
"change-password": "Şifre Değiştir",
|
||||
"change-password-desc": "Hesabınızı güvende tutun. En az 8 karakterli güçlü bir şifre kullanın.",
|
||||
"current-password": "Mevcut Şifre",
|
||||
"new-password": "Yeni Şifre",
|
||||
"confirm-password": "Şifreyi Onayla",
|
||||
"first-name": "Ad",
|
||||
"last-name": "Soyad",
|
||||
"full-name": "Ad Soyad",
|
||||
"email": "E-Posta Adresi",
|
||||
"role": "Rol",
|
||||
"member-since": "Üyelik Tarihi",
|
||||
"betting-stats": "Bahis İstatistikleri",
|
||||
"total-coupons": "Toplam Kupon",
|
||||
"win-rate": "Kazanma Oranı",
|
||||
"total-profit": "Toplam Kâr"
|
||||
},
|
||||
|
||||
"leagues": {
|
||||
"title": "Ligler & Takımlar",
|
||||
"countries": "Ülkeler",
|
||||
"leagues": "Ligler",
|
||||
"countries-leagues": "Ülkeler & Ligler",
|
||||
"search-at-least-2": "Takım aramak için en az 2 karakter yazın."
|
||||
},
|
||||
|
||||
"h2h": {
|
||||
"title": "Karşılıklı Karşılaşma",
|
||||
"team-1": "Takım 1",
|
||||
"team-2": "Takım 2",
|
||||
"search-team": "Takım ara...",
|
||||
"compare": "Karşılaştır",
|
||||
"draws": "Beraberlikler",
|
||||
"no-matches-found": "Bu takımlar arasında karşılıklı maç bulunamadı."
|
||||
},
|
||||
|
||||
"analysis": {
|
||||
"title": "Çoklu Maç Analizi",
|
||||
"select-matches": "Maç Seç",
|
||||
"analyze-matches": "Maçları Analiz Et",
|
||||
"history": "Analiz Geçmişi",
|
||||
"selected": "seçili",
|
||||
"select-at-least-2": "Analiz etmek için en az 2 maç seçin.",
|
||||
"analysis-complete": "Analiz tamamlandı!",
|
||||
"matches-analyzed": "maç analiz edildi",
|
||||
"no-history": "Henüz analiz geçmişi yok."
|
||||
},
|
||||
|
||||
"spor-toto": {
|
||||
"title": "Spor Toto",
|
||||
"sync-bulletins": "Bültenleri Senkronize Et",
|
||||
"sync-success": "Bültenler başarıyla senkronize edildi!",
|
||||
"select-bulletin": "Bülten Seç",
|
||||
"choose-bulletin": "Bir bülten seçin...",
|
||||
"bulletin-label": "Döngü #{cycle} - {date}",
|
||||
"cycle-no": "Döngü No.",
|
||||
"cycle-no-short": "Döngü #{cycle}",
|
||||
"draw-date": "Çekiliş Tarihi",
|
||||
"status": "Durum",
|
||||
"matches": "Maçlar",
|
||||
"choose-strategy": "Strateji Seç",
|
||||
"generate-prediction": "Yapay Zeka Tahmini Oluştur",
|
||||
"selected": "Seçili",
|
||||
"strategy-conservative": "Muhafazakâr",
|
||||
"strategy-conservative-desc": "100 kolon, düşük risk yaklaşımı",
|
||||
"strategy-balanced": "Dengeli",
|
||||
"strategy-balanced-desc": "500 kolon, dengeli risk/getiri",
|
||||
"strategy-aggressive": "Agresif",
|
||||
"strategy-aggressive-desc": "2500 kolon, maksimum kapsama",
|
||||
"strategy-formula": "Formül 6%",
|
||||
"strategy-formula-desc": "%6 örnekleme, maliyet etkin",
|
||||
"bulletin-history": "Bülten Geçmişi",
|
||||
"rollover-stats": "Devir İstatistikleri",
|
||||
"prediction-generated": "Tahmin başarıyla oluşturuldu!"
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"title": "Yönetim Paneli",
|
||||
"subtitle": "Kullanıcıları yönetin, tahminleri takip edin ve sistemi izleyin.",
|
||||
"overview": "Genel Bakış",
|
||||
"analytics": "Analitik Genel Bakış",
|
||||
"user-management": "Kullanıcı Yönetimi",
|
||||
"users": "Kullanıcılar",
|
||||
"settings": "Ayarlar",
|
||||
"usage-limits": "Kullanım Limitleri",
|
||||
"total-users": "Toplam Kullanıcı",
|
||||
"active-users": "Aktif Kullanıcı",
|
||||
"active-matches": "Aktif Maçlar",
|
||||
"total-predictions": "Toplam Tahmin",
|
||||
"total-coupons": "Toplam Kupon",
|
||||
"toggle-active": "Aktiflik Değiştir",
|
||||
"update-role": "Rol Güncelle",
|
||||
"update-subscription": "Abonelik Güncelle",
|
||||
"delete-user": "Kullanıcıyı Sil",
|
||||
"reset-limits": "Tüm Limitleri Sıfırla",
|
||||
"setting-key": "Ayar Anahtarı",
|
||||
"setting-value": "Ayar Değeri",
|
||||
"update-setting": "Güncelle",
|
||||
"admin-badge": "Admin",
|
||||
"user-name": "İsim",
|
||||
"user-email": "E-Posta",
|
||||
"user-role": "Rol",
|
||||
"user-status": "Durum",
|
||||
"no-users": "Kullanıcı bulunamadı."
|
||||
},
|
||||
|
||||
"common": {
|
||||
"loading": "Yükleniyor...",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"delete": "Sil",
|
||||
"edit": "Düzenle",
|
||||
"search": "Ara",
|
||||
"filter": "Filtrele",
|
||||
"sort": "Sırala",
|
||||
"next": "Sonraki",
|
||||
"previous": "Önceki",
|
||||
"back": "Geri",
|
||||
"refresh": "Yenile",
|
||||
"clear": "Temizle",
|
||||
"all": "Tümü",
|
||||
"no-data": "Veri bulunamadı.",
|
||||
"confirm": "Onayla",
|
||||
"close": "Kapat",
|
||||
"actions": "İşlemler",
|
||||
"status": "Durum",
|
||||
"date": "Tarih",
|
||||
"active": "Aktif",
|
||||
"inactive": "Pasif",
|
||||
"page": "Sayfa",
|
||||
"of": "/",
|
||||
"items-per-page": "Sayfa başına öğe",
|
||||
"showing": "Gösterilen",
|
||||
"results": "sonuç"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
optimizePackageImports: ["@chakra-ui/react"],
|
||||
},
|
||||
reactCompiler: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/backend/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3005/api'}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
export default withNextIntl(nextConfig);
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Suggest-Bet-FE-v2",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --webpack --experimental-https -p 3001",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.28.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@google/genai": "^1.35.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"aos": "^2.3.4",
|
||||
"axios": "^1.13.1",
|
||||
"framer-motion": "^12.34.1",
|
||||
"i18next": "^25.6.0",
|
||||
"next": "16.0.0",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^3.27.1",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@types/aos": "^3.0.7",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-next": "16.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"description": "Generated by Frontend CLI"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# 🤖 AI Assistant Context - Next.js Frontend
|
||||
|
||||
> Bu dosya, AI asistanların (Claude, GPT, Gemini vb.) projeyi hızlıca anlaması için hazırlanmış bir referans dökümanıdır.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Projeyi Anlamak İçin Önce Oku
|
||||
|
||||
1. **README.md** dosyasını oku - Projenin mimarisi, teknoloji stack'i ve kurulum adımlarını içerir.
|
||||
|
||||
```
|
||||
README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Referans Klasörü
|
||||
|
||||
`.claude/` klasörü best practice'ler, agent tanımları ve yardımcı scriptler içerir. Görev türüne göre ilgili referansları kullan:
|
||||
|
||||
### Skills (Beceri Setleri)
|
||||
|
||||
| Beceri | Konum | Ne Zaman Kullan |
|
||||
| ------------------- | --------------------------------- | ------------------------------------------ |
|
||||
| **Senior QA** | `.claude/skills/senior-qa/` | Test yazarken, coverage analizi yaparken |
|
||||
| **Senior Frontend** | `.claude/skills/senior-frontend/` | Component geliştirirken, UI best practices |
|
||||
| **Frontend Design** | `.claude/skills/frontend-design/` | Tasarım kararları alırken |
|
||||
|
||||
### Agents (Roller)
|
||||
|
||||
| Agent | Konum | Açıklama |
|
||||
| ------------------------------- | ---------------------------------------------- | ------------------------- |
|
||||
| **Frontend Developer** | `.claude/agents/frontend-developer.md` | Genel frontend geliştirme |
|
||||
| **Next.js Architecture Expert** | `.claude/agents/nextjs-architecture-expert.md` | Mimari kararlar |
|
||||
|
||||
### Commands (Komutlar)
|
||||
|
||||
| Komut | Konum | Açıklama |
|
||||
| ----------------------- | ------------------------------------------------ | ------------------------ |
|
||||
| **Component Generator** | `.claude/commands/nextjs-component-generator.md` | Yeni component oluşturma |
|
||||
| **API Tester** | `.claude/commands/nextjs-api-tester.md` | API endpoint test etme |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Teknoloji Stack'i (Özet)
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
- **UI Library:** Chakra UI v3
|
||||
- **State Management:** React Query (TanStack)
|
||||
- **Auth:** NextAuth.js
|
||||
- **i18n:** next-intl
|
||||
- **Language:** TypeScript (Strict Mode)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Proje Yapısı Özeti
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/ # Locale-based routing
|
||||
├── components/ # UI components
|
||||
├── lib/api/ # API clients
|
||||
├── i18n/ # Internationalization
|
||||
└── theme/ # Chakra UI theme
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Görev Bazlı Referans Kullanımı
|
||||
|
||||
**Test yazarken:**
|
||||
|
||||
```
|
||||
.claude/skills/senior-qa/references/testing_strategies.md
|
||||
.claude/skills/senior-qa/references/test_automation_patterns.md
|
||||
```
|
||||
|
||||
**Component geliştirirken:**
|
||||
|
||||
```
|
||||
.claude/skills/senior-frontend/SKILL.md
|
||||
.claude/skills/frontend-design/SKILL.md
|
||||
```
|
||||
|
||||
**Mimari kararlar alırken:**
|
||||
|
||||
```
|
||||
.claude/agents/nextjs-architecture-expert.md
|
||||
README.md (ADR bölümü)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Örnek Prompt'lar
|
||||
|
||||
### Yeni Component Oluşturma
|
||||
|
||||
> "`.claude/skills/senior-frontend/` referanslarını kullanarak, reusable bir `DataTable` component'i oluştur."
|
||||
|
||||
### Test Yazma
|
||||
|
||||
> "`.claude/skills/senior-qa/references/testing_strategies.md` pattern'lerini kullanarak `LoginForm` için unit test yaz."
|
||||
|
||||
### Code Review
|
||||
|
||||
> "`.claude/skills/senior-frontend/` best practice'lerine göre `src/components/auth/` klasörünü review et."
|
||||
|
||||
---
|
||||
|
||||
**Backend Projesi:** `../typescript-boilerplate-be/prompt.md`
|
||||
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 558 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import Footer from '@/components/layout/footer/footer';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
|
||||
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Box as='main'>{children}</Box>
|
||||
<Footer />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Text,
|
||||
ClientOnly,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Switch } from "@/components/ui/forms/switch";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { useTranslations } from "next-intl";
|
||||
import signInImage from "../../../../../public/assets/img/sign-in-image.png";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { BiLock } from "react-icons/bi";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().required(),
|
||||
});
|
||||
|
||||
type SignInForm = yup.InferType<typeof schema>;
|
||||
|
||||
const defaultValues = {
|
||||
email: "test@test.com.tr",
|
||||
password: "test1234",
|
||||
};
|
||||
|
||||
function SignInPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignInForm>({
|
||||
resolver: yupResolver(schema),
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: SignInForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
router.replace("/home");
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Giriş yaparken hata oluştu!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Flex
|
||||
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
|
||||
w="100%"
|
||||
maxW="1044px"
|
||||
mx="auto"
|
||||
justifyContent="space-between"
|
||||
mb="30px"
|
||||
pt={{ sm: "100px", md: "0px" }}
|
||||
>
|
||||
<Flex
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
style={{ userSelect: "none" }}
|
||||
w={{ base: "100%", md: "50%", lg: "42%" }}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
w="100%"
|
||||
background="transparent"
|
||||
p="10"
|
||||
mt={{ md: "150px", lg: "80px" }}
|
||||
>
|
||||
<Heading
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
fontSize="32px"
|
||||
mb="10px"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{t("auth.welcome-back")}
|
||||
</Heading>
|
||||
<Text
|
||||
mb="36px"
|
||||
ms="4px"
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="bold"
|
||||
fontSize="14px"
|
||||
>
|
||||
{t("auth.subtitle")}
|
||||
</Text>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
size="lg"
|
||||
{...register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
<PasswordInput
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
size="lg"
|
||||
{...register("password")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<Switch colorPalette="teal" label={t("auth.remember-me")}>
|
||||
{t("auth.remember-me")}
|
||||
</Switch>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
h="45px"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "primary.500",
|
||||
}}
|
||||
_active={{
|
||||
bg: "primary.400",
|
||||
}}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
</Field>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
>
|
||||
<Text
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{t("auth.dont-have-account")}
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/signup"
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
ms="5px"
|
||||
fontWeight="bold"
|
||||
focusRing="none"
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
overflowX="hidden"
|
||||
h="100%"
|
||||
w="40vw"
|
||||
position="absolute"
|
||||
right="0px"
|
||||
>
|
||||
<Box
|
||||
bgImage={`url(${signInImage.src})`}
|
||||
w="100%"
|
||||
h="100%"
|
||||
bgSize="cover"
|
||||
bgPos="50%"
|
||||
position="absolute"
|
||||
borderBottomLeftRadius="20px"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInPage;
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Text,
|
||||
ClientOnly,
|
||||
} from "@chakra-ui/react";
|
||||
import signUpImage from "../../../../../public/assets/img/sign-up-image.png";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { BiLock, BiUser } from "react-icons/bi";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { authService } from "@/lib/api/example/auth/service";
|
||||
import { useState } from "react";
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
type SignUpForm = yup.InferType<typeof schema>;
|
||||
|
||||
function SignUpPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: "onChange" });
|
||||
|
||||
const onSubmit = async (formData: SignUpForm) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await authService.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
firstName: formData.name,
|
||||
lastName: "",
|
||||
});
|
||||
router.replace("/signin");
|
||||
} catch {
|
||||
// Error handled by api-service interceptor (toast + 422 display)
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return formData;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
minH={{ base: "70vh", md: "50vh" }}
|
||||
w={{ md: "calc(100vw - 50px)" }}
|
||||
borderRadius={{ md: "15px" }}
|
||||
left="0"
|
||||
right="0"
|
||||
bgRepeat="no-repeat"
|
||||
overflow="hidden"
|
||||
zIndex="-1"
|
||||
top="0"
|
||||
bgImage={`url(${signUpImage.src})`}
|
||||
bgSize="cover"
|
||||
mx={{ md: "auto" }}
|
||||
mt={{ md: "14px" }}
|
||||
/>
|
||||
<Flex
|
||||
w="full"
|
||||
h="full"
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text
|
||||
fontSize={{ base: "2xl", md: "3xl", lg: "4xl" }}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
mt={{ base: "2rem", md: "4.5rem", "2xl": "6.5rem" }}
|
||||
mb={{ base: "2rem", md: "3rem", "2xl": "4rem" }}
|
||||
>
|
||||
{t("auth.create-an-account-now")}
|
||||
</Text>
|
||||
<Flex
|
||||
direction="column"
|
||||
w={{ base: "100%", md: "445px" }}
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="10"
|
||||
mx={{ base: "100px" }}
|
||||
bg="bg.panel"
|
||||
boxShadow="0 20px 27px 0 rgb(0 0 0 / 5%)"
|
||||
mb="8"
|
||||
>
|
||||
<Flex
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
w="100%"
|
||||
>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("name")}
|
||||
errorText={errors.name?.message}
|
||||
invalid={!!errors.name}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("name")}
|
||||
size="lg"
|
||||
{...register("name")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
size="lg"
|
||||
{...register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
<PasswordInput
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
size="lg"
|
||||
{...register("password")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
|
||||
<Button
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45px"
|
||||
_hover={{
|
||||
bg: "primary.500",
|
||||
}}
|
||||
_active={{
|
||||
bg: "primary.400",
|
||||
}}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("auth.registering") : t("auth.sign-up")}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
</Field>
|
||||
</Flex>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
>
|
||||
<Text
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{t("auth.already-have-an-account")}
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
ml="2"
|
||||
href="/signin"
|
||||
fontWeight="bold"
|
||||
focusRing="none"
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
function AboutPage() {
|
||||
return <div>AboutPage</div>;
|
||||
}
|
||||
|
||||
export default AboutPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import AdminContent from "@/components/admin/admin-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("admin.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Admin panel for managing users, monitoring predictions, and system overview.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminContent />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import AnalysisContent from "@/components/analysis/analysis-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("analysis.title")} | Suggest Bet`,
|
||||
description: "AI-powered multi-match analysis for coupon generation.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return <AnalysisContent />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import CouponBuilderContent from "@/components/coupons/coupon-builder-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("coupons.builder-title")} | Suggest Bet`,
|
||||
description:
|
||||
"Build your coupon with AI-powered suggestions. Choose your strategy and let AI optimize your bets.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function CouponBuilderPage() {
|
||||
return <CouponBuilderContent />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import CouponHistoryContent from "@/components/coupons/coupon-history-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("coupons.history-title")} | Suggest Bet`,
|
||||
description:
|
||||
"View your coupon history, track wins and losses, and analyze your betting performance.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function CouponHistoryPage() {
|
||||
return <CouponHistoryContent />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import DashboardContent from "@/components/dashboard/dashboard-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: `${t("dashboard.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Your personalized betting dashboard with predictions, value bets, and match insights.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardContent />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import H2HContent from "@/components/h2h/h2h-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("matches.head-to-head")} | Suggest Bet`,
|
||||
description: "Compare two teams and view their head-to-head match history.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function H2HPage() {
|
||||
return <H2HContent />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import HomeContent from "@/components/home/home-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: `${t("home")} | Suggest Bet`,
|
||||
description:
|
||||
"AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <HomeContent />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Flex } from '@chakra-ui/react';
|
||||
import Header from '@/components/layout/header/header';
|
||||
import Footer from '@/components/layout/footer/footer';
|
||||
import BackToTop from '@/components/ui/back-to-top';
|
||||
|
||||
function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Header />
|
||||
<Container as='main' maxW='8xl' flex='1' py={4}>
|
||||
{children}
|
||||
</Container>
|
||||
<BackToTop />
|
||||
<Footer />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import LeaguesContent from "@/components/leagues/leagues-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("leagues.title")} | Suggest Bet`,
|
||||
description: "Browse football and basketball leagues, countries, and teams.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function LeaguesPage() {
|
||||
return <LeaguesContent />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import MatchDetailContent from "@/components/matches/match-detail-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: `${t("matches.match-details")} | Suggest Bet`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function MatchDetailPage() {
|
||||
return <MatchDetailContent />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import MatchesContent from "@/components/matches/matches-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: `${t("matches.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Browse and analyze upcoming football and basketball matches with AI predictions.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function MatchesPage() {
|
||||
return <MatchesContent />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import PredictionsContent from "@/components/predictions/predictions-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("predictions.title")} | Suggest Bet`,
|
||||
description:
|
||||
"AI-powered match predictions with confidence scores, value bets, and prediction history.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function PredictionsPage() {
|
||||
return <PredictionsContent />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import ProfileContent from "@/components/profile/profile-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("profile.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Manage your profile, view account info, and track your betting statistics.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileContent />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import SporTotoContent from "@/components/spor-toto/spor-toto-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("spor-toto.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Spor Toto predictions with AI-powered analysis. Generate optimized system coupons with contrarian parimutuel strategy.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function SporTotoPage() {
|
||||
return <SporTotoContent />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import TeamDetailContent from "@/components/teams/team-detail-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("nav.teams")} | Suggest Bet`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TeamDetailPage() {
|
||||
return <TeamDetailContent />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import TeamsContent from "@/components/teams/teams-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: `${t("nav.teams")} | Suggest Bet`,
|
||||
description: "Search and explore football teams, view match history and stats.",
|
||||
};
|
||||
}
|
||||
|
||||
export default function TeamsPage() {
|
||||
return <TeamsContent />;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Suggest-Bet — Global CSS
|
||||
Premium animations, gradients, and utility keyframes
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────
|
||||
Custom Animation Keyframes
|
||||
────────────────────────────────── */
|
||||
|
||||
/* Pulsing live indicator */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.3); }
|
||||
}
|
||||
|
||||
/* Glow ring for CTAs */
|
||||
@keyframes glow-ring {
|
||||
0% { box-shadow: 0 0 0 0 rgba(56, 178, 172, 0.4); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(56, 178, 172, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(56, 178, 172, 0); }
|
||||
}
|
||||
|
||||
/* Shimmer for skeleton loading */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* Gradient background shift */
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* Sparkle float */
|
||||
@keyframes sparkle-float {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(-120px) rotate(360deg); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Subtle float for decorative elements */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* Gradient text shimmer */
|
||||
@keyframes text-shimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 200% 50%; }
|
||||
}
|
||||
|
||||
/* Rotate for spinners */
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Fade in up — CSS fallback for non-JS */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────
|
||||
Utility Classes
|
||||
────────────────────────────────── */
|
||||
|
||||
.animate-pulse { animation: pulse 1.5s ease-in-out infinite; }
|
||||
.animate-glow { animation: glow-ring 2s infinite; }
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.08) 50%, transparent 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||
.animate-spin-slow { animation: spin-slow 8s linear infinite; }
|
||||
|
||||
/* Gradient mesh background */
|
||||
.gradient-mesh {
|
||||
background:
|
||||
radial-gradient(at 20% 20%, rgba(56, 178, 172, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(at 80% 80%, rgba(128, 90, 213, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(at 50% 50%, rgba(66, 153, 225, 0.08) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Dark mode gradient mesh */
|
||||
[data-theme="dark"] .gradient-mesh,
|
||||
.dark .gradient-mesh {
|
||||
background:
|
||||
radial-gradient(at 20% 20%, rgba(56, 178, 172, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(at 80% 80%, rgba(128, 90, 213, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(at 50% 50%, rgba(66, 153, 225, 0.04) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Animated gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #38B2AC, #805AD5, #4299E1, #38B2AC);
|
||||
background-size: 300% 300%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: text-shimmer 4s ease infinite;
|
||||
}
|
||||
|
||||
/* Glassmorphism card */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .glass,
|
||||
.dark .glass {
|
||||
background: rgba(26, 32, 44, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────
|
||||
Scrollbar Styling
|
||||
────────────────────────────────── */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────
|
||||
Selection Color
|
||||
────────────────────────────────── */
|
||||
|
||||
::selection {
|
||||
background: rgba(56, 178, 172, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Provider } from "@/components/ui/provider";
|
||||
import { Bricolage_Grotesque } from "next/font/google";
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { dir } from "i18next";
|
||||
import "./global.css";
|
||||
|
||||
const bricolage = Bricolage_Grotesque({
|
||||
variable: "--font-bricolage",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={dir(locale)}
|
||||
suppressHydrationWarning
|
||||
data-scroll-behavior="smooth"
|
||||
>
|
||||
<head>
|
||||
{/* <link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
|
||||
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' /> */}
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
</head>
|
||||
<body className={bricolage.variable}>
|
||||
<NextIntlClientProvider>
|
||||
<Provider>{children}</Provider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
|
||||
<VStack spaceY={6}>
|
||||
<Heading
|
||||
as='h1'
|
||||
fontSize={{ base: '5xl', md: '6xl' }}
|
||||
fontWeight='bold'
|
||||
color={{ base: 'primary.600', _dark: 'primary.400' }}
|
||||
>
|
||||
{t('error.404')}
|
||||
</Heading>
|
||||
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
|
||||
{t('error.not-found')}
|
||||
</Text>
|
||||
<Link href='/home' passHref>
|
||||
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
|
||||
{t('error.back-to-home')}
|
||||
</Button>
|
||||
</Link>
|
||||
</VStack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
redirect('/home');
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { authService } from "@/lib/api/example/auth/service";
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
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: user.roles || [],
|
||||
};
|
||||
} 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 = user.roles;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: JWT }) {
|
||||
session.user.id = token.id;
|
||||
session.user.roles = 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);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Button,
|
||||
Separator,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import {
|
||||
SlideUp,
|
||||
StaggerContainer,
|
||||
StaggerItem,
|
||||
AnimatedCounter,
|
||||
} from "@/components/motion";
|
||||
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
||||
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
||||
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
|
||||
type AdminTab = "overview" | "users";
|
||||
|
||||
// ========================
|
||||
// Admin Stat Card
|
||||
// ========================
|
||||
|
||||
interface AdminStatProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
colorPalette: string;
|
||||
}
|
||||
|
||||
function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
return (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<HStack gap={4}>
|
||||
<Flex
|
||||
boxSize="48px"
|
||||
bg={`${colorPalette}.subtle`}
|
||||
borderRadius="xl"
|
||||
align="center"
|
||||
justify="center"
|
||||
color={`${colorPalette}.fg`}
|
||||
fontSize="xl"
|
||||
>
|
||||
{icon}
|
||||
</Flex>
|
||||
<VStack gap={0} align="flex-start">
|
||||
<Text fontSize="2xl" fontWeight="900" lineHeight="1">
|
||||
<AnimatedCounter value={value} />
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Admin Content
|
||||
// ========================
|
||||
|
||||
export default function AdminContent() {
|
||||
const t = useTranslations("admin");
|
||||
const tCommon = useTranslations("common");
|
||||
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const { data: analyticsData, isLoading: analyticsLoading } =
|
||||
useAdminAnalytics();
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers();
|
||||
|
||||
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
||||
const users = (usersData?.data as AdminUserDto[] | undefined) ?? [];
|
||||
|
||||
const tabs: { key: AdminTab; label: string }[] = [
|
||||
{ key: "overview", label: t("overview") },
|
||||
{ key: "users", label: t("user-management") },
|
||||
];
|
||||
|
||||
const getUserDisplayName = (user: AdminUserDto) => {
|
||||
if (user.firstName && user.lastName)
|
||||
return `${user.firstName} ${user.lastName}`;
|
||||
if (user.firstName) return user.firstName;
|
||||
return user.email.split("@")[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<VStack gap={1} align="flex-start">
|
||||
<Heading as="h1" size="xl" fontWeight="bold">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<Text color="fg.muted" fontSize="sm">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
colorPalette="red"
|
||||
variant="solid"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
<LuShield />
|
||||
{t("admin-badge")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* Tabs */}
|
||||
<HStack gap={2} mb={6}>
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant={activeTab === tab.key ? "solid" : "outline"}
|
||||
colorPalette={activeTab === tab.key ? "primary" : "gray"}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" &&
|
||||
(analyticsLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : (
|
||||
<StaggerContainer>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4} mb={8}>
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-users")}
|
||||
value={analytics?.totalUsers ?? 0}
|
||||
icon={<LuUsers />}
|
||||
colorPalette="primary"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-predictions")}
|
||||
value={analytics?.totalPredictions ?? 0}
|
||||
icon={<LuChartBar />}
|
||||
colorPalette="green"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("active-users")}
|
||||
value={analytics?.activeUsers ?? 0}
|
||||
icon={<LuActivity />}
|
||||
colorPalette="orange"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-coupons")}
|
||||
value={analytics?.totalCoupons ?? 0}
|
||||
icon={<LuShield />}
|
||||
colorPalette="purple"
|
||||
/>
|
||||
</StaggerItem>
|
||||
</SimpleGrid>
|
||||
</StaggerContainer>
|
||||
))}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === "users" &&
|
||||
(usersLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : users.length > 0 ? (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<VStack gap={0} align="stretch">
|
||||
{/* Table Header */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
mb={2}
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text flex={2}>{t("user-name")}</Text>
|
||||
<Text flex={2}>{t("user-email")}</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-role")}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-status")}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* User Rows */}
|
||||
{users.map((user: AdminUserDto, idx: number) => (
|
||||
<Box key={user.id ?? idx}>
|
||||
{idx > 0 && <Separator />}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
align="center"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Text
|
||||
flex={2}
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
truncate
|
||||
>
|
||||
{getUserDisplayName(user)}
|
||||
</Text>
|
||||
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={
|
||||
user.role === "ADMIN" ? "red" : "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.role || "User"}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={user.isActive ? "green" : "gray"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.isActive
|
||||
? tCommon("active")
|
||||
: tCommon("inactive")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-users")}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AdminContent } from "./admin-content";
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import {
|
||||
useAnalyzeMatches,
|
||||
useAnalysisHistory,
|
||||
} from "@/lib/api/analysis/use-hooks";
|
||||
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
|
||||
import type { LeagueWithMatchesDto } from "@/lib/api/matches/types";
|
||||
import { LuSparkles, LuClock, LuCheck } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
|
||||
export default function AnalysisContent() {
|
||||
const t = useTranslations("analysis");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const [selectedMatchIds, setSelectedMatchIds] = useState<string[]>([]);
|
||||
|
||||
const upcomingMatches = useQueryMatches();
|
||||
const analyzeMutation = useAnalyzeMatches();
|
||||
const historyQuery = useAnalysisHistory();
|
||||
const toast = (opts: { title: string; status: string }) =>
|
||||
toaster.create({
|
||||
title: opts.title,
|
||||
type: opts.status as
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "loading",
|
||||
});
|
||||
|
||||
const toggleMatch = (id: string) => {
|
||||
setSelectedMatchIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (selectedMatchIds.length < 2) {
|
||||
toast({
|
||||
title: t("select-at-least-2"),
|
||||
status: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeMutation.mutateAsync({ matchIds: selectedMatchIds });
|
||||
toast({
|
||||
title: t("analysis-complete"),
|
||||
status: "success",
|
||||
});
|
||||
historyQuery.refetch();
|
||||
};
|
||||
|
||||
const allMatches: { id: string; home: string; away: string; date: string }[] =
|
||||
upcomingMatches.data?.data
|
||||
?.flatMap((league: LeagueWithMatchesDto) =>
|
||||
league.matches?.map((m) => ({
|
||||
id: m.id,
|
||||
home: m.homeTeam?.name || "",
|
||||
away: m.awayTeam?.name || "",
|
||||
date: m.mstUtc ? new Date(m.mstUtc).toLocaleDateString() : "",
|
||||
})),
|
||||
)
|
||||
.filter(Boolean) || [];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box maxW="6xl" mx="auto">
|
||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
||||
{t("title")}
|
||||
</Heading>
|
||||
|
||||
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
|
||||
{/* Match Selection */}
|
||||
<Box flex={2}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Header>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Heading as="h3" size="sm">
|
||||
{t("select-matches")}
|
||||
</Heading>
|
||||
<Badge
|
||||
colorScheme={
|
||||
selectedMatchIds.length > 0 ? "primary" : "gray"
|
||||
}
|
||||
>
|
||||
{selectedMatchIds.length} {t("selected")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{upcomingMatches.isPending ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="sm" />
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack gap={2}>
|
||||
{allMatches.map((m) => {
|
||||
const isSelected = selectedMatchIds.includes(m.id);
|
||||
return (
|
||||
<Flex
|
||||
key={m.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={isSelected ? "primary.500" : borderColor}
|
||||
bg={isSelected ? "primary.50" : "transparent"}
|
||||
_dark={isSelected ? { bg: "primary.900" } : undefined}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
onClick={() => toggleMatch(m.id)}
|
||||
>
|
||||
<HStack gap={3}>
|
||||
<Box
|
||||
boxSize="20px"
|
||||
borderRadius="sm"
|
||||
borderWidth="2px"
|
||||
borderColor={
|
||||
isSelected ? "primary.500" : "gray.300"
|
||||
}
|
||||
bg={isSelected ? "primary.500" : "transparent"}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color="white"
|
||||
>
|
||||
{isSelected ? <LuCheck size="12" /> : null}
|
||||
</Box>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{m.home} vs {m.away}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{m.date}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
<Button
|
||||
mt={4}
|
||||
w="full"
|
||||
onClick={handleAnalyze}
|
||||
loading={analyzeMutation.isPending}
|
||||
disabled={selectedMatchIds.length < 2}
|
||||
>
|
||||
<LuSparkles /> {t("analyze-matches")}
|
||||
</Button>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
|
||||
{/* Analysis History */}
|
||||
<Box flex={1}>
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
<HStack gap={2}>
|
||||
<LuClock />
|
||||
<Text>{t("history")}</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{historyQuery.isLoading ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="sm" />
|
||||
</Flex>
|
||||
) : historyQuery.data?.data?.analyses &&
|
||||
historyQuery.data.data.analyses.length > 0 ? (
|
||||
<VStack gap={3}>
|
||||
{historyQuery.data.data.analyses.map(
|
||||
(a: {
|
||||
id: string;
|
||||
matchIds: string[];
|
||||
createdAt: string;
|
||||
}) => (
|
||||
<Card.Root
|
||||
key={a.id}
|
||||
size="sm"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
{a.matchIds.length} {t("matches-analyzed")}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{new Date(a.createdAt).toLocaleString()}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
),
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text color="fg.muted" textAlign="center" py={6}>
|
||||
{t("no-history")}
|
||||
</Text>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/overlays/dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { BiLock } from "react-icons/bi";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().min(6).required(),
|
||||
});
|
||||
|
||||
type LoginForm = yup.InferType<typeof schema>;
|
||||
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
const t = useTranslations();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: yupResolver(schema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: LoginForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
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.login-success") || "Login successful!",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Login failed!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Heading size="lg" color="primary.500">
|
||||
{t("auth.sign-in")}
|
||||
</Heading>
|
||||
</DialogTitle>
|
||||
<DialogCloseTrigger />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
{...register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
<PasswordInput
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
{...register("password")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
color="white"
|
||||
_hover={{ bg: "primary.500" }}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("auth.dont-have-account")}{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
style={{
|
||||
color: "var(--chakra-colors-primary-500)",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Separator,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, StaggerContainer, StaggerItem } from "@/components/motion";
|
||||
import { useCouponHistory } from "@/lib/api/coupons/use-hooks";
|
||||
import type { CouponResponseDto, CouponItemDto } from "@/lib/api/coupons/types";
|
||||
import { useState } from "react";
|
||||
|
||||
type FilterType = "all" | "pending" | "won" | "lost";
|
||||
|
||||
export default function CouponHistoryContent() {
|
||||
const t = useTranslations("coupons");
|
||||
const tCommon = useTranslations("common");
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const { data, isLoading } = useCouponHistory();
|
||||
const historyData = data?.data as
|
||||
| { coupons?: CouponResponseDto[] }
|
||||
| undefined;
|
||||
const allCoupons: CouponResponseDto[] = historyData?.coupons ?? [];
|
||||
|
||||
const filteredCoupons =
|
||||
filter === "all"
|
||||
? allCoupons
|
||||
: allCoupons.filter(
|
||||
(c: CouponResponseDto) => c.status?.toLowerCase() === filter,
|
||||
);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "yellow",
|
||||
won: "green",
|
||||
lost: "red",
|
||||
};
|
||||
|
||||
const filters: { key: FilterType; label: string }[] = [
|
||||
{ key: "all", label: tCommon("all") },
|
||||
{ key: "pending", label: t("pending") },
|
||||
{ key: "won", label: t("won") },
|
||||
{ key: "lost", label: t("lost") },
|
||||
];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
||||
{t("history-title")}
|
||||
</Heading>
|
||||
|
||||
{/* Filters */}
|
||||
<HStack gap={2} mb={6} overflowX="auto" pb={1}>
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
variant={filter === f.key ? "solid" : "outline"}
|
||||
colorPalette={filter === f.key ? "primary" : "gray"}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
onClick={() => setFilter(f.key)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{isLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : filteredCoupons.length > 0 ? (
|
||||
<StaggerContainer>
|
||||
<VStack gap={4} align="stretch">
|
||||
{filteredCoupons.map((coupon: CouponResponseDto, idx: number) => (
|
||||
<StaggerItem key={coupon.id ?? idx}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Header pb={2}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack gap={2}>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{t("coupon")} #{coupon.id?.slice(-6) || idx + 1}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette={
|
||||
statusColors[
|
||||
coupon.status?.toLowerCase() ?? ""
|
||||
] || "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{coupon.status || "—"}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<VStack gap={0} align="flex-end">
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("total-odd")}
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{coupon.totalOdd?.toFixed(2) || "—"}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{coupon.items?.map(
|
||||
(item: CouponItemDto, itemIdx: number) => (
|
||||
<Box key={itemIdx}>
|
||||
{itemIdx > 0 && <Separator my={2} />}
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack gap={0} align="flex-start">
|
||||
<Text fontSize="xs" fontWeight="semibold">
|
||||
{item.matchId}
|
||||
</Text>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{item.market}: {item.pick}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
colorPalette="primary"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{item.odd?.toFixed(2)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
|
||||
{coupon.strategy && (
|
||||
<Flex
|
||||
mt={3}
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor={borderColor}
|
||||
justify="space-between"
|
||||
>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{t("strategy")}: {coupon.strategy}
|
||||
</Text>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{coupon.createdAt &&
|
||||
new Date(coupon.createdAt).toLocaleDateString(
|
||||
"tr-TR",
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</VStack>
|
||||
</StaggerContainer>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-coupons")}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CouponBuilderContent } from "./coupon-builder-content";
|
||||
export { default as CouponHistoryContent } from "./coupon-history-content";
|
||||
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { MatchCard } from "@/components/matches";
|
||||
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
|
||||
import {
|
||||
useUpcomingPredictions,
|
||||
useValueBets,
|
||||
} from "@/lib/api/predictions/use-hooks";
|
||||
import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LeagueWithMatchesDto, MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import type { MatchPredictionDto, ValueBetDto } from "@/lib/api/predictions/types";
|
||||
|
||||
// ========================
|
||||
// Stats Card
|
||||
// ========================
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
colorPalette?: string;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
colorPalette = "primary",
|
||||
}: StatCardProps) {
|
||||
const cardBg = useColorModeValue(
|
||||
"rgba(255, 255, 255, 0.75)",
|
||||
"rgba(26, 32, 44, 0.65)",
|
||||
);
|
||||
const borderColor = useColorModeValue(
|
||||
"rgba(255, 255, 255, 0.8)",
|
||||
"rgba(255, 255, 255, 0.06)",
|
||||
);
|
||||
|
||||
return (
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
backdropFilter="blur(12px)"
|
||||
_hover={{
|
||||
transform: "translateY(-3px)",
|
||||
shadow: "lg",
|
||||
borderColor: `${colorPalette}.300`,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<Card.Body>
|
||||
<HStack gap={4}>
|
||||
<Flex
|
||||
boxSize="48px"
|
||||
bg={`${colorPalette}.subtle`}
|
||||
borderRadius="xl"
|
||||
align="center"
|
||||
justify="center"
|
||||
color={`${colorPalette}.fg`}
|
||||
fontSize="xl"
|
||||
shadow="sm"
|
||||
>
|
||||
{icon}
|
||||
</Flex>
|
||||
<VStack gap={0} align="flex-start">
|
||||
<Text fontSize="2xl" fontWeight="900" lineHeight="1">
|
||||
{value}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Value Bet Mini Card
|
||||
// ========================
|
||||
|
||||
interface ValueBetMiniCardProps {
|
||||
matchName: string;
|
||||
prediction: string;
|
||||
odd: number;
|
||||
expectedValue: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function ValueBetMiniCard({
|
||||
matchName,
|
||||
prediction,
|
||||
odd,
|
||||
expectedValue,
|
||||
confidence,
|
||||
}: ValueBetMiniCardProps) {
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Text fontSize="xs" color="fg.muted" truncate mb={1}>
|
||||
{matchName}
|
||||
</Text>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{prediction}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Badge
|
||||
colorPalette="green"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
EV+ {(expectedValue * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
<Badge
|
||||
colorPalette="primary"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{odd.toFixed(2)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Dashboard Content
|
||||
// ========================
|
||||
|
||||
export default function DashboardContent() {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCoupons = useTranslations("coupons");
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
// Data fetching
|
||||
const queryMatches = useQueryMatches();
|
||||
const { data: upcomingData, isLoading: upcomingLoading } =
|
||||
useUpcomingPredictions();
|
||||
const { data: valueBetsData, isLoading: valueBetsLoading } = useValueBets();
|
||||
const { data: statsData, isLoading: statsLoading } = useUserBettingStats();
|
||||
|
||||
// Trigger match fetch for today
|
||||
if (!queryMatches.data && !queryMatches.isPending) {
|
||||
queryMatches.mutate({ sport: "football", limit: 20 });
|
||||
}
|
||||
|
||||
const todayMatches: MatchResponseDto[] = queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ?? [];
|
||||
const upcomingPredictions: MatchPredictionDto[] = upcomingData?.data?.matches ?? [];
|
||||
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
|
||||
const userStats = statsData?.data;
|
||||
|
||||
const userName = session?.user?.name || "";
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* Welcome Header */}
|
||||
<Box mb={6}>
|
||||
<Heading as="h1" size="xl" fontWeight="bold">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
{userName && (
|
||||
<Text color="fg.muted" mt={1}>
|
||||
{t("welcome")},{" "}
|
||||
<Text as="span" fontWeight="semibold" color="fg">
|
||||
{userName}
|
||||
</Text>{" "}
|
||||
👋
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StaggerContainer>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4} mb={8}>
|
||||
<StaggerItem>
|
||||
<StatCard
|
||||
label={tCoupons("total-coupons")}
|
||||
value={userStats?.totalCoupons ?? "—"}
|
||||
icon={<LuTicket />}
|
||||
colorPalette="primary"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatCard
|
||||
label={tCoupons("win-rate")}
|
||||
value={
|
||||
userStats?.winRate ? `${Math.round(userStats.winRate)}%` : "—"
|
||||
}
|
||||
icon={<LuTrendingUp />}
|
||||
colorPalette="green"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatCard
|
||||
label={tCoupons("won")}
|
||||
value={userStats?.wonBets ?? "—"}
|
||||
icon={<LuTarget />}
|
||||
colorPalette="teal"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatCard
|
||||
label={tCoupons("pending")}
|
||||
value={userStats?.pendingBets ?? "—"}
|
||||
icon={<LuChartBar />}
|
||||
colorPalette="yellow"
|
||||
/>
|
||||
</StaggerItem>
|
||||
</SimpleGrid>
|
||||
</StaggerContainer>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<Flex
|
||||
gap={6}
|
||||
direction={{ base: "column", lg: "row" }}
|
||||
align="flex-start"
|
||||
>
|
||||
{/* Left Column — Today's Matches */}
|
||||
<Box flex={2} minW={0}>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<Heading as="h2" size="md">
|
||||
{t("todays-matches")}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
colorPalette="primary"
|
||||
onClick={() => router.push("/matches")}
|
||||
>
|
||||
{t("view-all")} →
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{queryMatches.isPending ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3} py={4}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height="140px" borderRadius="xl" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : todayMatches.length > 0 ? (
|
||||
<StaggerContainer>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
|
||||
{todayMatches.slice(0, 6).map((match: MatchResponseDto) => (
|
||||
<StaggerItem key={match.id}>
|
||||
<MatchCard match={match} />
|
||||
</StaggerItem>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</StaggerContainer>
|
||||
) : (
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Body>
|
||||
<Flex justify="center" py={8}>
|
||||
<Text color="fg.muted">{t("no-matches")}</Text>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right Column — Predictions & Value Bets */}
|
||||
<VStack gap={6} flex={1} align="stretch" minW={0}>
|
||||
{/* Upcoming Predictions */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Header pb={2}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Heading as="h3" size="sm">
|
||||
{t("upcoming-predictions")}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
colorPalette="primary"
|
||||
onClick={() => router.push("/predictions")}
|
||||
>
|
||||
{t("view-all")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{upcomingLoading ? (
|
||||
<VStack gap={2} align="stretch">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="50px" borderRadius="lg" />
|
||||
))}
|
||||
</VStack>
|
||||
) : upcomingPredictions.length > 0 ? (
|
||||
<VStack gap={2} align="stretch">
|
||||
{upcomingPredictions.slice(0, 4).map((pred: MatchPredictionDto, idx: number) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={2.5}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
onClick={() =>
|
||||
router.push(`/matches/${pred.match_info.match_id}`)
|
||||
}
|
||||
>
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{pred.match_info.home_team} vs{" "}
|
||||
{pred.match_info.away_team}
|
||||
</Text>
|
||||
{pred.main_pick && (
|
||||
<Flex justify="space-between" align="center" mt={1}>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{pred.main_pick.pick}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette="primary"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{Math.round(
|
||||
pred.main_pick.calibrated_confidence ??
|
||||
pred.main_pick.confidence,
|
||||
)}
|
||||
%
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
textAlign="center"
|
||||
py={4}
|
||||
>
|
||||
{t("no-predictions")}
|
||||
</Text>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Value Bets */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Header pb={2}>
|
||||
<Heading as="h3" size="sm">
|
||||
{t("value-bets")}
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{valueBetsLoading ? (
|
||||
<VStack gap={2} align="stretch">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="44px" borderRadius="lg" />
|
||||
))}
|
||||
</VStack>
|
||||
) : valueBets.length > 0 ? (
|
||||
<VStack gap={2} align="stretch">
|
||||
{valueBets.slice(0, 5).map((vb: ValueBetDto, idx: number) => (
|
||||
<ValueBetMiniCard
|
||||
key={idx}
|
||||
matchName={vb.matchName}
|
||||
prediction={vb.prediction}
|
||||
odd={vb.odd}
|
||||
expectedValue={vb.expectedValue}
|
||||
confidence={vb.confidence}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
textAlign="center"
|
||||
py={4}
|
||||
>
|
||||
{t("no-predictions")}
|
||||
</Text>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DashboardContent } from "./dashboard-content";
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Input,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
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 { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import { LuSearch, LuArrowLeftRight } from "react-icons/lu";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
function TeamSearchInput({
|
||||
label,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
value: TeamDto | null;
|
||||
onSelect: (team: TeamDto) => void;
|
||||
}) {
|
||||
const t = useTranslations("h2h");
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const searchTeams = useSearchTeams(
|
||||
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box position="relative" w="full">
|
||||
<Text fontWeight="semibold" mb={2}>
|
||||
{label}
|
||||
</Text>
|
||||
<InputGroup startElement={<LuSearch />}>
|
||||
<Input
|
||||
value={value ? value.name : query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (value) onSelect(null as unknown as TeamDto);
|
||||
}}
|
||||
placeholder={t("search-team")}
|
||||
/>
|
||||
</InputGroup>
|
||||
{debouncedQuery.length >= 2 && !value && searchTeams.data?.data && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="full"
|
||||
left={0}
|
||||
right={0}
|
||||
bg="bg.panel"
|
||||
border="1px"
|
||||
borderColor="border.muted"
|
||||
borderRadius="md"
|
||||
zIndex={10}
|
||||
maxH="200px"
|
||||
overflowY="auto"
|
||||
>
|
||||
{searchTeams.data.data.map((team: TeamDto) => (
|
||||
<Flex
|
||||
key={team.id}
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: "gray.100", _dark: { bg: "gray.700" } }}
|
||||
onClick={() => onSelect(team)}
|
||||
align="center"
|
||||
gap={2}
|
||||
>
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ borderRadius: "50%" }}
|
||||
alt={team.name}
|
||||
/>
|
||||
) : null}
|
||||
<Text fontSize="sm">{team.name}</Text>
|
||||
{team.sport ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
colorScheme={team.sport === "football" ? "green" : "orange"}
|
||||
>
|
||||
{team.sport}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function H2HContent() {
|
||||
const t = useTranslations("h2h");
|
||||
const tMatches = useTranslations("matches");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const [team1, setTeam1] = useState<TeamDto | null>(null);
|
||||
const [team2, setTeam2] = useState<TeamDto | null>(null);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
const h2h = useHeadToHead(
|
||||
team1 && team2
|
||||
? { team1: team1.id, team2: team2.id }
|
||||
: { team1: "", team2: "" },
|
||||
);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (team1 && team2) {
|
||||
setHasSearched(true);
|
||||
h2h.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const stats: { label: string; value: number; color: string }[] = h2h.data
|
||||
?.data
|
||||
? [
|
||||
{
|
||||
label: team1?.name || t("team1"),
|
||||
value: h2h.data.data.team1Wins,
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
label: t("draws"),
|
||||
value: h2h.data.data.draws,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
label: team2?.name || t("team2"),
|
||||
value: h2h.data.data.team2Wins,
|
||||
color: "blue",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box maxW="5xl" mx="auto">
|
||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
||||
<HStack gap={2}>
|
||||
<LuArrowLeftRight />
|
||||
<Text>{t("title")}</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
|
||||
{/* Team Selection */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Body>
|
||||
<Flex
|
||||
direction={{ base: "column", md: "row" }}
|
||||
gap={4}
|
||||
align="flex-end"
|
||||
>
|
||||
<Box flex={1}>
|
||||
<TeamSearchInput
|
||||
label={t("team-1")}
|
||||
value={team1}
|
||||
onSelect={(t) => setTeam1(t)}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<TeamSearchInput
|
||||
label={t("team-2")}
|
||||
value={team2}
|
||||
onSelect={(t) => setTeam2(t)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={!team1 || !team2}
|
||||
minW="120px"
|
||||
>
|
||||
{t("compare")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && (
|
||||
<>
|
||||
{/* Stats Bar */}
|
||||
{h2h.isLoading ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="md" color="primary.500" />
|
||||
</Flex>
|
||||
) : h2h.data?.data ? (
|
||||
<>
|
||||
<Flex gap={4} mb={6} justify="center">
|
||||
{stats.map((s) => (
|
||||
<Card.Root
|
||||
key={s.label}
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
flex={1}
|
||||
maxW="200px"
|
||||
>
|
||||
<Card.Body textAlign="center">
|
||||
<Text
|
||||
fontSize="3xl"
|
||||
fontWeight="bold"
|
||||
color={`${s.color}.500`}
|
||||
>
|
||||
{s.value}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="fg.muted" mt={1}>
|
||||
{s.label}
|
||||
</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
{/* Match History */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
{tMatches("recent-matches")} (
|
||||
{h2h.data.data.matches?.length ?? 0})
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
<VStack gap={3}>
|
||||
{(
|
||||
h2h.data.data.matches as
|
||||
| MatchResponseDto[]
|
||||
| undefined
|
||||
| null
|
||||
)?.map((match: MatchResponseDto) => {
|
||||
const isHomeTeam1 = match.homeTeam?.id === team1?.id;
|
||||
// Backend returns scoreHome/scoreAway, not homeScore/awayScore
|
||||
const homeScore = Number((match as any).scoreHome ?? 0);
|
||||
const awayScore = Number((match as any).scoreAway ?? 0);
|
||||
const homeWon =
|
||||
(isHomeTeam1 && homeScore > awayScore) ||
|
||||
(!isHomeTeam1 && awayScore > homeScore);
|
||||
const isDraw = homeScore === awayScore;
|
||||
|
||||
// Parse mstUtc - can be bigint string from backend
|
||||
const matchDate = match.mstUtc
|
||||
? new Date(Number(match.mstUtc)).toLocaleDateString()
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={match.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
bg={
|
||||
isDraw
|
||||
? "gray.50"
|
||||
: homeWon
|
||||
? "green.50"
|
||||
: "red.50"
|
||||
}
|
||||
_dark={{
|
||||
bg: isDraw
|
||||
? "gray.750"
|
||||
: homeWon
|
||||
? "green.900"
|
||||
: "red.900",
|
||||
}}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Flex align="center" gap={3} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{match.homeTeam?.name}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
isDraw ? "gray" : homeWon ? "green" : "red"
|
||||
}
|
||||
>
|
||||
{homeScore ?? 0} - {awayScore ?? 0}
|
||||
</Badge>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{match.awayTeam?.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{matchDate}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</>
|
||||
) : (
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Body textAlign="center" py={8}>
|
||||
<Text color="fg.muted">{t("no-matches-found")}</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ScrollSlideUp,
|
||||
StaggerContainer,
|
||||
StaggerItem,
|
||||
AnimatedCounter,
|
||||
Sparkles,
|
||||
GradientOrb,
|
||||
ScrollScaleIn,
|
||||
springs,
|
||||
} from "@/components/motion";
|
||||
import { LuBrain, LuTrendingUp, LuTicket, LuRadio } from "react-icons/lu";
|
||||
|
||||
const MotionBox = motion.create(Box);
|
||||
|
||||
// ========================
|
||||
// Feature Card — glassmorphic with hover glow
|
||||
// ========================
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
colorPalette: string;
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
colorPalette,
|
||||
}: FeatureCardProps) {
|
||||
const cardBg = useColorModeValue(
|
||||
"rgba(255, 255, 255, 0.8)",
|
||||
"rgba(26, 32, 44, 0.7)",
|
||||
);
|
||||
const borderColor = useColorModeValue(
|
||||
"rgba(255, 255, 255, 0.6)",
|
||||
"rgba(255, 255, 255, 0.06)",
|
||||
);
|
||||
|
||||
return (
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="2xl"
|
||||
backdropFilter="blur(12px)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
transform: "translateY(-6px)",
|
||||
shadow: "2xl",
|
||||
borderColor: `${colorPalette}.400`,
|
||||
}}
|
||||
transition="all 0.4s cubic-bezier(0.25, 0.1, 0.25, 1)"
|
||||
>
|
||||
{/* Hover glow effect */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-50%"
|
||||
left="-50%"
|
||||
w="200%"
|
||||
h="200%"
|
||||
bg={`radial-gradient(circle at center, ${colorPalette === "primary" ? "rgba(56,178,172,0.06)" : colorPalette === "green" ? "rgba(72,187,120,0.06)" : colorPalette === "purple" ? "rgba(128,90,213,0.06)" : "rgba(245,101,101,0.06)"} 0%, transparent 70%)`}
|
||||
opacity={0}
|
||||
transition="opacity 0.4s"
|
||||
_groupHover={{ opacity: 1 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<Card.Body>
|
||||
<VStack gap={4} align="flex-start">
|
||||
<Flex
|
||||
boxSize="56px"
|
||||
bg={`${colorPalette}.subtle`}
|
||||
borderRadius="xl"
|
||||
align="center"
|
||||
justify="center"
|
||||
color={`${colorPalette}.fg`}
|
||||
fontSize="2xl"
|
||||
shadow="sm"
|
||||
>
|
||||
{icon}
|
||||
</Flex>
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" mb={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="fg.muted" lineHeight="tall">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Stat Block — with real counting animation
|
||||
// ========================
|
||||
|
||||
interface StatBlockProps {
|
||||
value: number;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function StatBlock({ value, label, suffix }: StatBlockProps) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Text
|
||||
fontSize={{ base: "3xl", md: "4xl" }}
|
||||
fontWeight="900"
|
||||
className="gradient-text"
|
||||
>
|
||||
<AnimatedCounter value={value} suffix={suffix} duration={2.5} />
|
||||
</Text>
|
||||
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Home Content — Premium
|
||||
// ========================
|
||||
|
||||
export default function HomeContent() {
|
||||
const t = useTranslations("landing");
|
||||
const router = useRouter();
|
||||
|
||||
const heroBg = useColorModeValue(
|
||||
"linear-gradient(135deg, #E6FFFA 0%, #C4F1F9 25%, #B2F5EA 50%, #81E6D9 75%, #4FD1C5 100%)",
|
||||
"linear-gradient(135deg, #1A202C 0%, #1D4044 30%, #234E52 60%, #285E61 100%)",
|
||||
);
|
||||
const heroTextColor = useColorModeValue("gray.800", "white");
|
||||
const statsBg = useColorModeValue(
|
||||
"rgba(255, 255, 255, 0.6)",
|
||||
"rgba(26, 32, 44, 0.6)",
|
||||
);
|
||||
const statsBorder = useColorModeValue(
|
||||
"rgba(255, 255, 255, 0.8)",
|
||||
"rgba(255, 255, 255, 0.06)",
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="gradient-mesh" position="relative">
|
||||
{/* Hero Section */}
|
||||
<MotionBox
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<Box
|
||||
bgGradient={heroBg}
|
||||
borderRadius="3xl"
|
||||
px={{ base: 6, md: 12 }}
|
||||
py={{ base: 14, md: 24 }}
|
||||
mb={12}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Animated gradient orbs */}
|
||||
<GradientOrb
|
||||
color="rgba(56, 178, 172, 0.2)"
|
||||
size={250}
|
||||
top="-80px"
|
||||
right="-60px"
|
||||
blur={80}
|
||||
/>
|
||||
<GradientOrb
|
||||
color="rgba(128, 90, 213, 0.15)"
|
||||
size={200}
|
||||
bottom="-60px"
|
||||
left="-40px"
|
||||
blur={70}
|
||||
/>
|
||||
<GradientOrb
|
||||
color="rgba(66, 153, 225, 0.1)"
|
||||
size={150}
|
||||
top="50%"
|
||||
right="20%"
|
||||
blur={50}
|
||||
/>
|
||||
|
||||
{/* Sparkle particles */}
|
||||
<Sparkles count={8} color="rgba(255, 255, 255, 0.4)" />
|
||||
|
||||
{/* Decorative grid pattern */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
opacity={0.03}
|
||||
backgroundImage="radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)"
|
||||
backgroundSize="40px 40px"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<VStack
|
||||
gap={6}
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
maxW="2xl"
|
||||
mx="auto"
|
||||
textAlign="center"
|
||||
>
|
||||
<MotionBox
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.6 }}
|
||||
>
|
||||
<Heading
|
||||
as="h1"
|
||||
fontSize={{ base: "3xl", md: "5xl", lg: "6xl" }}
|
||||
fontWeight="800"
|
||||
color={heroTextColor}
|
||||
lineHeight="shorter"
|
||||
letterSpacing="tight"
|
||||
>
|
||||
{t("hero-title")}
|
||||
</Heading>
|
||||
</MotionBox>
|
||||
|
||||
<MotionBox
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
>
|
||||
<Text
|
||||
fontSize={{ base: "md", md: "lg" }}
|
||||
color={heroTextColor}
|
||||
opacity={0.85}
|
||||
maxW="lg"
|
||||
lineHeight="tall"
|
||||
>
|
||||
{t("hero-subtitle")}
|
||||
</Text>
|
||||
</MotionBox>
|
||||
|
||||
<MotionBox
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<HStack gap={4} mt={2}>
|
||||
<Button
|
||||
size="lg"
|
||||
colorPalette="primary"
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
fontWeight="bold"
|
||||
onClick={() => router.push("/matches")}
|
||||
_hover={{ transform: "scale(1.05)", shadow: "xl" }}
|
||||
transition="all 0.3s"
|
||||
className="animate-glow"
|
||||
>
|
||||
{t("get-started")} →
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
color={heroTextColor}
|
||||
borderColor={heroTextColor}
|
||||
_hover={{
|
||||
bg: "whiteAlpha.200",
|
||||
transform: "scale(1.03)",
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
>
|
||||
{t("learn-more")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</MotionBox>
|
||||
</VStack>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
|
||||
{/* Stats Section — glassmorphic card */}
|
||||
<ScrollSlideUp>
|
||||
<Box
|
||||
bg={statsBg}
|
||||
backdropFilter="blur(16px) saturate(180%)"
|
||||
border="1px solid"
|
||||
borderColor={statsBorder}
|
||||
borderRadius="2xl"
|
||||
px={{ base: 4, md: 8 }}
|
||||
py={{ base: 6, md: 8 }}
|
||||
mb={16}
|
||||
shadow="lg"
|
||||
>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={6}>
|
||||
<StatBlock value={15000} label={t("stats-predictions")} suffix="+" />
|
||||
<StatBlock value={72} label={t("stats-accuracy")} suffix="%" />
|
||||
<StatBlock value={3200} label={t("stats-users")} suffix="+" />
|
||||
<StatBlock value={50000} label={t("stats-matches")} suffix="+" />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</ScrollSlideUp>
|
||||
|
||||
{/* Features Section */}
|
||||
<Box mb={16}>
|
||||
<ScrollScaleIn>
|
||||
<Heading as="h2" size="xl" textAlign="center" mb={3} fontWeight="bold">
|
||||
{t("features-title")}
|
||||
</Heading>
|
||||
<Text
|
||||
textAlign="center"
|
||||
color="fg.muted"
|
||||
fontSize="md"
|
||||
maxW="lg"
|
||||
mx="auto"
|
||||
mb={10}
|
||||
>
|
||||
{t("hero-subtitle")}
|
||||
</Text>
|
||||
</ScrollScaleIn>
|
||||
|
||||
<StaggerContainer inView>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} gap={6}>
|
||||
<StaggerItem>
|
||||
<FeatureCard
|
||||
icon={<LuBrain />}
|
||||
title={t("feature-ai")}
|
||||
description={t("feature-ai-desc")}
|
||||
colorPalette="primary"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<FeatureCard
|
||||
icon={<LuTrendingUp />}
|
||||
title={t("feature-value")}
|
||||
description={t("feature-value-desc")}
|
||||
colorPalette="green"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<FeatureCard
|
||||
icon={<LuTicket />}
|
||||
title={t("feature-coupon")}
|
||||
description={t("feature-coupon-desc")}
|
||||
colorPalette="purple"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<FeatureCard
|
||||
icon={<LuRadio />}
|
||||
title={t("feature-live")}
|
||||
description={t("feature-live-desc")}
|
||||
colorPalette="red"
|
||||
/>
|
||||
</StaggerItem>
|
||||
</SimpleGrid>
|
||||
</StaggerContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HomeContent } from "./home-content";
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Text, HStack, Flex, Link as ChakraLink } from "@chakra-ui/react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Box as="footer" bg="bg.muted" mt="auto">
|
||||
<Flex
|
||||
justify={{ base: "center", md: "space-between" }}
|
||||
align="center"
|
||||
maxW="8xl"
|
||||
mx="auto"
|
||||
wrap="wrap"
|
||||
px={{ base: 4, md: 8 }}
|
||||
py={4}
|
||||
gap={3}
|
||||
>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
© {new Date().getFullYear()}{" "}
|
||||
<ChakraLink
|
||||
href="/"
|
||||
color={{ base: "primary.600", _dark: "primary.300" }}
|
||||
focusRing="none"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
Suggest Bet
|
||||
</ChakraLink>
|
||||
. {t("all-right-reserved")}
|
||||
</Text>
|
||||
|
||||
<HStack spaceX={4}>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/privacy-and-security-policy"
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
focusRing="none"
|
||||
textDecor="none"
|
||||
transition="color 0.2s"
|
||||
_hover={{
|
||||
color: { base: "primary.500", _dark: "primary.300" },
|
||||
}}
|
||||
>
|
||||
{t("privacy-policy")}
|
||||
</ChakraLink>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/terms-of-use"
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
focusRing="none"
|
||||
textDecor="none"
|
||||
transition="color 0.2s"
|
||||
_hover={{
|
||||
color: { base: "primary.500", _dark: "primary.300" },
|
||||
}}
|
||||
>
|
||||
{t("terms-of-service")}
|
||||
</ChakraLink>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Box, Link as ChakraLink, Text } from "@chakra-ui/react";
|
||||
import { NavItem } from "@/config/navigation";
|
||||
import {
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/overlays/menu";
|
||||
import { RxChevronDown } from "react-icons/rx";
|
||||
import { useActiveNavItem } from "@/hooks/useActiveNavItem";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
function HeaderLink({ item }: { item: NavItem }) {
|
||||
const t = useTranslations("nav");
|
||||
const { isActive, isChildActive } = useActiveNavItem(item);
|
||||
const [open, setOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseOpen = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseClose = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), 150);
|
||||
};
|
||||
|
||||
const activeBg = { base: "primary.50", _dark: "primary.950" };
|
||||
const activeColor = { base: "primary.600", _dark: "primary.300" };
|
||||
const hoverBg = { base: "gray.50", _dark: "gray.800" };
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
{item.children ? (
|
||||
<Box onMouseEnter={handleMouseOpen} onMouseLeave={handleMouseClose}>
|
||||
<MenuRoot open={open} onOpenChange={(e) => setOpen(e.open)}>
|
||||
<MenuTrigger asChild>
|
||||
<Text
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap="1"
|
||||
cursor="pointer"
|
||||
color={isActive ? activeColor : "fg.muted"}
|
||||
bg={isActive ? activeBg : "transparent"}
|
||||
fontWeight={isActive ? "bold" : "medium"}
|
||||
fontSize="sm"
|
||||
px="3"
|
||||
py="1.5"
|
||||
borderRadius="lg"
|
||||
transition="all 0.2s ease"
|
||||
_hover={{
|
||||
color: activeColor,
|
||||
bg: isActive ? activeBg : hoverBg,
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
<RxChevronDown
|
||||
style={{
|
||||
transform: open ? "rotate(-180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{item.children.map((child) => {
|
||||
const isActiveChild = isChildActive(child.href);
|
||||
return (
|
||||
<MenuItem key={child.href} value={child.href}>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href={child.href}
|
||||
focusRing="none"
|
||||
w="full"
|
||||
color={isActiveChild ? activeColor : "fg.muted"}
|
||||
textDecor="none"
|
||||
fontWeight={isActiveChild ? "bold" : "medium"}
|
||||
fontSize="sm"
|
||||
_hover={{ color: activeColor }}
|
||||
>
|
||||
{t(child.label)}
|
||||
</ChakraLink>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</Box>
|
||||
) : (
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href={item.href}
|
||||
focusRing="none"
|
||||
color={isActive ? activeColor : "fg.muted"}
|
||||
bg={isActive ? activeBg : "transparent"}
|
||||
textDecor="none"
|
||||
fontWeight={isActive ? "bold" : "medium"}
|
||||
fontSize="sm"
|
||||
px="3"
|
||||
py="1.5"
|
||||
borderRadius="lg"
|
||||
transition="all 0.2s ease"
|
||||
_hover={{
|
||||
color: activeColor,
|
||||
bg: isActive ? activeBg : hoverBg,
|
||||
textDecor: "none",
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
</ChakraLink>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderLink;
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
IconButton,
|
||||
Link as ChakraLink,
|
||||
Stack,
|
||||
VStack,
|
||||
Button,
|
||||
MenuItem,
|
||||
ClientOnly,
|
||||
Text,
|
||||
Separator,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ColorModeButton } from "@/components/ui/color-mode";
|
||||
import {
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/overlays/popover";
|
||||
import { RxHamburgerMenu } from "react-icons/rx";
|
||||
import { NAV_ITEMS, getVisibleNavItems } from "@/config/navigation";
|
||||
import HeaderLink from "./header-link";
|
||||
import MobileHeaderLink from "./mobile-header-link";
|
||||
import LocaleSwitcher from "@/components/ui/locale-switcher";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
MenuContent,
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/overlays/menu";
|
||||
import { Avatar } from "@/components/ui/data-display/avatar";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { authConfig } from "@/config/auth";
|
||||
import { LoginModal } from "@/components/auth/login-modal";
|
||||
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
|
||||
import GlobalSearch from "@/components/search/global-search";
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations();
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const isAuthenticated = !!session;
|
||||
const isLoading = status === "loading";
|
||||
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsSticky(window.scrollY >= 10);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
if (authConfig.isAuthRequired) {
|
||||
router.replace("/signin");
|
||||
}
|
||||
};
|
||||
|
||||
// Desktop auth section
|
||||
const renderAuthSection = () => {
|
||||
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||
<MenuTrigger rounded="full" focusRing="none">
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem value="profile" onClick={() => router.push("/profile")}>
|
||||
<LuUser />
|
||||
{t("nav.profile")}
|
||||
</MenuItem>
|
||||
{session?.user &&
|
||||
session.user.roles?.includes("ADMIN") && (
|
||||
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
||||
<LuShield />
|
||||
{t("nav.admin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleLogout} value="sign-out">
|
||||
{t("auth.sign-out")}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Mobile auth section
|
||||
const renderMobileAuthSection = () => {
|
||||
if (isLoading) return <Skeleton height="10" width="full" />;
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<VStack gap={2} w="full">
|
||||
<Flex align="center" gap={2} w="full">
|
||||
<Avatar
|
||||
name={session?.user?.name || "User"}
|
||||
variant="solid"
|
||||
size="sm"
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="semibold" truncate>
|
||||
{session?.user?.name || session?.user?.email}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
width="full"
|
||||
justifyContent="flex-start"
|
||||
onClick={() => router.push("/profile")}
|
||||
>
|
||||
<LuUser />
|
||||
{t("nav.profile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="surface"
|
||||
size="sm"
|
||||
width="full"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("auth.sign-out")}
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
width="full"
|
||||
borderRadius="full"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
as="nav"
|
||||
bg={isSticky ? "rgba(255, 255, 255, 0.75)" : "white"}
|
||||
_dark={{
|
||||
bg: isSticky ? "rgba(1, 1, 1, 0.75)" : "black",
|
||||
}}
|
||||
shadow={isSticky ? "md" : "xs"}
|
||||
backdropFilter="blur(16px) saturate(180%)"
|
||||
borderBottom="1px solid"
|
||||
borderColor={{ base: "gray.100", _dark: "gray.800" }}
|
||||
transition="all 0.3s ease-in-out"
|
||||
px={{ base: 4, md: 6 }}
|
||||
py="2.5"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
w="full"
|
||||
>
|
||||
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
|
||||
{/* Logo */}
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/home"
|
||||
focusRing="none"
|
||||
textDecor="none"
|
||||
_hover={{ textDecor: "none" }}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap="2"
|
||||
flexShrink={0}
|
||||
mr={6}
|
||||
>
|
||||
<Flex
|
||||
boxSize="32px"
|
||||
bg="primary.500"
|
||||
borderRadius="lg"
|
||||
align="center"
|
||||
justify="center"
|
||||
shadow="sm"
|
||||
>
|
||||
<LuZap color="white" size={18} />
|
||||
</Flex>
|
||||
<Box>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="800"
|
||||
lineHeight="1"
|
||||
color={{ base: "gray.900", _dark: "white" }}
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
Suggest
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="600"
|
||||
lineHeight="1"
|
||||
mt="1px"
|
||||
color={{ base: "primary.600", _dark: "primary.300" }}
|
||||
letterSpacing="0.08em"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
BET
|
||||
</Text>
|
||||
</Box>
|
||||
</ChakraLink>
|
||||
|
||||
{/* DESKTOP NAVIGATION */}
|
||||
<HStack gap={1} display={{ base: "none", lg: "flex" }} flex={1}>
|
||||
{visibleItems.map((item) => (
|
||||
<HeaderLink key={item.href} item={item} />
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* Right side actions */}
|
||||
<HStack gap={2} flexShrink={0}>
|
||||
{/* Global Search (Desktop) */}
|
||||
<Box display={{ base: "none", lg: "block" }}>
|
||||
<GlobalSearch />
|
||||
</Box>
|
||||
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
height="5"
|
||||
display={{ base: "none", lg: "block" }}
|
||||
borderColor={{ base: "gray.200", _dark: "gray.700" }}
|
||||
/>
|
||||
|
||||
<ColorModeButton colorPalette="gray" />
|
||||
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
|
||||
<LocaleSwitcher />
|
||||
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
|
||||
{renderAuthSection()}
|
||||
</ClientOnly>
|
||||
</Box>
|
||||
|
||||
{/* MOBILE NAVIGATION */}
|
||||
<Stack display={{ base: "inline-flex", lg: "none" }}>
|
||||
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger as="span">
|
||||
<IconButton aria-label="Open menu" variant="ghost">
|
||||
<RxHamburgerMenu />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
||||
<PopoverBody>
|
||||
<VStack mt="2" align="start" spaceY="2" w="full">
|
||||
{visibleItems.map((item) => (
|
||||
<MobileHeaderLink key={item.href} item={item} />
|
||||
))}
|
||||
<Box
|
||||
w="full"
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor="border.muted"
|
||||
>
|
||||
<LocaleSwitcher />
|
||||
</Box>
|
||||
{renderMobileAuthSection()}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</ClientOnly>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Login Modal */}
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Text,
|
||||
Box,
|
||||
Link as ChakraLink,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { RxChevronDown } from "react-icons/rx";
|
||||
import { NavItem } from "@/config/navigation";
|
||||
import { useActiveNavItem } from "@/hooks/useActiveNavItem";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function MobileHeaderLink({ item }: { item: NavItem }) {
|
||||
const t = useTranslations("nav");
|
||||
const { isActive, isChildActive } = useActiveNavItem(item);
|
||||
const { open, onToggle } = useDisclosure();
|
||||
|
||||
const activeColor = { base: "primary.600", _dark: "primary.300" };
|
||||
const activeBg = { base: "primary.50", _dark: "primary.950" };
|
||||
|
||||
return (
|
||||
<Box key={item.label} w="full">
|
||||
{item.children ? (
|
||||
<VStack align="start" w="full" spaceY={0}>
|
||||
<Text
|
||||
onClick={onToggle}
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap="1"
|
||||
cursor="pointer"
|
||||
color={isActive ? activeColor : "fg.muted"}
|
||||
bg={isActive ? activeBg : "transparent"}
|
||||
fontWeight={isActive ? "bold" : "semibold"}
|
||||
fontSize="sm"
|
||||
px="3"
|
||||
py="2"
|
||||
w="full"
|
||||
borderRadius="lg"
|
||||
transition="all 0.2s ease"
|
||||
_hover={{
|
||||
color: activeColor,
|
||||
bg: activeBg,
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
<RxChevronDown
|
||||
style={{
|
||||
transform: open ? "rotate(-180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s",
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{open && item.children && (
|
||||
<VStack align="start" pl="3" pt="1" pb="1" w="full" spaceY={0}>
|
||||
{item.children.map((child) => {
|
||||
const isActiveChild = isChildActive(child.href);
|
||||
return (
|
||||
<ChakraLink
|
||||
key={child.href}
|
||||
as={Link}
|
||||
href={child.href}
|
||||
focusRing="none"
|
||||
color={isActiveChild ? activeColor : "fg.muted"}
|
||||
bg={isActiveChild ? activeBg : "transparent"}
|
||||
textDecor="none"
|
||||
fontWeight={isActiveChild ? "bold" : "medium"}
|
||||
fontSize="sm"
|
||||
px="3"
|
||||
py="1.5"
|
||||
w="full"
|
||||
borderRadius="md"
|
||||
transition="all 0.2s ease"
|
||||
_hover={{
|
||||
color: activeColor,
|
||||
bg: activeBg,
|
||||
textDecor: "none",
|
||||
}}
|
||||
>
|
||||
{t(child.label)}
|
||||
</ChakraLink>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href={item.href}
|
||||
w="full"
|
||||
focusRing="none"
|
||||
color={isActive ? activeColor : "fg.muted"}
|
||||
bg={isActive ? activeBg : "transparent"}
|
||||
textDecor="none"
|
||||
fontWeight={isActive ? "bold" : "semibold"}
|
||||
fontSize="sm"
|
||||
px="3"
|
||||
py="2"
|
||||
borderRadius="lg"
|
||||
display="block"
|
||||
transition="all 0.2s ease"
|
||||
_hover={{
|
||||
color: activeColor,
|
||||
bg: activeBg,
|
||||
textDecor: "none",
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
</ChakraLink>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileHeaderLink;
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Input,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import {
|
||||
useCountries,
|
||||
useLeagues,
|
||||
useSearchTeams,
|
||||
} from "@/lib/api/leagues/use-hooks";
|
||||
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
|
||||
import { LuSearch, LuGlobe, LuTrophy, LuUsers } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { Link as ChakraLink } from "@chakra-ui/react";
|
||||
|
||||
export default function LeaguesContent() {
|
||||
const t = useTranslations("leagues");
|
||||
const tMatches = useTranslations("matches");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
|
||||
const [sportFilter, setSportFilter] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const countries = useCountries();
|
||||
const leagues = useLeagues(
|
||||
sportFilter
|
||||
? { sport: sportFilter as "football" | "basketball" }
|
||||
: undefined,
|
||||
);
|
||||
const searchTeams = useSearchTeams(
|
||||
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" },
|
||||
);
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box maxW="6xl" mx="auto">
|
||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
||||
{t("title")}
|
||||
</Heading>
|
||||
|
||||
<Tabs.Root
|
||||
value={activeTab}
|
||||
onValueChange={(e) => setActiveTab(e.value as "leagues" | "teams")}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="leagues">
|
||||
<LuGlobe />
|
||||
{t("countries-leagues")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="teams">
|
||||
<LuUsers />
|
||||
{tMatches("search-teams")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
{/* Countries & Leagues Tab */}
|
||||
<Tabs.Content value="leagues">
|
||||
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
|
||||
{/* Countries Sidebar */}
|
||||
<Box w={{ base: "full", lg: "280px" }} flexShrink={0}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading as="h4" size="sm">
|
||||
<HStack gap={2}>
|
||||
<LuGlobe />
|
||||
<Text>{t("countries")}</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0} maxH="600px" overflowY="auto">
|
||||
{countries.isLoading ? (
|
||||
<Flex justify="center" py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack gap={1} align="stretch">
|
||||
{countries.data?.data?.map((country: CountryDto) => (
|
||||
<Flex
|
||||
key={country.id}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: "gray.50",
|
||||
_dark: { bg: "gray.750" },
|
||||
}}
|
||||
cursor="pointer"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
{country.flag ? (
|
||||
<img
|
||||
src={country.flag}
|
||||
width="16"
|
||||
height="16"
|
||||
style={{ borderRadius: "2px" }}
|
||||
alt={country.name}
|
||||
/>
|
||||
) : null}
|
||||
<Text fontSize="sm">{country.name}</Text>
|
||||
</HStack>
|
||||
<Badge size="xs" colorScheme="gray">
|
||||
{country.leagues?.length || 0}
|
||||
</Badge>
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
|
||||
{/* Leagues List */}
|
||||
<Box flex={1}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Header>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Heading as="h4" size="sm">
|
||||
<HStack gap={2}>
|
||||
<LuTrophy />
|
||||
<Text>{t("leagues")}</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack gap={2}>
|
||||
<Badge
|
||||
cursor="pointer"
|
||||
colorScheme={!sportFilter ? "primary" : "gray"}
|
||||
onClick={() => setSportFilter("")}
|
||||
>
|
||||
{tMatches("all")}
|
||||
</Badge>
|
||||
<Badge
|
||||
cursor="pointer"
|
||||
colorScheme={
|
||||
sportFilter === "football" ? "green" : "gray"
|
||||
}
|
||||
onClick={() =>
|
||||
setSportFilter(
|
||||
sportFilter === "football" ? "" : "football",
|
||||
)
|
||||
}
|
||||
>
|
||||
{tMatches("football")}
|
||||
</Badge>
|
||||
<Badge
|
||||
cursor="pointer"
|
||||
colorScheme={
|
||||
sportFilter === "basketball" ? "orange" : "gray"
|
||||
}
|
||||
onClick={() =>
|
||||
setSportFilter(
|
||||
sportFilter === "basketball" ? "" : "basketball",
|
||||
)
|
||||
}
|
||||
>
|
||||
{tMatches("basketball")}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{leagues.isLoading ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="sm" />
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack gap={2}>
|
||||
{leagues.data?.data?.map((league: LeagueDto) => (
|
||||
<ChakraLink
|
||||
key={league.id}
|
||||
as={Link}
|
||||
href="/matches"
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{
|
||||
borderColor: "primary.300",
|
||||
bg: "primary.50",
|
||||
_dark: { bg: "gray.750" },
|
||||
}}
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
textDecoration="none"
|
||||
color="inherit"
|
||||
>
|
||||
<VStack align="start" gap={0}>
|
||||
<Text fontWeight="semibold">{league.name}</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{league.country?.name || ""}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack gap={2}>
|
||||
{league.sport ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
colorScheme={
|
||||
league.sport === "football"
|
||||
? "green"
|
||||
: "orange"
|
||||
}
|
||||
>
|
||||
{league.sport}
|
||||
</Badge>
|
||||
) : null}
|
||||
{league.season ? (
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{league.season}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</ChakraLink>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* Teams Search Tab */}
|
||||
<Tabs.Content value="teams">
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<InputGroup startElement={<LuSearch />} mb={4}>
|
||||
<Input
|
||||
placeholder={tMatches("search-teams")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{debouncedQuery.length < 2 ? (
|
||||
<Text color="fg.muted" textAlign="center" py={8}>
|
||||
{t("search-at-least-2")}
|
||||
</Text>
|
||||
) : searchTeams.isLoading ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="md" />
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack gap={2}>
|
||||
{searchTeams.data?.data?.map((team: TeamDto) => (
|
||||
<ChakraLink
|
||||
key={team.id}
|
||||
as={Link}
|
||||
href={`/teams/${team.id}`}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{
|
||||
borderColor: "primary.300",
|
||||
bg: "primary.50",
|
||||
_dark: { bg: "gray.750" },
|
||||
}}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
textDecoration="none"
|
||||
color="inherit"
|
||||
>
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
width="32"
|
||||
height="32"
|
||||
style={{ borderRadius: "50%" }}
|
||||
alt={team.name}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
boxSize="32px"
|
||||
borderRadius="full"
|
||||
bg="gray.200"
|
||||
_dark={{ bg: "gray.600" }}
|
||||
/>
|
||||
)}
|
||||
<VStack align="start" gap={0}>
|
||||
<Text fontWeight="semibold">{team.name}</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{team.country || ""}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
ml="auto"
|
||||
size="xs"
|
||||
colorScheme={
|
||||
team.sport === "football" ? "green" : "orange"
|
||||
}
|
||||
>
|
||||
{team.sport}
|
||||
</Badge>
|
||||
</ChakraLink>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as MatchCard } from "./match-card";
|
||||
export { default as MatchList } from "./match-list";
|
||||
export { default as SportFilter } from "./sport-filter";
|
||||
export { default as LeagueSidebar } from "./league-sidebar";
|
||||
export { default as PredictionCard } from "./prediction-card";
|
||||
export { default as MatchDetailContent } from "./match-detail-content";
|
||||
export { default as MatchesContent } from "./matches-content";
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { Box, VStack, Text, Badge, Flex, Image } from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import type { ActiveLeagueDto } from "@/lib/api/matches/types";
|
||||
|
||||
interface LeagueSidebarProps {
|
||||
leagues: ActiveLeagueDto[];
|
||||
selectedLeagueId: string | null;
|
||||
onSelect: (leagueId: string | null) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function LeagueSidebar({
|
||||
leagues,
|
||||
selectedLeagueId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: LeagueSidebarProps) {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
const bg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
const activeBg = useColorModeValue("primary.50", "primary.900");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
p={4}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
h="40px"
|
||||
w="100%"
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box px={4} py={3} borderBottomWidth="1px" borderColor={borderColor}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wide"
|
||||
color="fg.muted"
|
||||
>
|
||||
{t("active-leagues")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* All Leagues Option */}
|
||||
<Box
|
||||
px={4}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
bg={selectedLeagueId === null ? activeBg : "transparent"}
|
||||
_hover={{ bg: selectedLeagueId === null ? activeBg : hoverBg }}
|
||||
onClick={() => onSelect(null)}
|
||||
transition="background 0.15s"
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={selectedLeagueId === null ? "bold" : "medium"}
|
||||
color={selectedLeagueId === null ? "primary.fg" : "fg"}
|
||||
>
|
||||
{t("all-leagues")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* League List */}
|
||||
<VStack gap={0} align="stretch" maxH="60vh" overflowY="auto">
|
||||
{leagues.map((league) => {
|
||||
const isActive = selectedLeagueId === league.id;
|
||||
return (
|
||||
<Box
|
||||
key={league.id}
|
||||
px={4}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
bg={isActive ? activeBg : "transparent"}
|
||||
_hover={{ bg: isActive ? activeBg : hoverBg }}
|
||||
onClick={() => onSelect(league.id)}
|
||||
transition="background 0.15s"
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex align="center" gap={2} minW={0} flex={1}>
|
||||
{league.countryFlag && (
|
||||
<Image
|
||||
src={league.countryFlag}
|
||||
alt={league.countryName || ""}
|
||||
boxSize="16px"
|
||||
objectFit="contain"
|
||||
flexShrink={0}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={isActive ? "bold" : "medium"}
|
||||
color={isActive ? "primary.fg" : "fg"}
|
||||
truncate
|
||||
>
|
||||
{league.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={1.5} flexShrink={0}>
|
||||
{league.liveCount > 0 && (
|
||||
<Badge
|
||||
colorPalette="red"
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
px={1.5}
|
||||
>
|
||||
{league.liveCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
colorPalette="gray"
|
||||
variant="subtle"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
px={1.5}
|
||||
>
|
||||
{league.matchCount}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Badge,
|
||||
HStack,
|
||||
VStack,
|
||||
Image,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { slideUpVariants } from "@/components/motion";
|
||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
|
||||
interface MatchCardProps {
|
||||
match: MatchResponseDto;
|
||||
}
|
||||
|
||||
const MotionBox = motion.create(Box);
|
||||
|
||||
export default function MatchCard({ match }: MatchCardProps) {
|
||||
const t = useTranslations("matches");
|
||||
const router = useRouter();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const cardBorder = useColorModeValue("gray.100", "gray.700");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
||||
const hoverBorder = useColorModeValue("primary.200", "primary.500");
|
||||
|
||||
const isLive = match.status === "LIVE";
|
||||
const isFinished = match.status === "Finished";
|
||||
|
||||
const statusColor = isLive ? "red" : isFinished ? "gray" : "green";
|
||||
const statusText = isLive
|
||||
? t("live")
|
||||
: isFinished
|
||||
? t("finished")
|
||||
: t("not-started");
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/matches/${match.id}`);
|
||||
};
|
||||
|
||||
// Date handling from timestamp (mstUtc)
|
||||
const matchDate = new Date(match.mstUtc);
|
||||
|
||||
return (
|
||||
<MotionBox
|
||||
variants={slideUpVariants}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={handleClick}
|
||||
transition={{ duration: 0.25 }}
|
||||
_hover={{
|
||||
bg: hoverBg,
|
||||
borderColor: hoverBorder,
|
||||
transform: "translateY(-3px)",
|
||||
shadow: "xl",
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Badge
|
||||
colorPalette={statusColor}
|
||||
variant="subtle"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{isLive && (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-block"
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
bg="red.500"
|
||||
mr={1.5}
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
)}
|
||||
{statusText}
|
||||
</Badge>
|
||||
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{matchDate.toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Teams */}
|
||||
<HStack gap={3} justify="space-between">
|
||||
{/* Home Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.homeTeamLogo ? (
|
||||
<Image
|
||||
src={match.homeTeamLogo}
|
||||
alt={match.homeTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.homeTeamName?.charAt(0) || "H"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.homeTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Score or VS */}
|
||||
<VStack gap={0} flexShrink={0}>
|
||||
{(isLive || isFinished) &&
|
||||
match.scoreHome !== undefined &&
|
||||
match.scoreAway !== undefined ? (
|
||||
<HStack gap={2}>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreHome}
|
||||
</Text>
|
||||
<Text fontSize="lg" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreAway}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text fontSize="md" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.awayTeamLogo ? (
|
||||
<Image
|
||||
src={match.awayTeamLogo}
|
||||
alt={match.awayTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.awayTeamName?.charAt(0) || "A"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.awayTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* League Info */}
|
||||
{(match.leagueName || match.countryName) && (
|
||||
<Flex
|
||||
mt={3}
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={1.5}
|
||||
>
|
||||
{/* Flag handling if available in flat response, otherwise skip or pass from parent */}
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{match.countryName && `${match.countryName} • `}
|
||||
{match.leagueName}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</MotionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Image,
|
||||
Spinner,
|
||||
Button,
|
||||
Card,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { useMatchDetails } from "@/lib/api/matches/use-hooks";
|
||||
import { usePrediction } from "@/lib/api/predictions/use-hooks";
|
||||
import PredictionCard from "@/components/matches/prediction-card";
|
||||
import OddsCard from "@/components/matches/odds-card";
|
||||
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
|
||||
|
||||
export default function MatchDetailContent() {
|
||||
const t = useTranslations("matches");
|
||||
const tPred = useTranslations("predictions");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const matchId = params.id as string;
|
||||
|
||||
const { data: matchData, isLoading: matchLoading } = useMatchDetails(matchId);
|
||||
const {
|
||||
data: predictionData,
|
||||
isLoading: predLoading,
|
||||
refetch: refetchPrediction,
|
||||
} = usePrediction(matchId);
|
||||
|
||||
const headerBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const match = matchData?.data;
|
||||
const prediction = predictionData?.data;
|
||||
|
||||
if (matchLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={20} direction="column" gap={4}>
|
||||
<Text color="fg.muted" fontSize="lg">
|
||||
{t("no-matches")}
|
||||
</Text>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<LuArrowLeft />
|
||||
{tCommon("back")}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const isLive = match.status === "LIVE";
|
||||
const isFinished = match.status === "Finished";
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
mb={4}
|
||||
onClick={() => router.back()}
|
||||
gap={1.5}
|
||||
>
|
||||
<LuArrowLeft />
|
||||
{tCommon("back")}
|
||||
</Button>
|
||||
{/* Match Header */}
|
||||
<Card.Root
|
||||
bg={headerBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Body>
|
||||
{/* League Info */}
|
||||
{match.league && (
|
||||
<Flex justify="center" align="center" gap={2} mb={4}>
|
||||
{match.league.country?.flag && (
|
||||
<Image
|
||||
src={match.league.country.flag}
|
||||
alt={match.league.country.name || ""}
|
||||
boxSize="18px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||
{match.league.name}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette={isLive ? "red" : isFinished ? "gray" : "green"}
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{isLive && (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-block"
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
bg="red.500"
|
||||
mr={1}
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
)}
|
||||
{isLive
|
||||
? t("live")
|
||||
: isFinished
|
||||
? t("finished")
|
||||
: t("not-started")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Teams & Score */}
|
||||
<HStack gap={6} justify="center" align="center">
|
||||
{/* Home Team */}
|
||||
<VStack gap={2} flex={1} align="center">
|
||||
{match.homeTeam?.logo ? (
|
||||
<Image
|
||||
src={match.homeTeam.logo}
|
||||
alt={match.homeTeam.name}
|
||||
boxSize="64px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="64px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="primary.fg">
|
||||
{match.homeTeam?.name?.charAt(0) || "H"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text fontSize="md" fontWeight="bold" textAlign="center">
|
||||
{match.homeTeam?.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("home-team")}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Score */}
|
||||
<VStack gap={1} flexShrink={0}>
|
||||
{match.score && (isLive || isFinished) ? (
|
||||
<HStack gap={3}>
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.home}
|
||||
</Text>
|
||||
<Text fontSize="2xl" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.away}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text fontSize="xl" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{new Date(match.mstUtc).toLocaleDateString("tr-TR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<VStack gap={2} flex={1} align="center">
|
||||
{match.awayTeam?.logo ? (
|
||||
<Image
|
||||
src={match.awayTeam.logo}
|
||||
alt={match.awayTeam.name}
|
||||
boxSize="64px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="64px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="primary.fg">
|
||||
{match.awayTeam?.name?.charAt(0) || "A"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text fontSize="md" fontWeight="bold" textAlign="center">
|
||||
{match.awayTeam?.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("away-team")}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Prediction Section */}
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<Heading as="h2" size="lg">
|
||||
{tPred("title")}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchPrediction()}
|
||||
gap={1.5}
|
||||
>
|
||||
<LuRefreshCw />
|
||||
{tCommon("refresh")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{predLoading ? (
|
||||
<Flex justify="center" py={10}>
|
||||
<Spinner size="md" color="primary.500" />
|
||||
</Flex>
|
||||
) : prediction ? (
|
||||
<PredictionCard prediction={prediction} />
|
||||
) : (
|
||||
<Card.Root borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<Flex justify="center" align="center" py={8}>
|
||||
<Text color="fg.muted">{tPred("no-predictions")}</Text>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Odds Section */}
|
||||
{match.odds && Object.keys(match.odds).length > 0 && (
|
||||
<OddsCard odds={match.odds} />
|
||||
)}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import MatchCard from "./match-card";
|
||||
import type {
|
||||
LeagueWithMatchesDto,
|
||||
MatchResponseDto,
|
||||
} from "@/lib/api/matches/types";
|
||||
|
||||
// ========================
|
||||
// Match Card Skeleton — realistic loading placeholder
|
||||
// ========================
|
||||
|
||||
function MatchCardSkeleton() {
|
||||
const bg = useColorModeValue("white", "gray.800");
|
||||
const border = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={border}
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
className="animate-shimmer"
|
||||
>
|
||||
{/* Status + Date */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Skeleton borderRadius="full" height="20px" width="60px" />
|
||||
<Skeleton borderRadius="md" height="14px" width="80px" />
|
||||
</Flex>
|
||||
|
||||
{/* Teams */}
|
||||
<HStack gap={3} justify="space-between">
|
||||
{/* Home */}
|
||||
<VStack gap={1.5} flex={1} align="center">
|
||||
<Skeleton boxSize="40px" borderRadius="full" />
|
||||
<Skeleton height="14px" width="70px" />
|
||||
</VStack>
|
||||
|
||||
{/* VS / Score */}
|
||||
<Skeleton height="24px" width="30px" borderRadius="md" />
|
||||
|
||||
{/* Away */}
|
||||
<VStack gap={1.5} flex={1} align="center">
|
||||
<Skeleton boxSize="40px" borderRadius="full" />
|
||||
<Skeleton height="14px" width="70px" />
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* League */}
|
||||
<Flex mt={3} pt={2} borderTopWidth="1px" borderColor={border} justify="center">
|
||||
<Skeleton height="12px" width="120px" />
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton grid for loading state */
|
||||
function MatchListSkeleton() {
|
||||
return (
|
||||
<Box>
|
||||
{/* Fake league header */}
|
||||
<Skeleton height="44px" borderRadius="xl" mb={3} />
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
md: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
}}
|
||||
gap={3}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<MatchCardSkeleton key={i} />
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface MatchListProps {
|
||||
leagues?: LeagueWithMatchesDto[];
|
||||
flatMatches?: MatchResponseDto[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MatchList — renders matches grouped by league, or flat if only flatMatches is provided.
|
||||
*/
|
||||
export default function MatchList({
|
||||
leagues,
|
||||
flatMatches,
|
||||
isLoading,
|
||||
}: MatchListProps) {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
const leagueHeaderBg = useColorModeValue("gray.50", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
|
||||
if (isLoading) {
|
||||
return <MatchListSkeleton />;
|
||||
}
|
||||
|
||||
// Flat mode — no league grouping
|
||||
if (flatMatches) {
|
||||
if (flatMatches.length === 0) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={16}>
|
||||
<Text color="fg.muted" fontSize="md">
|
||||
{t("no-matches")}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StaggerContainer>
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
md: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
}}
|
||||
gap={4}
|
||||
>
|
||||
{flatMatches.map((match) => (
|
||||
<StaggerItem key={match.id}>
|
||||
<MatchCard match={match} />
|
||||
</StaggerItem>
|
||||
))}
|
||||
</Grid>
|
||||
</StaggerContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Grouped mode — grouped by league
|
||||
if (!leagues || leagues.length === 0) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={16}>
|
||||
<Text color="fg.muted" fontSize="md">
|
||||
{t("no-matches")}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StaggerContainer>
|
||||
{leagues.map((league) => (
|
||||
<StaggerItem key={league.id}>
|
||||
<Box mb={6}>
|
||||
{/* League Header */}
|
||||
<Flex
|
||||
align="center"
|
||||
gap={2}
|
||||
px={4}
|
||||
py={2.5}
|
||||
bg={leagueHeaderBg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
mb={3}
|
||||
>
|
||||
{league.country?.flagUrl && (
|
||||
<Image
|
||||
src={league.country.flagUrl}
|
||||
alt={league.country.name || ""}
|
||||
boxSize="20px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{league.country?.name && `${league.country.name} • `}
|
||||
{league.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted" ml="auto">
|
||||
{league.matches.length} {t("title").toLowerCase()}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Match Grid */}
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
md: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
}}
|
||||
gap={3}
|
||||
>
|
||||
{league.matches.map((match) => (
|
||||
<MatchCard key={match.id} match={match} />
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Heading } from "@chakra-ui/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { SportFilter, LeagueSidebar, MatchList } from "@/components/matches";
|
||||
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
|
||||
import { useMatchStore } from "@/lib/stores/match-store";
|
||||
|
||||
export default function MatchesContent() {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
const sport = useMatchStore((s) => s.sport);
|
||||
const leagueFilter = useMatchStore((s) => s.leagueFilter);
|
||||
const setSport = useMatchStore((s) => s.setSport);
|
||||
const setLeague = useMatchStore((s) => s.setLeague);
|
||||
|
||||
// Fetch active leagues for sidebar
|
||||
const { data: leaguesData, isLoading: leaguesLoading } =
|
||||
useActiveLeagues(sport);
|
||||
const leagues = leaguesData?.data ?? [];
|
||||
|
||||
// Query matches grouped by league
|
||||
const queryMatches = useQueryMatches();
|
||||
|
||||
// Trigger query on sport/league change
|
||||
const { data: matchesData, isPending: matchesLoading } = (() => {
|
||||
// We use the queryMatches mutation for initial data
|
||||
// but for the UI we want a reactive approach.
|
||||
// Let's use the standard list with league filter
|
||||
return {
|
||||
data: queryMatches.data,
|
||||
isPending: queryMatches.isPending,
|
||||
};
|
||||
})();
|
||||
|
||||
// Auto-trigger query when sport or league changes
|
||||
const handleSportChange = (newSport: typeof sport) => {
|
||||
setSport(newSport);
|
||||
queryMatches.mutate({
|
||||
sport: newSport,
|
||||
leagueId: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeagueChange = (leagueId: string | null) => {
|
||||
setLeague(leagueId);
|
||||
queryMatches.mutate({
|
||||
sport,
|
||||
leagueId: leagueId || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!queryMatches.data && !queryMatches.isPending) {
|
||||
queryMatches.mutate({
|
||||
sport,
|
||||
leagueId: leagueFilter || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const matchLeagues = matchesData?.data ?? [];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* Page Header */}
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mb={6}
|
||||
flexWrap="wrap"
|
||||
gap={3}
|
||||
>
|
||||
<Heading as="h1" size="xl" fontWeight="bold">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<SportFilter value={sport} onChange={handleSportChange} />
|
||||
</Flex>
|
||||
|
||||
{/* Main Content */}
|
||||
<Flex
|
||||
gap={6}
|
||||
align="flex-start"
|
||||
direction={{ base: "column", lg: "row" }}
|
||||
>
|
||||
{/* League Sidebar (Desktop only) */}
|
||||
<Box
|
||||
display={{ base: "none", lg: "block" }}
|
||||
w="260px"
|
||||
flexShrink={0}
|
||||
position="sticky"
|
||||
top="80px"
|
||||
>
|
||||
<LeagueSidebar
|
||||
leagues={leagues}
|
||||
selectedLeagueId={leagueFilter}
|
||||
onSelect={handleLeagueChange}
|
||||
isLoading={leaguesLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Match List */}
|
||||
<Box flex={1} minW={0}>
|
||||
<MatchList
|
||||
leagues={matchLeagues}
|
||||
isLoading={queryMatches.isPending}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
VStack,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
|
||||
interface OddsCardProps {
|
||||
odds?: Record<string, Record<string, { odd: string }>>;
|
||||
}
|
||||
|
||||
interface MarketBlockProps {
|
||||
title: string;
|
||||
selections: Record<string, { odd: string }>;
|
||||
}
|
||||
|
||||
function MarketBlock({ title, selections }: MarketBlockProps) {
|
||||
const bg = useColorModeValue("gray.50", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const selectionBg = useColorModeValue("white", "gray.700");
|
||||
|
||||
// Sort selections based on common market patterns
|
||||
const sortedKeys = Object.keys(selections).sort((a, b) => {
|
||||
// MS: 1, X, 2
|
||||
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b)) {
|
||||
const order = ["1", "X", "2"];
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
}
|
||||
// Alt/Üst: Alt, Üst
|
||||
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b)) {
|
||||
return a === "Alt" ? -1 : 1; // Alt first
|
||||
}
|
||||
// KG: Var, Yok
|
||||
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b)) {
|
||||
return a === "Var" ? -1 : 1; // Var first
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box px={3} py={1.5} borderBottomWidth="1px" borderColor={borderColor}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="fg.muted"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex p={2} gap={2} wrap="wrap">
|
||||
{sortedKeys.map((key) => (
|
||||
<Flex
|
||||
key={key}
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg={selectionBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="sm"
|
||||
minW="50px"
|
||||
py={1}
|
||||
px={2}
|
||||
flex={1}
|
||||
>
|
||||
<Text fontSize="xs" color="fg.muted" mb={0.5}>
|
||||
{key}
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color="primary.500">
|
||||
{Number(selections[key].odd).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OddsCard({ odds }: OddsCardProps) {
|
||||
const cardBg = useColorModeValue("white", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.800");
|
||||
|
||||
if (!odds || Object.keys(odds).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Define priority markets to show at the top
|
||||
const PRIORITY_MARKETS = [
|
||||
"Maç Sonucu",
|
||||
"2.5 Alt/Üst",
|
||||
"Karşılıklı Gol",
|
||||
"İlk Yarı Sonucu",
|
||||
"1. Yarı Sonucu",
|
||||
"Kart",
|
||||
"Korner",
|
||||
];
|
||||
|
||||
const marketKeys = Object.keys(odds);
|
||||
const priorityKeys = marketKeys.filter((k) =>
|
||||
PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
||||
);
|
||||
const otherKeys = marketKeys.filter(
|
||||
(k) => !PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
||||
);
|
||||
|
||||
// Group similar markets if needed, but simple list for now
|
||||
|
||||
return (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Body>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
Canlı İddaa Oranları
|
||||
</Text>
|
||||
|
||||
{/* Priority Markets Grid */}
|
||||
{priorityKeys.length > 0 && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
{priorityKeys.map((key) => (
|
||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Other Markets - Show ALL */}
|
||||
{otherKeys.length > 0 && (
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
gap={4}
|
||||
mt={priorityKeys.length > 0 ? 2 : 0}
|
||||
>
|
||||
{otherKeys.map((key) => (
|
||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { HStack, Button } from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LuCircleDot } from "react-icons/lu";
|
||||
import { MdSportsSoccer, MdSportsBasketball } from "react-icons/md";
|
||||
import type { SportType } from "@/lib/api/matches/types";
|
||||
|
||||
interface SportFilterProps {
|
||||
value: SportType;
|
||||
onChange: (sport: SportType) => void;
|
||||
}
|
||||
|
||||
const SPORT_OPTIONS: { value: SportType; icon: React.ReactNode }[] = [
|
||||
{ value: "football", icon: <MdSportsSoccer /> },
|
||||
{ value: "basketball", icon: <MdSportsBasketball /> },
|
||||
];
|
||||
|
||||
export default function SportFilter({ value, onChange }: SportFilterProps) {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
return (
|
||||
<HStack gap={2}>
|
||||
{SPORT_OPTIONS.map((sport) => {
|
||||
const isActive = value === sport.value;
|
||||
return (
|
||||
<Button
|
||||
key={sport.value}
|
||||
onClick={() => onChange(sport.value)}
|
||||
variant={isActive ? "solid" : "outline"}
|
||||
colorPalette={isActive ? "primary" : "gray"}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
gap={1.5}
|
||||
>
|
||||
{sport.icon}
|
||||
{t(sport.value)}
|
||||
{isActive && <LuCircleDot size={12} />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
animate,
|
||||
useInView,
|
||||
type HTMLMotionProps,
|
||||
} from "framer-motion";
|
||||
import { forwardRef, type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
// ========================
|
||||
// Shared animation variants
|
||||
// ========================
|
||||
|
||||
export const fadeInVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
};
|
||||
|
||||
export const slideUpVariants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
export const slideDownVariants = {
|
||||
hidden: { opacity: 0, y: -24 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
export const slideLeftVariants = {
|
||||
hidden: { opacity: 0, x: 40 },
|
||||
visible: { opacity: 1, x: 0 },
|
||||
};
|
||||
|
||||
export const slideRightVariants = {
|
||||
hidden: { opacity: 0, x: -40 },
|
||||
visible: { opacity: 1, x: 0 },
|
||||
};
|
||||
|
||||
export const scaleInVariants = {
|
||||
hidden: { opacity: 0, scale: 0.9 },
|
||||
visible: { opacity: 1, scale: 1 },
|
||||
};
|
||||
|
||||
export const blurInVariants = {
|
||||
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||
visible: { opacity: 1, filter: "blur(0px)" },
|
||||
};
|
||||
|
||||
export const staggerContainerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ========================
|
||||
// Spring presets
|
||||
// ========================
|
||||
|
||||
export const springs = {
|
||||
gentle: { type: "spring" as const, stiffness: 120, damping: 14 },
|
||||
bouncy: { type: "spring" as const, stiffness: 300, damping: 15 },
|
||||
snappy: { type: "spring" as const, stiffness: 400, damping: 25 },
|
||||
smooth: { duration: 0.5, ease: [0.25, 0.1, 0.25, 1] as const },
|
||||
};
|
||||
|
||||
// ========================
|
||||
// Generic Motion Wrappers (animate on mount)
|
||||
// ========================
|
||||
|
||||
interface MotionWrapperProps extends HTMLMotionProps<"div"> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const FadeIn = forwardRef<HTMLDivElement, MotionWrapperProps>(
|
||||
({ children, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={fadeInVariants}
|
||||
transition={{ duration: 0.4 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
FadeIn.displayName = "FadeIn";
|
||||
|
||||
export const SlideUp = forwardRef<HTMLDivElement, MotionWrapperProps>(
|
||||
({ children, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={slideUpVariants}
|
||||
transition={springs.smooth}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
SlideUp.displayName = "SlideUp";
|
||||
|
||||
export const ScaleIn = forwardRef<HTMLDivElement, MotionWrapperProps>(
|
||||
({ children, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={scaleInVariants}
|
||||
transition={springs.gentle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScaleIn.displayName = "ScaleIn";
|
||||
|
||||
export const BlurIn = forwardRef<HTMLDivElement, MotionWrapperProps>(
|
||||
({ children, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={blurInVariants}
|
||||
transition={{ duration: 0.6 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
BlurIn.displayName = "BlurIn";
|
||||
|
||||
// ========================
|
||||
// Scroll-based motion (whileInView)
|
||||
// ========================
|
||||
|
||||
interface ScrollMotionProps extends HTMLMotionProps<"div"> {
|
||||
children: ReactNode;
|
||||
/** How much of the element must be visible (0–1). Default: 0.2 */
|
||||
threshold?: number;
|
||||
/** Animate only once? Default: true */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
export const ScrollFadeIn = forwardRef<HTMLDivElement, ScrollMotionProps>(
|
||||
({ children, threshold = 0.2, once = true, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: threshold }}
|
||||
variants={fadeInVariants}
|
||||
transition={{ duration: 0.5 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScrollFadeIn.displayName = "ScrollFadeIn";
|
||||
|
||||
export const ScrollSlideUp = forwardRef<HTMLDivElement, ScrollMotionProps>(
|
||||
({ children, threshold = 0.15, once = true, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: threshold }}
|
||||
variants={slideUpVariants}
|
||||
transition={springs.smooth}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScrollSlideUp.displayName = "ScrollSlideUp";
|
||||
|
||||
export const ScrollScaleIn = forwardRef<HTMLDivElement, ScrollMotionProps>(
|
||||
({ children, threshold = 0.2, once = true, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: threshold }}
|
||||
variants={scaleInVariants}
|
||||
transition={springs.gentle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScrollScaleIn.displayName = "ScrollScaleIn";
|
||||
|
||||
export const ScrollBlurIn = forwardRef<HTMLDivElement, ScrollMotionProps>(
|
||||
({ children, threshold = 0.2, once = true, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: threshold }}
|
||||
variants={blurInVariants}
|
||||
transition={{ duration: 0.7 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScrollBlurIn.displayName = "ScrollBlurIn";
|
||||
|
||||
export const ScrollSlideLeft = forwardRef<HTMLDivElement, ScrollMotionProps>(
|
||||
({ children, threshold = 0.15, once = true, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: threshold }}
|
||||
variants={slideLeftVariants}
|
||||
transition={springs.smooth}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScrollSlideLeft.displayName = "ScrollSlideLeft";
|
||||
|
||||
export const ScrollSlideRight = forwardRef<HTMLDivElement, ScrollMotionProps>(
|
||||
({ children, threshold = 0.15, once = true, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: threshold }}
|
||||
variants={slideRightVariants}
|
||||
transition={springs.smooth}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
ScrollSlideRight.displayName = "ScrollSlideRight";
|
||||
|
||||
// ========================
|
||||
// Stagger Container — animate children one by one
|
||||
// ========================
|
||||
|
||||
interface StaggerProps extends HTMLMotionProps<"div"> {
|
||||
children: ReactNode;
|
||||
staggerDelay?: number;
|
||||
/** Use whileInView instead of animate? Default: false */
|
||||
inView?: boolean;
|
||||
}
|
||||
|
||||
export const StaggerContainer = forwardRef<HTMLDivElement, StaggerProps>(
|
||||
({ children, staggerDelay = 0.08, inView = false, ...props }, ref) => {
|
||||
const baseProps = {
|
||||
ref,
|
||||
variants: {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: staggerDelay } },
|
||||
},
|
||||
...props,
|
||||
};
|
||||
|
||||
if (inView) {
|
||||
return (
|
||||
<motion.div
|
||||
{...baseProps}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div {...baseProps} initial="hidden" animate="visible">
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
StaggerContainer.displayName = "StaggerContainer";
|
||||
|
||||
// ========================
|
||||
// Stagger Item — use as direct child of StaggerContainer
|
||||
// ========================
|
||||
|
||||
export const StaggerItem = forwardRef<HTMLDivElement, MotionWrapperProps>(
|
||||
({ children, ...props }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
variants={slideUpVariants}
|
||||
transition={springs.smooth}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
),
|
||||
);
|
||||
StaggerItem.displayName = "StaggerItem";
|
||||
|
||||
// ========================
|
||||
// Animated Counter — REAL counting with number interpolation
|
||||
// ========================
|
||||
|
||||
interface AnimatedCounterProps {
|
||||
/** Target value to count up to */
|
||||
value: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
/** Duration in seconds. Default: 2 */
|
||||
duration?: number;
|
||||
/** Only animate when visible? Default: true */
|
||||
inView?: boolean;
|
||||
}
|
||||
|
||||
export function AnimatedCounter({
|
||||
value,
|
||||
suffix = "",
|
||||
prefix = "",
|
||||
duration = 2,
|
||||
inView = true,
|
||||
}: AnimatedCounterProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(0);
|
||||
const rounded = useTransform(motionValue, (latest) =>
|
||||
Intl.NumberFormat("tr-TR").format(Math.round(latest)),
|
||||
);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.5 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView || isInView) {
|
||||
const controls = animate(motionValue, value, {
|
||||
duration,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
});
|
||||
return controls.stop;
|
||||
}
|
||||
}, [motionValue, value, duration, inView, isInView]);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isInView || !inView ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
>
|
||||
{prefix}
|
||||
<motion.span>{rounded}</motion.span>
|
||||
{suffix}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Sparkle / Particle Effect
|
||||
// ========================
|
||||
|
||||
interface SparkleProps {
|
||||
/** Number of sparkle particles. Default: 6 */
|
||||
count?: number;
|
||||
/** Color of the sparkle. Default: "primary.300" */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function Sparkles({ count = 6, color = "rgba(56, 178, 172, 0.6)" }: SparkleProps) {
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 4 + Math.random() * 4,
|
||||
height: 4 + Math.random() * 4,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
left: `${10 + Math.random() * 80}%`,
|
||||
bottom: `${Math.random() * 30}%`,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -(60 + Math.random() * 80)],
|
||||
opacity: [0, 1, 1, 0],
|
||||
scale: [0.5, 1, 0.8, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.5 + Math.random() * 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Gradient Orb — decorative floating orb
|
||||
// ========================
|
||||
|
||||
interface GradientOrbProps {
|
||||
/** CSS color/gradient for the orb */
|
||||
color?: string;
|
||||
/** Size in px */
|
||||
size?: number;
|
||||
/** Position: top, left, right, bottom (CSS values) */
|
||||
top?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
/** Blur amount in px. Default: 60 */
|
||||
blur?: number;
|
||||
}
|
||||
|
||||
export function GradientOrb({
|
||||
color = "rgba(56, 178, 172, 0.15)",
|
||||
size = 200,
|
||||
top,
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
blur = 60,
|
||||
}: GradientOrbProps) {
|
||||
return (
|
||||
<motion.div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
filter: `blur(${blur}px)`,
|
||||
top,
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -15, 0],
|
||||
scale: [1, 1.05, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 6,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PredictionsContent } from "./predictions-content";
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Card,
|
||||
SimpleGrid,
|
||||
Spinner,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, StaggerContainer, StaggerItem } from "@/components/motion";
|
||||
import type { SportType } from "@/lib/api/matches/types";
|
||||
import {
|
||||
useUpcomingPredictions,
|
||||
useValueBets,
|
||||
usePredictionHistory,
|
||||
} from "@/lib/api/predictions/use-hooks";
|
||||
import type {
|
||||
MatchPredictionDto,
|
||||
ValueBetDto,
|
||||
PredictionHistoryResponseDto,
|
||||
} from "@/lib/api/predictions/types";
|
||||
import { useState } from "react";
|
||||
|
||||
function getPredictionSport(prediction: MatchPredictionDto): SportType {
|
||||
const explicitSport = prediction.match_info?.sport;
|
||||
if (explicitSport === "basketball" || explicitSport === "football") {
|
||||
return explicitSport;
|
||||
}
|
||||
|
||||
if (
|
||||
prediction.model_version?.toLowerCase().includes("basketball") ||
|
||||
Object.keys(prediction.market_board || {}).some((market) =>
|
||||
["ML", "TOTAL", "SPREAD"].includes(market),
|
||||
)
|
||||
) {
|
||||
return "basketball";
|
||||
}
|
||||
|
||||
return "football";
|
||||
}
|
||||
|
||||
type TabType = "upcoming" | "value-bets" | "history";
|
||||
|
||||
export default function PredictionsContent() {
|
||||
const t = useTranslations("predictions");
|
||||
const tMatches = useTranslations("matches");
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<TabType>("upcoming");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const { data: upcomingData, isLoading: upcomingLoading } =
|
||||
useUpcomingPredictions();
|
||||
const { data: valueBetsData, isLoading: valueBetsLoading } = useValueBets();
|
||||
const { data: historyData, isLoading: historyLoading } =
|
||||
usePredictionHistory();
|
||||
|
||||
const upcomingPredictions: MatchPredictionDto[] =
|
||||
upcomingData?.data?.matches ?? [];
|
||||
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
|
||||
const historyResponse = historyData?.data as
|
||||
| PredictionHistoryResponseDto
|
||||
| undefined;
|
||||
const history = historyResponse?.history ?? [];
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
LOW: "green",
|
||||
MEDIUM: "yellow",
|
||||
HIGH: "orange",
|
||||
"VERY HIGH": "red",
|
||||
};
|
||||
|
||||
const tabs: { key: TabType; label: string }[] = [
|
||||
{ key: "upcoming", label: t("upcoming") },
|
||||
{ key: "value-bets", label: t("value-bets") },
|
||||
{ key: "history", label: t("history") },
|
||||
];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
||||
{t("title")}
|
||||
</Heading>
|
||||
|
||||
{/* Tabs */}
|
||||
<HStack gap={2} mb={6} overflowX="auto" pb={1}>
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant={activeTab === tab.key ? "solid" : "outline"}
|
||||
colorPalette={activeTab === tab.key ? "primary" : "gray"}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* Upcoming Predictions Tab */}
|
||||
{activeTab === "upcoming" &&
|
||||
(upcomingLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : upcomingPredictions.length > 0 ? (
|
||||
<StaggerContainer>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
{upcomingPredictions.map(
|
||||
(pred: MatchPredictionDto, idx: number) => {
|
||||
const sport = getPredictionSport(pred);
|
||||
return (
|
||||
<StaggerItem key={idx}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
cursor="pointer"
|
||||
_hover={{ shadow: "lg", borderColor: "primary.300" }}
|
||||
transition="all 0.2s"
|
||||
onClick={() =>
|
||||
router.push(`/matches/${pred.match_info.match_id}`)
|
||||
}
|
||||
>
|
||||
<Card.Body>
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{pred.match_info.home_team} vs{" "}
|
||||
{pred.match_info.away_team}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Badge
|
||||
colorPalette={
|
||||
sport === "basketball" ? "orange" : "blue"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{tMatches(sport)}
|
||||
</Badge>
|
||||
<Badge
|
||||
colorPalette={
|
||||
riskColors[pred.risk?.level?.toUpperCase()] ||
|
||||
"gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{pred.risk?.level}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{pred.main_pick && (
|
||||
<Box
|
||||
p={3}
|
||||
bg="primary.subtle"
|
||||
borderRadius="lg"
|
||||
mb={3}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack gap={0} align="flex-start">
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("main-pick")}
|
||||
</Text>
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
{pred.main_pick.pick}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} align="flex-end">
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("confidence")}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color="primary.fg"
|
||||
>
|
||||
{Math.round(
|
||||
pred.main_pick.calibrated_confidence ??
|
||||
pred.main_pick.confidence,
|
||||
)}
|
||||
%
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("data-quality")}:{" "}
|
||||
{Math.round(
|
||||
(pred.data_quality?.score ?? 0) * 100,
|
||||
)}
|
||||
%
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{pred.model_version}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</StaggerItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</StaggerContainer>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-predictions")}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{/* Value Bets Tab */}
|
||||
{activeTab === "value-bets" &&
|
||||
(valueBetsLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : valueBets.length > 0 ? (
|
||||
<StaggerContainer>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={4}>
|
||||
{valueBets.map((vb: ValueBetDto, idx: number) => (
|
||||
<StaggerItem key={idx}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Body>
|
||||
<Text fontSize="sm" color="fg.muted" truncate mb={2}>
|
||||
{vb.matchName}
|
||||
</Text>
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
{vb.prediction}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette="primary"
|
||||
variant="subtle"
|
||||
borderRadius="full"
|
||||
>
|
||||
{vb.odd.toFixed(2)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<HStack justify="space-between">
|
||||
<Badge
|
||||
colorPalette="green"
|
||||
variant="solid"
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
EV+ {(vb.expectedValue * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("confidence")}: {Math.round(vb.confidence * 100)}
|
||||
%
|
||||
</Text>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</StaggerContainer>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-predictions")}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{/* History Tab */}
|
||||
{activeTab === "history" &&
|
||||
(historyLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : history.length > 0 ? (
|
||||
<StaggerContainer>
|
||||
<VStack gap={3} align="stretch">
|
||||
{history.map((item: Record<string, unknown>, idx: number) => (
|
||||
<StaggerItem key={idx}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Body>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack gap={0} align="flex-start">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{String(item.homeTeam ?? "")} vs{" "}
|
||||
{String(item.awayTeam ?? "")}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{String(item.pick ?? "")}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} align="flex-end">
|
||||
<Badge
|
||||
colorPalette={
|
||||
item.result === "correct"
|
||||
? "green"
|
||||
: item.result === "incorrect"
|
||||
? "red"
|
||||
: "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{String(item.result ?? "pending")}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{typeof item.confidence === "number"
|
||||
? `${Math.round(item.confidence * 100)}%`
|
||||
: "—"}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</VStack>
|
||||
</StaggerContainer>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-predictions")}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProfileContent } from "./profile-content";
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Separator,
|
||||
Spinner,
|
||||
Input,
|
||||
Button,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { Avatar } from "@/components/ui/data-display/avatar";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
|
||||
import type { UserBettingStatsDto } from "@/lib/api/coupons/types";
|
||||
import {
|
||||
LuMail,
|
||||
LuUser,
|
||||
LuCalendar,
|
||||
LuShield,
|
||||
LuTrendingUp,
|
||||
LuTarget,
|
||||
LuTicket,
|
||||
LuPen,
|
||||
LuCheck,
|
||||
LuX,
|
||||
LuLock,
|
||||
} from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { useUpdateProfile, useChangePassword } from "@/lib/api/users/use-hooks";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" py={2}>
|
||||
<HStack gap={2} color="fg.muted">
|
||||
{icon}
|
||||
<Text fontSize="sm">{label}</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
{value}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const profileSchema = yup.object({
|
||||
firstName: yup.string().required(),
|
||||
lastName: yup.string().required(),
|
||||
});
|
||||
|
||||
type ProfileForm = yup.InferType<typeof profileSchema>;
|
||||
|
||||
const passwordSchema = yup.object({
|
||||
currentPassword: yup.string().required(),
|
||||
newPassword: yup.string().min(8).required(),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.oneOf([yup.ref("newPassword")], "Passwords must match")
|
||||
.required(),
|
||||
});
|
||||
|
||||
type PasswordForm = yup.InferType<typeof passwordSchema>;
|
||||
|
||||
export default function ProfileContent() {
|
||||
const t = useTranslations("profile");
|
||||
const tCommon = useTranslations("common");
|
||||
const { data: session, update: updateSession } = useSession();
|
||||
const { data: statsData, isLoading: statsLoading } = useUserBettingStats();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const user = session?.user;
|
||||
const stats = statsData?.data as UserBettingStatsDto | undefined;
|
||||
|
||||
// Edit profile state
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const updateProfile = useUpdateProfile();
|
||||
|
||||
const {
|
||||
handleSubmit: handleProfileSubmit,
|
||||
register: profileRegister,
|
||||
formState: { errors: profileErrors },
|
||||
reset: resetProfile,
|
||||
} = useForm<ProfileForm>({
|
||||
resolver: yupResolver(profileSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
firstName: user?.name?.split(" ")[0] || "",
|
||||
lastName: user?.name?.split(" ").slice(1).join(" ") || "",
|
||||
},
|
||||
});
|
||||
|
||||
const onProfileSubmit = async (data: ProfileForm) => {
|
||||
await updateProfile.mutateAsync(data);
|
||||
await updateSession();
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// Change password state
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const changePassword = useChangePassword();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
handleSubmit: handlePasswordSubmit,
|
||||
register: passwordRegister,
|
||||
formState: { errors: passwordErrors },
|
||||
reset: resetPassword,
|
||||
} = useForm<PasswordForm>({
|
||||
resolver: yupResolver(passwordSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onPasswordSubmit = async (data: PasswordForm) => {
|
||||
await changePassword.mutateAsync({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
resetPassword();
|
||||
setShowPasswordForm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box maxW="2xl" mx="auto">
|
||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
||||
{t("title")}
|
||||
</Heading>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Body>
|
||||
<Flex
|
||||
direction={{ base: "column", sm: "row" }}
|
||||
align="center"
|
||||
gap={6}
|
||||
>
|
||||
<Avatar name={user?.name || "User"} variant="solid" size="2xl" />
|
||||
<VStack gap={1} align={{ base: "center", sm: "flex-start" }}>
|
||||
<Heading as="h2" size="lg">
|
||||
{user?.name || "—"}
|
||||
</Heading>
|
||||
<Text color="fg.muted" fontSize="sm">
|
||||
{user?.email || "—"}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Account Info */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Header>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Heading as="h3" size="sm">
|
||||
{t("personal-info")}
|
||||
</Heading>
|
||||
{!isEditing ? (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={tCommon("edit")}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<LuPen />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{isEditing ? (
|
||||
<Flex
|
||||
as="form"
|
||||
direction="column"
|
||||
gap={4}
|
||||
onSubmit={handleProfileSubmit(onProfileSubmit)}
|
||||
>
|
||||
<Field
|
||||
label={t("first-name")}
|
||||
errorText={profileErrors.firstName?.message}
|
||||
invalid={!!profileErrors.firstName}
|
||||
>
|
||||
<Input
|
||||
{...profileRegister("firstName")}
|
||||
placeholder={t("first-name")}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("last-name")}
|
||||
errorText={profileErrors.lastName?.message}
|
||||
invalid={!!profileErrors.lastName}
|
||||
>
|
||||
<Input
|
||||
{...profileRegister("lastName")}
|
||||
placeholder={t("last-name")}
|
||||
/>
|
||||
</Field>
|
||||
<HStack gap={2} justify="flex-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
resetProfile();
|
||||
}}
|
||||
>
|
||||
<LuX />
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
loading={updateProfile.isPending}
|
||||
>
|
||||
<LuCheck />
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
icon={<LuUser />}
|
||||
label={t("full-name")}
|
||||
value={user?.name || "—"}
|
||||
/>
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={<LuMail />}
|
||||
label={t("email")}
|
||||
value={user?.email || "—"}
|
||||
/>
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={<LuShield />}
|
||||
label={t("role")}
|
||||
value={
|
||||
(user as Record<string, unknown>)?.roles
|
||||
? String((user as Record<string, unknown>).roles)
|
||||
: "User"
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={<LuCalendar />}
|
||||
label={t("member-since")}
|
||||
value="—"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Change Password */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Header>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Heading as="h3" size="sm">
|
||||
<HStack gap={2}>
|
||||
<LuLock />
|
||||
<Text>{t("change-password")}</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
{!showPasswordForm ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
>
|
||||
{tCommon("edit")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{showPasswordForm ? (
|
||||
<Flex
|
||||
as="form"
|
||||
direction="column"
|
||||
gap={4}
|
||||
onSubmit={handlePasswordSubmit(onPasswordSubmit)}
|
||||
>
|
||||
<Field
|
||||
label={t("current-password")}
|
||||
errorText={passwordErrors.currentPassword?.message}
|
||||
invalid={!!passwordErrors.currentPassword}
|
||||
>
|
||||
<PasswordInput
|
||||
{...passwordRegister("currentPassword")}
|
||||
placeholder={t("current-password")}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("new-password")}
|
||||
errorText={passwordErrors.newPassword?.message}
|
||||
invalid={!!passwordErrors.newPassword}
|
||||
>
|
||||
<PasswordInput
|
||||
{...passwordRegister("newPassword")}
|
||||
placeholder={t("new-password")}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("confirm-password")}
|
||||
errorText={passwordErrors.confirmPassword?.message}
|
||||
invalid={!!passwordErrors.confirmPassword}
|
||||
>
|
||||
<PasswordInput
|
||||
{...passwordRegister("confirmPassword")}
|
||||
placeholder={t("confirm-password")}
|
||||
/>
|
||||
</Field>
|
||||
<HStack gap={2} justify="flex-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
resetPassword();
|
||||
}}
|
||||
>
|
||||
<LuX />
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
loading={changePassword.isPending}
|
||||
onClick={() => router.refresh()}
|
||||
>
|
||||
<LuCheck />
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
) : (
|
||||
<Text fontSize="sm" color="fg.muted" py={2}>
|
||||
{t("change-password-desc")}
|
||||
</Text>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Betting Stats */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
{t("betting-stats")}
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{statsLoading ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
icon={<LuTicket />}
|
||||
label={t("total-coupons")}
|
||||
value={String(stats?.totalCoupons ?? "—")}
|
||||
/>
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={<LuTrendingUp />}
|
||||
label={t("win-rate")}
|
||||
value={
|
||||
stats?.winRate != null
|
||||
? `${Math.round(stats.winRate)}%`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={<LuTarget />}
|
||||
label={t("total-profit")}
|
||||
value={stats?.wonBets != null ? String(stats.wonBets) : "—"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Image,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { LuSearch, LuX } from "react-icons/lu";
|
||||
import type { TeamDto } from "@/lib/api/leagues/types";
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const bg = useColorModeValue("white", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.800");
|
||||
const inputBg = useColorModeValue("gray.50", "gray.800");
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
const { data: searchData, isLoading } = useSearchTeams({
|
||||
q: debouncedQuery,
|
||||
});
|
||||
|
||||
const teams: TeamDto[] = searchData?.data ?? [];
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut: Ctrl+K to focus
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleTeamClick = useCallback(
|
||||
(team: TeamDto) => {
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
router.push(`/teams/${team.id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} position="relative" w={{ base: "full", lg: "280px" }}>
|
||||
{/* Search Input */}
|
||||
<Flex
|
||||
align="center"
|
||||
bg={inputBg}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor={isOpen ? "primary.400" : borderColor}
|
||||
px={3}
|
||||
py={1}
|
||||
transition="all 0.2s"
|
||||
_focusWithin={{
|
||||
borderColor: "primary.400",
|
||||
shadow: "0 0 0 1px var(--chakra-colors-primary-400)",
|
||||
}}
|
||||
>
|
||||
<LuSearch
|
||||
style={{ flexShrink: 0, opacity: 0.5, width: 16, height: 16 }}
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => query.length >= 2 && setIsOpen(true)}
|
||||
placeholder="Takım ara... (Ctrl+K)"
|
||||
variant="flushed"
|
||||
size="sm"
|
||||
px={2}
|
||||
fontSize="sm"
|
||||
/>
|
||||
{query && (
|
||||
<Box
|
||||
as="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
cursor="pointer"
|
||||
opacity={0.5}
|
||||
_hover={{ opacity: 1 }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<LuX style={{ width: 14, height: 14 }} />
|
||||
</Box>
|
||||
)}
|
||||
<Text
|
||||
display={{ base: "none", lg: "block" }}
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
flexShrink={0}
|
||||
bg={useColorModeValue("gray.100", "gray.700")}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
fontFamily="mono"
|
||||
>
|
||||
⌘K
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Dropdown Results */}
|
||||
{isOpen && debouncedQuery.length >= 2 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="calc(100% + 8px)"
|
||||
left={0}
|
||||
right={0}
|
||||
bg={bg}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
shadow="lg"
|
||||
zIndex={100}
|
||||
maxH="360px"
|
||||
overflowY="auto"
|
||||
py={2}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
</Flex>
|
||||
) : teams.length === 0 ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Sonuç bulunamadı
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack gap={0} align="stretch">
|
||||
{teams.map((team: TeamDto) => (
|
||||
<HStack
|
||||
key={team.id}
|
||||
px={3}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="background 0.15s"
|
||||
onClick={() => handleTeamClick(team)}
|
||||
gap={3}
|
||||
>
|
||||
{team.logo ? (
|
||||
<Image
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
boxSize="32px"
|
||||
objectFit="contain"
|
||||
borderRadius="md"
|
||||
flexShrink={0}
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="32px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="md"
|
||||
align="center"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="bold" color="primary.fg">
|
||||
{team.name?.charAt(0) || "T"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontSize="sm" fontWeight="600" truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
{team.country && (
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{team.country}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Card,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Button,
|
||||
Table,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, StaggerContainer, StaggerItem } from "@/components/motion";
|
||||
import {
|
||||
useBulletins,
|
||||
useGeneratePrediction,
|
||||
useSyncBulletin,
|
||||
useRolloverHistory,
|
||||
} from "@/lib/api/spor-toto/use-hooks";
|
||||
import type {
|
||||
SporTotoBulletinDto,
|
||||
SporTotoPredictionResultDto,
|
||||
} from "@/lib/api/spor-toto/service";
|
||||
import {
|
||||
LuRefreshCw,
|
||||
LuSparkles,
|
||||
LuTrendingUp,
|
||||
LuTicket,
|
||||
} from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import {
|
||||
NativeSelectRoot,
|
||||
NativeSelectField,
|
||||
} from "@/components/ui/forms/native-select";
|
||||
|
||||
type PredictionStrategy =
|
||||
| "CONSERVATIVE"
|
||||
| "BALANCED"
|
||||
| "AGGRESSIVE"
|
||||
| "FORMULA_6PCT";
|
||||
|
||||
export default function SporTotoContent() {
|
||||
const t = useTranslations("spor-toto");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const [selectedBulletin, setSelectedBulletin] = useState<string>("");
|
||||
const [strategy, setStrategy] = useState<PredictionStrategy>("BALANCED");
|
||||
|
||||
const bulletins = useBulletins();
|
||||
const rolloverHistory = useRolloverHistory(10);
|
||||
const syncBulletin = useSyncBulletin();
|
||||
const generatePrediction = useGeneratePrediction();
|
||||
const toast = (opts: { title: string; status: string }) =>
|
||||
toaster.create({
|
||||
title: opts.title,
|
||||
type: opts.status as
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "loading",
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
await syncBulletin.mutateAsync();
|
||||
toast({
|
||||
title: t("sync-success"),
|
||||
status: "success",
|
||||
});
|
||||
bulletins.refetch();
|
||||
};
|
||||
|
||||
const handlePredict = async () => {
|
||||
if (!selectedBulletin) {
|
||||
toast({
|
||||
title: t("select-bulletin"),
|
||||
status: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await generatePrediction.mutateAsync({
|
||||
bulletinId: selectedBulletin,
|
||||
strategy,
|
||||
});
|
||||
toast({
|
||||
title: t("prediction-generated"),
|
||||
status: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const strategies: {
|
||||
value: PredictionStrategy;
|
||||
label: string;
|
||||
desc: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "CONSERVATIVE",
|
||||
label: t("strategy-conservative"),
|
||||
desc: t("strategy-conservative-desc"),
|
||||
},
|
||||
{
|
||||
value: "BALANCED",
|
||||
label: t("strategy-balanced"),
|
||||
desc: t("strategy-balanced-desc"),
|
||||
},
|
||||
{
|
||||
value: "AGGRESSIVE",
|
||||
label: t("strategy-aggressive"),
|
||||
desc: t("strategy-aggressive-desc"),
|
||||
},
|
||||
{
|
||||
value: "FORMULA_6PCT",
|
||||
label: t("strategy-formula"),
|
||||
desc: t("strategy-formula-desc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box maxW="6xl" mx="auto">
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<Heading as="h1" size="xl" fontWeight="bold">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSync}
|
||||
loading={syncBulletin.isPending}
|
||||
>
|
||||
<LuRefreshCw />
|
||||
{t("sync-bulletins")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<StaggerContainer>
|
||||
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
|
||||
{/* Left Column - Bulletin Selection & Prediction */}
|
||||
<Box flex={2}>
|
||||
{/* Bulletin Selection */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
{t("select-bulletin")}
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{bulletins.isLoading ? (
|
||||
<Flex justify="center" py={4}>
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
</Flex>
|
||||
) : (
|
||||
<NativeSelectRoot>
|
||||
<NativeSelectField
|
||||
value={selectedBulletin}
|
||||
onChange={(e) => setSelectedBulletin(e.target.value)}
|
||||
placeholder={t("choose-bulletin")}
|
||||
>
|
||||
{bulletins.data?.data?.map((b: SporTotoBulletinDto) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{t("bulletin-label", {
|
||||
cycle: b.gameCycleNo,
|
||||
date: new Date(b.drawDate).toLocaleDateString(),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelectField>
|
||||
</NativeSelectRoot>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Strategy Selection */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
{t("choose-strategy")}
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
<VStack gap={3}>
|
||||
{strategies.map((s) => (
|
||||
<Card.Root
|
||||
key={s.value}
|
||||
borderWidth={strategy === s.value ? "2px" : "1px"}
|
||||
borderColor={
|
||||
strategy === s.value ? "primary.500" : borderColor
|
||||
}
|
||||
cursor="pointer"
|
||||
onClick={() => setStrategy(s.value)}
|
||||
_hover={{ borderColor: "primary.300" }}
|
||||
>
|
||||
<Card.Body py={3}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" gap={0}>
|
||||
<Text fontWeight="semibold">{s.label}</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{s.desc}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
colorScheme={
|
||||
strategy === s.value ? "primary" : "gray"
|
||||
}
|
||||
>
|
||||
{strategy === s.value ? <LuSparkles /> : null}
|
||||
{strategy === s.value ? t("selected") : ""}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</VStack>
|
||||
<Button
|
||||
mt={4}
|
||||
w="full"
|
||||
onClick={handlePredict}
|
||||
loading={generatePrediction.isPending}
|
||||
>
|
||||
<LuSparkles /> {t("generate-prediction")}
|
||||
</Button>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Bulletins List */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
{t("bulletin-history")}
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{bulletins.isLoading ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Table.Root size="sm">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>
|
||||
{t("cycle-no")}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
{t("draw-date")}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t("status")}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
{t("matches")}
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{bulletins.data?.data?.map((b: SporTotoBulletinDto) => (
|
||||
<Table.Row
|
||||
key={b.id}
|
||||
cursor="pointer"
|
||||
onClick={() => setSelectedBulletin(b.id)}
|
||||
bg={
|
||||
selectedBulletin === b.id
|
||||
? "primary.50"
|
||||
: "transparent"
|
||||
}
|
||||
>
|
||||
<Table.Cell>{b.gameCycleNo}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{new Date(b.drawDate).toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
colorScheme={
|
||||
b.status === "COMPLETED"
|
||||
? "green"
|
||||
: b.status === "ACTIVE"
|
||||
? "blue"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{b.matches?.length || 0}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
|
||||
{/* Right Column - Rollover Stats */}
|
||||
<Box flex={1}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading as="h3" size="sm">
|
||||
<HStack gap={2}>
|
||||
<LuTrendingUp />
|
||||
<Text>{t("rollover-stats")}</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{rolloverHistory.isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<VStack gap={3}>
|
||||
{rolloverHistory.data?.data
|
||||
?.slice(0, 5)
|
||||
.map(
|
||||
(item: {
|
||||
gameCycleNo: number;
|
||||
rolloverAmount: number;
|
||||
drawDate: string;
|
||||
}) => (
|
||||
<Flex
|
||||
key={item.gameCycleNo}
|
||||
justify="space-between"
|
||||
w="full"
|
||||
py={2}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<VStack align="start" gap={0}>
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
{t("cycle-no-short", {
|
||||
cycle: item.gameCycleNo,
|
||||
})}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{new Date(item.drawDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge colorScheme="orange">
|
||||
<HStack gap={1}>
|
||||
<LuTicket />
|
||||
<Text>
|
||||
{new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
}).format(item.rolloverAmount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
</Flex>
|
||||
),
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Box>
|
||||
</Flex>
|
||||
</StaggerContainer>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Image,
|
||||
Spinner,
|
||||
Button,
|
||||
Card,
|
||||
Table,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, FadeIn } from "@/components/motion";
|
||||
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
||||
import { LuArrowLeft, LuCalendar, LuTrophy } from "react-icons/lu";
|
||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
|
||||
function getMatchTimestamp(match: MatchResponseDto): number {
|
||||
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
|
||||
return Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
|
||||
function getMatchStatus(match: MatchResponseDto): string {
|
||||
return String(match.status || (match as Record<string, unknown>).state || "").toUpperCase();
|
||||
}
|
||||
|
||||
function isMatchFinished(match: MatchResponseDto): boolean {
|
||||
const status = getMatchStatus(match);
|
||||
return status === "FT" || status === "FINISHED" || status === "POSTGAME" || status === "POST_GAME";
|
||||
}
|
||||
|
||||
function isMatchLive(match: MatchResponseDto): boolean {
|
||||
const status = getMatchStatus(match);
|
||||
return status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS";
|
||||
}
|
||||
|
||||
function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
||||
return String(team?.name || fallback || "");
|
||||
}
|
||||
|
||||
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
||||
return String(team?.logo || team?.logoUrl || fallback || "");
|
||||
}
|
||||
|
||||
function getLeagueLabel(match: MatchResponseDto): string {
|
||||
return String(match.leagueName || match.league?.name || "");
|
||||
}
|
||||
|
||||
export default function TeamDetailContent() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const teamId = params.id as string;
|
||||
|
||||
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
||||
const { data: matchesData, isLoading: matchesLoading } = useTeamMatches(teamId, { limit: 30 });
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const team = teamData?.data;
|
||||
const matches: MatchResponseDto[] = matchesData?.data ?? [];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate past and upcoming matches
|
||||
const isFinished = (m: MatchResponseDto) => isMatchFinished(m);
|
||||
|
||||
const pastMatches = matches.filter((m: MatchResponseDto) => isFinished(m));
|
||||
const upcomingMatches = matches.filter((m: MatchResponseDto) => !isFinished(m));
|
||||
|
||||
const getStatusBadge = (match: MatchResponseDto) => {
|
||||
if (isMatchLive(match))
|
||||
return (
|
||||
<Badge colorPalette="red" variant="subtle" fontSize="xs">
|
||||
Canlı
|
||||
</Badge>
|
||||
);
|
||||
if (isMatchFinished(match))
|
||||
return (
|
||||
<Badge colorPalette="gray" variant="subtle" fontSize="xs">
|
||||
Bitti
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge colorPalette="green" variant="subtle" fontSize="xs">
|
||||
Yaklaşan
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* Back Button */}
|
||||
<Button variant="ghost" size="sm" mb={4} onClick={() => router.back()} gap={1.5}>
|
||||
<LuArrowLeft />
|
||||
Geri
|
||||
</Button>
|
||||
|
||||
{/* Team Header */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Body>
|
||||
<HStack gap={6} justify="center" align="center">
|
||||
{team.logo ? (
|
||||
<Image
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
boxSize="80px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="80px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="xl"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
||||
{team.name?.charAt(0) || "T"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<VStack gap={1} align="start">
|
||||
<Heading as="h1" size="xl">
|
||||
{team.name}
|
||||
</Heading>
|
||||
{team.country && (
|
||||
<Text fontSize="md" color="fg.muted">
|
||||
🌍 {team.country}
|
||||
</Text>
|
||||
)}
|
||||
<HStack gap={4} mt={1}>
|
||||
<Badge colorPalette="blue" variant="subtle">
|
||||
<LuTrophy style={{ width: 12, height: 12 }} />
|
||||
{matches.length} Maç
|
||||
</Badge>
|
||||
<Badge colorPalette="green" variant="subtle">
|
||||
<LuCalendar style={{ width: 12, height: 12 }} />
|
||||
{upcomingMatches.length} Yaklaşan
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Upcoming Matches */}
|
||||
{upcomingMatches.length > 0 && (
|
||||
<FadeIn>
|
||||
<Box mb={6}>
|
||||
<Heading as="h2" size="lg" mb={4}>
|
||||
📅 Yaklaşan Maçlar
|
||||
</Heading>
|
||||
<VStack gap={2} align="stretch">
|
||||
{upcomingMatches.map((match: MatchResponseDto) => (
|
||||
<MatchRow
|
||||
key={match.id}
|
||||
match={match}
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
statusBadge={getStatusBadge(match)}
|
||||
onClick={() => router.push(`/tr/matches/${match.id}`)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
{/* Past Matches */}
|
||||
<FadeIn>
|
||||
<Box>
|
||||
<Heading as="h2" size="lg" mb={4}>
|
||||
📊 Geçmiş Maçlar
|
||||
</Heading>
|
||||
{matchesLoading ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="md" color="primary.500" />
|
||||
</Flex>
|
||||
) : pastMatches.length === 0 ? (
|
||||
<Text color="fg.muted" textAlign="center" py={8}>
|
||||
Geçmiş maç bulunamadı
|
||||
</Text>
|
||||
) : (
|
||||
<VStack gap={2} align="stretch">
|
||||
{pastMatches.map((match: MatchResponseDto) => (
|
||||
<MatchRow
|
||||
key={match.id}
|
||||
match={match}
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
statusBadge={getStatusBadge(match)}
|
||||
onClick={() => router.push(`/tr/matches/${match.id}`)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</FadeIn>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// Match Row Component
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
interface MatchRowProps {
|
||||
match: MatchResponseDto;
|
||||
cardBg: string;
|
||||
borderColor: string;
|
||||
statusBadge: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRowProps) {
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.700");
|
||||
const matchTimestamp = getMatchTimestamp(match);
|
||||
const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName);
|
||||
const awayTeamName = getTeamSideName(match.awayTeam, match.awayTeamName);
|
||||
const homeTeamLogo = getTeamSideLogo(match.homeTeam, match.homeTeamLogo);
|
||||
const awayTeamLogo = getTeamSideLogo(match.awayTeam, match.awayTeamLogo);
|
||||
const leagueLabel = getLeagueLabel(match);
|
||||
const hasScore = isMatchFinished(match) || isMatchLive(match);
|
||||
|
||||
return (
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: hoverBg, transform: "translateY(-1px)", shadow: "sm" }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card.Body py={3} px={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack gap={3} flex={1}>
|
||||
{/* Home Team */}
|
||||
<HStack gap={2} flex={1} justify="flex-end">
|
||||
<Text fontSize="sm" fontWeight="600" textAlign="right" truncate>
|
||||
{homeTeamName}
|
||||
</Text>
|
||||
{homeTeamLogo ? (
|
||||
<Image src={homeTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
|
||||
) : (
|
||||
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}>
|
||||
<Text fontSize="xs" fontWeight="bold">{homeTeamName?.charAt(0)}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Score / VS */}
|
||||
<VStack gap={0} flexShrink={0} minW="60px">
|
||||
{hasScore && match.scoreHome !== undefined && match.scoreHome !== null ? (
|
||||
<Text fontSize="md" fontWeight="900">
|
||||
{match.scoreHome} - {match.scoreAway}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="sm" color="fg.muted" fontWeight="600">
|
||||
vs
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{matchTimestamp
|
||||
? new Date(matchTimestamp).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
})
|
||||
: "-"}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<HStack gap={2} flex={1}>
|
||||
{awayTeamLogo ? (
|
||||
<Image src={awayTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
|
||||
) : (
|
||||
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}>
|
||||
<Text fontSize="xs" fontWeight="bold">{awayTeamName?.charAt(0)}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="600" truncate>
|
||||
{awayTeamName}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* Status + League */}
|
||||
<HStack gap={2} flexShrink={0} ml={3}>
|
||||
{leagueLabel && (
|
||||
<Text fontSize="2xs" color="fg.muted" display={{ base: "none", md: "block" }}>
|
||||
{leagueLabel}
|
||||
</Text>
|
||||
)}
|
||||
{statusBadge}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Image,
|
||||
Spinner,
|
||||
Card,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
import type { TeamDto } from "@/lib/api/leagues/types";
|
||||
|
||||
export default function TeamsContent() {
|
||||
const t = useTranslations();
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.700");
|
||||
|
||||
const { data: searchData, isLoading } = useSearchTeams({ q: query });
|
||||
const teams: TeamDto[] = searchData?.data ?? [];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
<Heading as="h1" size="xl" mb={6}>
|
||||
🔍 {t("nav.teams")}
|
||||
</Heading>
|
||||
|
||||
{/* Search Bar */}
|
||||
<Flex
|
||||
align="center"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
px={4}
|
||||
py={2}
|
||||
mb={6}
|
||||
gap={3}
|
||||
>
|
||||
<LuSearch style={{ opacity: 0.5, flexShrink: 0 }} />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Takım adı yazın... (min 2 karakter)"
|
||||
variant="flushed"
|
||||
size="lg"
|
||||
fontSize="md"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<Flex justify="center" py={10}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : query.length < 2 ? (
|
||||
<Flex justify="center" py={16} direction="column" align="center" gap={3}>
|
||||
<Text fontSize="5xl">⚽</Text>
|
||||
<Text color="fg.muted" fontSize="lg">
|
||||
Aramak istediğiniz takımın adını yazın
|
||||
</Text>
|
||||
<Text color="fg.muted" fontSize="sm">
|
||||
Örnek: Galatasaray, Barcelona, Manchester City
|
||||
</Text>
|
||||
</Flex>
|
||||
) : teams.length === 0 ? (
|
||||
<Flex justify="center" py={10}>
|
||||
<Text color="fg.muted">Sonuç bulunamadı</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<VStack gap={3} align="stretch">
|
||||
{teams.map((team: TeamDto) => (
|
||||
<Card.Root
|
||||
key={team.id}
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: hoverBg,
|
||||
transform: "translateY(-2px)",
|
||||
shadow: "md",
|
||||
}}
|
||||
onClick={() => router.push(`/teams/${team.id}`)}
|
||||
>
|
||||
<Card.Body>
|
||||
<HStack gap={4}>
|
||||
{team.logo ? (
|
||||
<Image
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
boxSize="48px"
|
||||
objectFit="contain"
|
||||
borderRadius="lg"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="48px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="lg"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="xl" fontWeight="bold" color="primary.fg">
|
||||
{team.name?.charAt(0) || "T"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Box flex={1}>
|
||||
<Text fontSize="md" fontWeight="700">
|
||||
{team.name}
|
||||
</Text>
|
||||
{team.country && (
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{team.country}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
→
|
||||
</Text>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon, IconButton, Presence } from '@chakra-ui/react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
|
||||
const BackToTop = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsVisible(window.pageYOffset > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Presence
|
||||
unmountOnExit
|
||||
present={isVisible}
|
||||
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
|
||||
animationDuration='moderate'
|
||||
>
|
||||
<IconButton
|
||||
variant={{ base: 'solid', _dark: 'subtle' }}
|
||||
aria-label='Back to top'
|
||||
position='fixed'
|
||||
bottom='8'
|
||||
right='8'
|
||||
borderRadius='full'
|
||||
size='lg'
|
||||
shadow='lg'
|
||||
zIndex='999'
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<Icon>
|
||||
<FiChevronUp />
|
||||
</Icon>
|
||||
</IconButton>
|
||||
</Presence>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackToTop;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
||||
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ButtonLoadingProps {
|
||||
loading?: boolean;
|
||||
loadingText?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
||||
const { loading, disabled, loadingText, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||
{loading && !loadingText ? (
|
||||
<>
|
||||
<AbsoluteCenter display='inline-flex'>
|
||||
<Spinner size='inherit' color='inherit' />
|
||||
</AbsoluteCenter>
|
||||
<Span opacity={0}>{children}</Span>
|
||||
</>
|
||||
) : loading && loadingText ? (
|
||||
<>
|
||||
<Spinner size='inherit' color='inherit' />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ChakraButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuX } from 'react-icons/lu';
|
||||
|
||||
export type CloseButtonProps = ButtonProps;
|
||||
|
||||
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
|
||||
return (
|
||||
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
|
||||
{props.children ?? <LuX />}
|
||||
</ChakraIconButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
|
||||
import { createRecipeContext } from '@chakra-ui/react';
|
||||
|
||||
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
|
||||
|
||||
const { withContext } = createRecipeContext({ key: 'button' });
|
||||
|
||||
// Replace "a" with your framework's link component
|
||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ToggleProps extends ChakraToggle.RootProps {
|
||||
variant?: keyof typeof variantMap;
|
||||
size?: ButtonProps['size'];
|
||||
}
|
||||
|
||||
const variantMap = {
|
||||
solid: { on: 'solid', off: 'outline' },
|
||||
surface: { on: 'surface', off: 'outline' },
|
||||
subtle: { on: 'subtle', off: 'ghost' },
|
||||
ghost: { on: 'subtle', off: 'ghost' },
|
||||
} as const;
|
||||
|
||||
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
|
||||
const { variant = 'subtle', size, children, ...rest } = props;
|
||||
const variantConfig = variantMap[variant];
|
||||
|
||||
return (
|
||||
<ChakraToggle.Root asChild {...rest}>
|
||||
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
|
||||
{children}
|
||||
</ToggleBaseButton>
|
||||
</ChakraToggle.Root>
|
||||
);
|
||||
});
|
||||
|
||||
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
variant: Record<'on' | 'off', ButtonProps['variant']>;
|
||||
}
|
||||
|
||||
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
|
||||
function ToggleBaseButton(props, ref) {
|
||||
const toggle = useToggleContext();
|
||||
const { variant, ...rest } = props;
|
||||
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const ToggleIndicator = ChakraToggle.Indicator;
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
|
||||
function ComboboxControl(props, ref) {
|
||||
const { children, clearable, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Control {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.IndicatorGroup>
|
||||
{clearable && <ComboboxClearTrigger />}
|
||||
<ChakraCombobox.Trigger />
|
||||
</ChakraCombobox.IndicatorGroup>
|
||||
</ChakraCombobox.Control>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
|
||||
function ComboboxClearTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
||||
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||
</ChakraCombobox.ClearTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
|
||||
function ComboboxContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraCombobox.Positioner>
|
||||
<ChakraCombobox.Content {...rest} ref={ref} />
|
||||
</ChakraCombobox.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
|
||||
function ComboboxItem(props, ref) {
|
||||
const { item, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.ItemIndicator />
|
||||
</ChakraCombobox.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
|
||||
function ComboboxRoot(props, ref) {
|
||||
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
|
||||
},
|
||||
) as ChakraCombobox.RootComponent;
|
||||
|
||||
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
|
||||
function ComboboxItemGroup(props, ref) {
|
||||
const { children, label, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
||||
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
|
||||
{children}
|
||||
</ChakraCombobox.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ComboboxLabel = ChakraCombobox.Label;
|
||||
export const ComboboxInput = ChakraCombobox.Input;
|
||||
export const ComboboxEmpty = ChakraCombobox.Empty;
|
||||
export const ComboboxItemText = ChakraCombobox.ItemText;
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Listbox as ChakraListbox } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export const ListboxRoot = React.forwardRef<HTMLDivElement, ChakraListbox.RootProps>(function ListboxRoot(props, ref) {
|
||||
return <ChakraListbox.Root {...props} ref={ref} />;
|
||||
}) as ChakraListbox.RootComponent;
|
||||
|
||||
export const ListboxContent = React.forwardRef<HTMLDivElement, ChakraListbox.ContentProps>(
|
||||
function ListboxContent(props, ref) {
|
||||
return <ChakraListbox.Content {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const ListboxItem = React.forwardRef<HTMLDivElement, ChakraListbox.ItemProps>(function ListboxItem(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraListbox.Item {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraListbox.ItemIndicator />
|
||||
</ChakraListbox.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const ListboxLabel = ChakraListbox.Label;
|
||||
export const ListboxItemText = ChakraListbox.ItemText;
|
||||
export const ListboxEmpty = ChakraListbox.Empty;
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import type { CollectionItem } from '@chakra-ui/react';
|
||||
import { Select as ChakraSelect, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '../buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface SelectTriggerProps extends ChakraSelect.ControlProps {
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
|
||||
function SelectTrigger(props, ref) {
|
||||
const { children, clearable, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.Control {...rest}>
|
||||
<ChakraSelect.Trigger ref={ref}>{children}</ChakraSelect.Trigger>
|
||||
<ChakraSelect.IndicatorGroup>
|
||||
{clearable && <SelectClearTrigger />}
|
||||
<ChakraSelect.Indicator />
|
||||
</ChakraSelect.IndicatorGroup>
|
||||
</ChakraSelect.Control>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const SelectClearTrigger = React.forwardRef<HTMLButtonElement, ChakraSelect.ClearTriggerProps>(
|
||||
function SelectClearTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraSelect.ClearTrigger asChild {...props} ref={ref}>
|
||||
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||
</ChakraSelect.ClearTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface SelectContentProps extends ChakraSelect.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(function SelectContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraSelect.Positioner>
|
||||
<ChakraSelect.Content {...rest} ref={ref} />
|
||||
</ChakraSelect.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProps>(function SelectItem(props, ref) {
|
||||
const { item, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraSelect.ItemIndicator />
|
||||
</ChakraSelect.Item>
|
||||
);
|
||||
});
|
||||
|
||||
interface SelectValueTextProps extends Omit<ChakraSelect.ValueTextProps, 'children'> {
|
||||
children?(items: CollectionItem[]): React.ReactNode;
|
||||
}
|
||||
|
||||
export const SelectValueText = React.forwardRef<HTMLSpanElement, SelectValueTextProps>(
|
||||
function SelectValueText(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.ValueText {...rest} ref={ref}>
|
||||
<ChakraSelect.Context>
|
||||
{(select) => {
|
||||
const items = select.selectedItems;
|
||||
if (items.length === 0) return props.placeholder;
|
||||
if (children) return children(items);
|
||||
if (items.length === 1) return select.collection.stringifyItem(items[0]);
|
||||
return `${items.length} selected`;
|
||||
}}
|
||||
</ChakraSelect.Context>
|
||||
</ChakraSelect.ValueText>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const SelectRoot = React.forwardRef<HTMLDivElement, ChakraSelect.RootProps>(function SelectRoot(props, ref) {
|
||||
return (
|
||||
<ChakraSelect.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }}>
|
||||
{props.asChild ? (
|
||||
props.children
|
||||
) : (
|
||||
<>
|
||||
<ChakraSelect.HiddenSelect />
|
||||
{props.children}
|
||||
</>
|
||||
)}
|
||||
</ChakraSelect.Root>
|
||||
);
|
||||
}) as ChakraSelect.RootComponent;
|
||||
|
||||
interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps {
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SelectItemGroup = React.forwardRef<HTMLDivElement, SelectItemGroupProps>(
|
||||
function SelectItemGroup(props, ref) {
|
||||
const { children, label, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.ItemGroup {...rest} ref={ref}>
|
||||
<ChakraSelect.ItemGroupLabel>{label}</ChakraSelect.ItemGroupLabel>
|
||||
{children}
|
||||
</ChakraSelect.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const SelectLabel = ChakraSelect.Label;
|
||||
export const SelectItemText = ChakraSelect.ItemText;
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { TreeView as ChakraTreeView } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export const TreeViewRoot = React.forwardRef<HTMLDivElement, ChakraTreeView.RootProps>(
|
||||
function TreeViewRoot(props, ref) {
|
||||
return <ChakraTreeView.Root {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
interface TreeViewTreeProps extends ChakraTreeView.TreeProps {}
|
||||
|
||||
export const TreeViewTree = React.forwardRef<HTMLDivElement, TreeViewTreeProps>(function TreeViewTree(props, ref) {
|
||||
const { ...rest } = props;
|
||||
return <ChakraTreeView.Tree {...rest} ref={ref} />;
|
||||
});
|
||||
|
||||
export const TreeViewBranch = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchProps>(
|
||||
function TreeViewBranch(props, ref) {
|
||||
return <ChakraTreeView.Branch {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TreeViewBranchControl = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchControlProps>(
|
||||
function TreeViewBranchControl(props, ref) {
|
||||
return <ChakraTreeView.BranchControl {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TreeViewItem = React.forwardRef<HTMLDivElement, ChakraTreeView.ItemProps>(
|
||||
function TreeViewItem(props, ref) {
|
||||
return <ChakraTreeView.Item {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TreeViewLabel = ChakraTreeView.Label;
|
||||
export const TreeViewBranchIndicator = ChakraTreeView.BranchIndicator;
|
||||
export const TreeViewBranchText = ChakraTreeView.BranchText;
|
||||
export const TreeViewBranchContent = ChakraTreeView.BranchContent;
|
||||
export const TreeViewBranchIndentGuide = ChakraTreeView.BranchIndentGuide;
|
||||
export const TreeViewItemText = ChakraTreeView.ItemText;
|
||||
export const TreeViewNode = ChakraTreeView.Node;
|
||||
export const TreeViewNodeProvider = ChakraTreeView.NodeProvider;
|
||||
|
||||
export const TreeView = {
|
||||
Root: TreeViewRoot,
|
||||
Label: TreeViewLabel,
|
||||
Tree: TreeViewTree,
|
||||
Branch: TreeViewBranch,
|
||||
BranchControl: TreeViewBranchControl,
|
||||
BranchIndicator: TreeViewBranchIndicator,
|
||||
BranchText: TreeViewBranchText,
|
||||
BranchContent: TreeViewBranchContent,
|
||||
BranchIndentGuide: TreeViewBranchIndentGuide,
|
||||
Item: TreeViewItem,
|
||||
ItemText: TreeViewItemText,
|
||||
Node: TreeViewNode,
|
||||
NodeProvider: TreeViewNodeProvider,
|
||||
};
|
||||