Compare commits
26 Commits
prod-ready
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df5145104 | |||
| fc369db123 | |||
| a70848164e | |||
| 71a6ed320c | |||
| e744a62fc2 | |||
| 66877b88ca | |||
| b2ccc98226 | |||
| e6e58b4433 | |||
| c47f128958 | |||
| 5c8619b282 | |||
| 6dadc5f613 | |||
| 4df27e3e6d | |||
| e3cc6702dd | |||
| f72857a3b2 | |||
| 0d194f7409 | |||
| ab5864df2f | |||
| bff5ea7b5f | |||
| 14159911f0 | |||
| 96b9653a7e | |||
| 30592394ef | |||
| c450661cf8 | |||
| 4bf0ab52f9 | |||
| 105c10699f | |||
| 10e521e382 | |||
| 9e04ca5627 | |||
| 4896323e04 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 6195
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ==========================================
|
||||||
|
# IDDAAI-FE — DEVELOPMENT ENVIRONMENT VARIABLES
|
||||||
|
# ==========================================
|
||||||
|
# Bu dosya lokal geliştirme için kullanılır.
|
||||||
|
# Prod değerleri: .gitea/workflows/deploy.yml → Gitea Secrets
|
||||||
|
|
||||||
|
# --- Next.js App ---
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3005/api
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
PORT=3000
|
||||||
|
HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# --- NextAuth ---
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET='fFw34R134jRof1H2jofh2!32hU3gfjA1'
|
||||||
|
|
||||||
|
# --- Auth Config ---
|
||||||
|
NEXT_PUBLIC_AUTH_REQUIRED=false
|
||||||
|
NEXT_PUBLIC_ENABLE_MOCK_MODE=false
|
||||||
|
|
||||||
|
# --- Paddle (Sandbox) ---
|
||||||
|
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN='test_...'
|
||||||
|
NEXT_PUBLIC_PADDLE_ENVIRONMENT='sandbox'
|
||||||
|
NEXT_PUBLIC_PADDLE_SELLER_ID='...'
|
||||||
@@ -11,11 +11,29 @@ jobs:
|
|||||||
- name: Kodu Cek
|
- name: Kodu Cek
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ortam Degiskenlerini Olustur
|
||||||
|
run: |
|
||||||
|
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > .env.production
|
||||||
|
echo "NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}" >> .env.production
|
||||||
|
echo "NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }}" >> .env.production
|
||||||
|
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env.production
|
||||||
|
echo "NEXT_PUBLIC_AUTH_REQUIRED=${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}" >> .env.production
|
||||||
|
echo "NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=${{ secrets.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN }}" >> .env.production
|
||||||
|
echo "NEXT_PUBLIC_PADDLE_ENVIRONMENT=${{ secrets.NEXT_PUBLIC_PADDLE_ENVIRONMENT }}" >> .env.production
|
||||||
|
echo "NEXT_PUBLIC_PADDLE_SELLER_ID=${{ secrets.NEXT_PUBLIC_PADDLE_SELLER_ID }}" >> .env.production
|
||||||
|
cp .env.production .env.development
|
||||||
|
|
||||||
- name: Docker Build
|
- name: Docker Build
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg NEXT_PUBLIC_API_URL='https://api.iddaai.com/api' \
|
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
|
||||||
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='false' \
|
--build-arg NEXT_PUBLIC_APP_URL="${{ secrets.NEXT_PUBLIC_APP_URL }}" \
|
||||||
|
--build-arg NEXTAUTH_URL="${{ secrets.NEXTAUTH_URL }}" \
|
||||||
|
--build-arg NEXTAUTH_SECRET="${{ secrets.NEXTAUTH_SECRET }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_AUTH_REQUIRED="${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ secrets.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_PADDLE_ENVIRONMENT="${{ secrets.NEXT_PUBLIC_PADDLE_ENVIRONMENT }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_PADDLE_SELLER_ID="${{ secrets.NEXT_PUBLIC_PADDLE_SELLER_ID }}" \
|
||||||
-t iddaai-fe:latest .
|
-t iddaai-fe:latest .
|
||||||
|
|
||||||
- name: Eski Konteyneri Sil
|
- name: Eski Konteyneri Sil
|
||||||
@@ -29,8 +47,5 @@ jobs:
|
|||||||
--network iddaai_iddaai-network \
|
--network iddaai_iddaai-network \
|
||||||
-p 127.0.0.1:1510:3000 \
|
-p 127.0.0.1:1510:3000 \
|
||||||
-e NODE_ENV=production \
|
-e NODE_ENV=production \
|
||||||
-e NEXT_PUBLIC_API_URL='https://api.iddaai.com/api' \
|
--env-file .env.production \
|
||||||
-e NEXTAUTH_URL='https://iddaai.com' \
|
|
||||||
-e NEXTAUTH_SECRET='fFw34R134jRof1H2jofh2!32hU3gfjA1' \
|
|
||||||
-e NEXT_PUBLIC_AUTH_REQUIRED='false' \
|
|
||||||
iddaai-fe:latest
|
iddaai-fe:latest
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.next
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
certificates/
|
||||||
|
|||||||
+34
-12
@@ -9,10 +9,26 @@ RUN npm install
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build-time environment variables
|
||||||
|
ARG NEXT_PUBLIC_API_URL
|
||||||
|
ARG NEXT_PUBLIC_APP_URL
|
||||||
|
ARG NEXTAUTH_URL
|
||||||
|
ARG NEXTAUTH_SECRET
|
||||||
|
ARG NEXT_PUBLIC_AUTH_REQUIRED
|
||||||
|
ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||||
|
ARG NEXT_PUBLIC_PADDLE_ENVIRONMENT
|
||||||
|
ARG NEXT_PUBLIC_PADDLE_SELLER_ID
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||||
|
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
||||||
|
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||||
|
ENV NEXT_PUBLIC_AUTH_REQUIRED=$NEXT_PUBLIC_AUTH_REQUIRED
|
||||||
|
ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||||
|
ENV NEXT_PUBLIC_PADDLE_ENVIRONMENT=$NEXT_PUBLIC_PADDLE_ENVIRONMENT
|
||||||
|
ENV NEXT_PUBLIC_PADDLE_SELLER_ID=$NEXT_PUBLIC_PADDLE_SELLER_ID
|
||||||
|
|
||||||
# Build Next.js app
|
# 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
|
RUN npm run build
|
||||||
|
|
||||||
# --- STAGE 2: RUNNER ---
|
# --- STAGE 2: RUNNER ---
|
||||||
@@ -21,16 +37,22 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Copy only necessary files
|
# Don't run as root
|
||||||
COPY --from=builder /app/package*.json ./
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
COPY --from=builder /app/.next ./.next
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy standalone build
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/next.config.ts ./
|
# Set permissions for standalone build
|
||||||
# Copy messages for internationalization
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder /app/messages ./messages
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Start Next.js
|
# Start standalone server
|
||||||
CMD ["npm", "start"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
-----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-----
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
-----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-----
|
|
||||||
+5
-11
@@ -1,20 +1,14 @@
|
|||||||
import { dirname } from 'path';
|
import nextConfig from 'eslint-config-next';
|
||||||
import { fileURLToPath } from 'url';
|
import prettierConfig from 'eslint-config-prettier';
|
||||||
import { FlatCompat } from '@eslint/eslintrc';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
|
...nextConfig,
|
||||||
|
prettierConfig,
|
||||||
{
|
{
|
||||||
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
|
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-empty-object-type': 'off',
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
|||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
> iddaai-fe@0.0.1 lint
|
||||||
|
> eslint
|
||||||
|
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/admin/edit-user-modal.tsx
|
||||||
|
36:9 warning 't' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
37:9 warning 'tCommon' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
50: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/iddaai/iddaai-fe/src/components/admin/edit-user-modal.tsx:50:7
|
||||||
|
48 | useEffect(() => {
|
||||||
|
49 | if (user) {
|
||||||
|
> 50 | setRole(user.role || "user");
|
||||||
|
| ^^^^^^^ Avoid calling setState() directly within an effect
|
||||||
|
51 | setPlan(user.subscriptionStatus || "free");
|
||||||
|
52 | setIsActive(user.isActive);
|
||||||
|
53 | if (user.subscriptionExpiresAt) { react-hooks/set-state-in-effect
|
||||||
|
57:17 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/analysis/analysis-content.tsx
|
||||||
|
14:3 warning 'SimpleGrid' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
31:9 warning 'tCommon' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/coupons/coupon-builder-content.tsx
|
||||||
|
367:6 warning React Hook React.useEffect has a missing dependency: 'upcomingQuery'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||||
|
381:6 warning React Hook React.useEffect has a missing dependency: 'finishedQuery'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||||
|
403:9 warning The 'leagueGroups' logical expression could make the dependencies of useMemo Hook (at line 407) change on every render. To fix this, wrap the initialization of 'leagueGroups' in its own useMemo() Hook react-hooks/exhaustive-deps
|
||||||
|
404:9 warning The 'finishedLeagueGroups' logical expression could make the dependencies of useMemo Hook (at line 411) change on every render. To fix this, wrap the initialization of 'finishedLeagueGroups' in its own useMemo() Hook react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/dashboard/dashboard-content.tsx
|
||||||
|
21:3 warning 'ScrollSlideUp' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
127:3 warning 'confidence' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
188:39 warning 'statsLoading' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/h2h/h2h-content.tsx
|
||||||
|
21:24 warning 'HeadToHeadDto' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
24:20 warning 'useEffect' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
84:17 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/iddaai/iddaai-fe/src/components/home/home-content.tsx
|
||||||
|
13:3 warning 'Icon' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
27:3 warning 'springs' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/layout/header/header.tsx
|
||||||
|
47:8 warning 'Image' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
293:13 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/iddaai/iddaai-fe/src/components/leagues/league-detail-content.tsx
|
||||||
|
5:3 warning 'Flex' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
29:9 warning 't' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/leagues/leagues-content.tsx
|
||||||
|
239:23 error React Hook "useColorModeValue" is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks
|
||||||
|
345:39 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
|
||||||
|
414:27 error React Hook "useColorModeValue" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return? react-hooks/rules-of-hooks
|
||||||
|
715:23 error `"` can be escaped with `"`, `“`, `"`, `”` react/no-unescaped-entities
|
||||||
|
715:44 error `"` can be escaped with `"`, `“`, `"`, `”` react/no-unescaped-entities
|
||||||
|
762:31 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/iddaai/iddaai-fe/src/components/matches/match-detail-content.tsx
|
||||||
|
15:3 warning 'Grid' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
33:3 warning 'LuShield' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
34:3 warning 'LuFlag' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/matches/match-list.tsx
|
||||||
|
9:3 warning 'ScrollSlideUp' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/matches/matches-content.tsx
|
||||||
|
38:41 warning 'matchesLoading' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/matches/odds-card.tsx
|
||||||
|
7:3 warning 'Badge' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/matches/v28-odds-band-panel.tsx
|
||||||
|
374:15 error React Hook "useColorModeValue" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return? react-hooks/rules-of-hooks
|
||||||
|
565:15 error React Hook "useColorModeValue" is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/motion/index.tsx
|
||||||
|
417:5 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/iddaai/iddaai-fe/src/components/motion/index.tsx:417:5
|
||||||
|
415 | delay: Math.random() * 3,
|
||||||
|
416 | }));
|
||||||
|
> 417 | setSparkles(newSparkles);
|
||||||
|
| ^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||||
|
418 | }, [count]);
|
||||||
|
419 |
|
||||||
|
420 | if (sparkles.length === 0) { react-hooks/set-state-in-effect
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/spor-toto/spor-toto-content.tsx
|
||||||
|
18:37 warning 'StaggerItem' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
29:3 warning 'SporTotoPredictionResultDto' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
52:9 warning 'tCommon' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
88:11 warning 'result' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/teams/team-detail-content.tsx
|
||||||
|
26:3 warning 'LuChevronDown' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
81:10 warning 'getSeasonFromTimestamp' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
107:9 warning 't' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
144:9 warning The 'matches' conditional could make the dependencies of useMemo Hook (at line 157) change on every render. To fix this, wrap the initialization of 'matches' in its own useMemo() Hook react-hooks/exhaustive-deps
|
||||||
|
144:9 warning The 'matches' conditional could make the dependencies of useMemo Hook (at line 161) change on every render. To fix this, wrap the initialization of 'matches' in its own useMemo() Hook react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/components/ui/top-loader.tsx
|
||||||
|
12:5 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/iddaai/iddaai-fe/src/components/ui/top-loader.tsx:12:5
|
||||||
|
10 |
|
||||||
|
11 | useEffect(() => {
|
||||||
|
> 12 | setMounted(true);
|
||||||
|
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
|
||||||
|
13 | }, []);
|
||||||
|
14 |
|
||||||
|
15 | if (!mounted) return null; react-hooks/set-state-in-effect
|
||||||
|
|
||||||
|
/Users/piton/Documents/GitHub/iddaai/iddaai-fe/src/lib/api/leagues/service.ts
|
||||||
|
3:15 warning 'MatchResponseDto' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
✖ 48 problems (9 errors, 39 warnings)
|
||||||
|
|
||||||
+339
-31
@@ -34,7 +34,11 @@
|
|||||||
"forgot-password": "Forgot Password?",
|
"forgot-password": "Forgot Password?",
|
||||||
"or-continue-with": "Or continue with",
|
"or-continue-with": "Or continue with",
|
||||||
"logging-in": "Signing in...",
|
"logging-in": "Signing in...",
|
||||||
"registering": "Creating account..."
|
"registering": "Creating account...",
|
||||||
|
"login-success": "Login successful!",
|
||||||
|
"register-success": "Registration successful!",
|
||||||
|
"login-required-title": "Login Required",
|
||||||
|
"login-required-message": "Please sign in or create an account to view match analysis."
|
||||||
},
|
},
|
||||||
"all-right-reserved": "All rights reserved.",
|
"all-right-reserved": "All rights reserved.",
|
||||||
"privacy-policy": "Privacy Policy",
|
"privacy-policy": "Privacy Policy",
|
||||||
@@ -43,7 +47,6 @@
|
|||||||
"low": "Low",
|
"low": "Low",
|
||||||
"medium": "Medium",
|
"medium": "Medium",
|
||||||
"high": "High",
|
"high": "High",
|
||||||
|
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -64,13 +67,12 @@
|
|||||||
"coupons": "Coupons",
|
"coupons": "Coupons",
|
||||||
"tools": "Tools"
|
"tools": "Tools"
|
||||||
},
|
},
|
||||||
|
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero-title": "AI-Powered Betting Predictions",
|
"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.",
|
"hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.",
|
||||||
"get-started": "Get Started",
|
"get-started": "Get Started",
|
||||||
"learn-more": "Learn More",
|
"learn-more": "Learn More",
|
||||||
"features-title": "Why Choose Suggest Bet?",
|
"features-title": "Why Choose Iddaai?",
|
||||||
"feature-ai": "AI Predictions",
|
"feature-ai": "AI Predictions",
|
||||||
"feature-ai-desc": "Powered by V20 ensemble model with 95%+ data quality scoring.",
|
"feature-ai-desc": "Powered by V20 ensemble model with 95%+ data quality scoring.",
|
||||||
"feature-value": "Value Bets",
|
"feature-value": "Value Bets",
|
||||||
@@ -84,7 +86,6 @@
|
|||||||
"stats-users": "Active Users",
|
"stats-users": "Active Users",
|
||||||
"stats-matches": "Matches Analyzed"
|
"stats-matches": "Matches Analyzed"
|
||||||
},
|
},
|
||||||
|
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"welcome": "Welcome back",
|
"welcome": "Welcome back",
|
||||||
@@ -97,7 +98,6 @@
|
|||||||
"no-matches": "No matches available today.",
|
"no-matches": "No matches available today.",
|
||||||
"no-predictions": "No predictions available."
|
"no-predictions": "No predictions available."
|
||||||
},
|
},
|
||||||
|
|
||||||
"matches": {
|
"matches": {
|
||||||
"title": "Matches",
|
"title": "Matches",
|
||||||
"filter-sport": "Sport",
|
"filter-sport": "Sport",
|
||||||
@@ -119,9 +119,44 @@
|
|||||||
"recent-matches": "Recent Matches",
|
"recent-matches": "Recent Matches",
|
||||||
"home-team": "Home",
|
"home-team": "Home",
|
||||||
"away-team": "Away",
|
"away-team": "Away",
|
||||||
"vs": "vs"
|
"vs": "vs",
|
||||||
|
"referee": "Referee",
|
||||||
|
"sidelined": "Injuries & Absences",
|
||||||
|
"injury": "Injury",
|
||||||
|
"suspended": "Suspended",
|
||||||
|
"other-reason": "Other",
|
||||||
|
"matches-missed": "Matches Missed",
|
||||||
|
"position": "Position",
|
||||||
|
"no-sidelined": "No injury information available",
|
||||||
|
"match-events": "Match Events",
|
||||||
|
"goal": "Goal",
|
||||||
|
"yellow-card": "Yellow Card",
|
||||||
|
"red-card": "Red Card",
|
||||||
|
"substitution": "Substitution",
|
||||||
|
"starters": "Starting XI",
|
||||||
|
"substitutes": "Substitutes",
|
||||||
|
"all-matches": "All Matches",
|
||||||
|
"today-matches": "Today's Matches",
|
||||||
|
"next-1-hour": "Next 1 Hour",
|
||||||
|
"possession": "Possession",
|
||||||
|
"shots-on-target": "Shots on Target",
|
||||||
|
"shots-off-target": "Shots off Target",
|
||||||
|
"total-shots": "Total Shots",
|
||||||
|
"total-passes": "Total Passes",
|
||||||
|
"corners": "Corners",
|
||||||
|
"fouls": "Fouls",
|
||||||
|
"offsides": "Offsides",
|
||||||
|
"officials": "Officials",
|
||||||
|
"main-referee": "Referee",
|
||||||
|
"assistant-referee": "Assistant Referee",
|
||||||
|
"fourth-official": "Fourth Official",
|
||||||
|
"var-referee": "VAR Referee",
|
||||||
|
"avar-referee": "AVAR Referee",
|
||||||
|
"penalty": "Penalty",
|
||||||
|
"half-time": "1st Half",
|
||||||
|
"second-half": "2nd Half",
|
||||||
|
"assist": "Assist"
|
||||||
},
|
},
|
||||||
|
|
||||||
"predictions": {
|
"predictions": {
|
||||||
"title": "Predictions",
|
"title": "Predictions",
|
||||||
"upcoming": "Upcoming",
|
"upcoming": "Upcoming",
|
||||||
@@ -143,6 +178,8 @@
|
|||||||
"bet-summary": "Bet Summary",
|
"bet-summary": "Bet Summary",
|
||||||
"expected-value": "Expected Value",
|
"expected-value": "Expected Value",
|
||||||
"no-predictions": "No predictions available.",
|
"no-predictions": "No predictions available.",
|
||||||
|
"generate": "Analyze with AI",
|
||||||
|
"pre-match-disclaimer": "This analysis is based on pre-match data only.",
|
||||||
"accuracy": "Accuracy",
|
"accuracy": "Accuracy",
|
||||||
"total-predictions": "Total Predictions",
|
"total-predictions": "Total Predictions",
|
||||||
"correct-predictions": "Correct Predictions",
|
"correct-predictions": "Correct Predictions",
|
||||||
@@ -184,11 +221,11 @@
|
|||||||
"missing_total_odds": "Over/Under odds are missing.",
|
"missing_total_odds": "Over/Under odds are missing.",
|
||||||
"missing_spread_odds": "Spread (Handicap) 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.",
|
"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.",
|
"insufficient_play_score": "Model signal is below the threshold.",
|
||||||
"no_ev_edge_minimum_stake": "Passed safety gates but no mathematical edge — minimum stake applied.",
|
"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."
|
"upset_risk_detected": "High upset risk detected, proceed with caution."
|
||||||
},
|
},
|
||||||
"ev-edge": "EV Edge",
|
"ev-edge": "Theoretical Edge",
|
||||||
"implied-prob": "Market Probability",
|
"implied-prob": "Market Probability",
|
||||||
"model-prob": "Model Probability",
|
"model-prob": "Model Probability",
|
||||||
"kelly-stake": "Kelly Stake",
|
"kelly-stake": "Kelly Stake",
|
||||||
@@ -217,27 +254,48 @@
|
|||||||
"HTFT": "Half Time / Full Time",
|
"HTFT": "Half Time / Full Time",
|
||||||
"HT/FT": "Half Time / Full Time",
|
"HT/FT": "Half Time / Full Time",
|
||||||
"OE": "Odd / Even",
|
"OE": "Odd / Even",
|
||||||
"HT_OU05": "First Half 0.5 Goals"
|
"HT_OU05": "First Half 0.5 Goals",
|
||||||
|
"HT_OU15": "First Half 1.5 Goals",
|
||||||
|
"CARDS": "Cards 4.5",
|
||||||
|
"HCAP": "Handicap Result"
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"summary-title": "Prediction Summary",
|
"summary-title": "Prediction Summary",
|
||||||
"summary-info": "Shows what stands out first and then explains why it stands out.",
|
"summary-info": "Shows model signals and uncertainty in a conservative summary.",
|
||||||
"main-recommendation": "Main Recommendation",
|
"model-signal-disclaimer": "This is a model signal; it is not a guaranteed result, guarantee, or hit-rate promise. Signal score can be wrong because of in-match variance, lineups, and data quality.",
|
||||||
|
"main-recommendation": "Highlighted Signal",
|
||||||
"best-market-copy": "is the strongest option in this market.",
|
"best-market-copy": "is the strongest option in this market.",
|
||||||
"confidence-label": "Confidence",
|
"confidence-label": "Confidence",
|
||||||
|
"confidence-interval": "Confidence Interval",
|
||||||
|
"confidence-interval-warning": "The confidence interval is wide. Even with a signal, it is not recommended as a standalone pick.",
|
||||||
|
"confidence-band": "Band",
|
||||||
"odds-label": "Odds",
|
"odds-label": "Odds",
|
||||||
"edge-label": "Expected Advantage (Edge)",
|
"edge-label": "Theoretical 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.",
|
"edge-info": "The theoretical gap between model probability and market probability; it is not a guarantee or a certain profit expectation.",
|
||||||
"stake-label": "Suggested Bet Size (Stake)",
|
"stake-label": "Suggested Bet Size (Stake)",
|
||||||
"stake-label-short": "Bet Size",
|
"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.",
|
"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",
|
"play-score-label": "Model Signal",
|
||||||
"playability-label": "Playability",
|
"playability-label": "Model signal",
|
||||||
"quick-read": "Quick read",
|
"quick-read": "Quick read",
|
||||||
"lineup-source": "Lineup Source",
|
"lineup-source": "Lineup Source",
|
||||||
|
"lineup-confirmed-live": "Confirmed starting XI",
|
||||||
|
"lineup-probable-xi": "Probable starting XI",
|
||||||
|
"unknown": "Unknown",
|
||||||
"model-label": "Model",
|
"model-label": "Model",
|
||||||
"engine-info": "Shows which components influence the prediction the most.",
|
"engine-info": "Shows which components influence the prediction the most.",
|
||||||
"best-single-pick": "Best Single Pick",
|
"engine-team-football": "Team Strength",
|
||||||
|
"engine-team-basketball": "Team Form",
|
||||||
|
"engine-player-football": "Player Impact",
|
||||||
|
"engine-player-basketball": "Lineup Impact",
|
||||||
|
"engine-odds": "Odds Analysis",
|
||||||
|
"engine-referee-football": "Referee Impact",
|
||||||
|
"engine-referee-basketball": "Supporting Signals",
|
||||||
|
"engine-label-high": "High",
|
||||||
|
"engine-label-medium": "Medium",
|
||||||
|
"engine-label-low": "Low",
|
||||||
|
"engine-label-very-low": "Very Low",
|
||||||
|
"best-single-pick": "Strongest Signal",
|
||||||
"alternative-markets": "Alternative Markets",
|
"alternative-markets": "Alternative Markets",
|
||||||
"alternative-markets-info": "Options outside the main recommendation.",
|
"alternative-markets-info": "Options outside the main recommendation.",
|
||||||
"alternative": "Alternative",
|
"alternative": "Alternative",
|
||||||
@@ -246,10 +304,50 @@
|
|||||||
"all-markets-info": "Compares every option in a single table.",
|
"all-markets-info": "Compares every option in a single table.",
|
||||||
"market-board-info": "The probability distribution the model sees for each market.",
|
"market-board-info": "The probability distribution the model sees for each market.",
|
||||||
"bet-advice-info": "The model's final action recommendation.",
|
"bet-advice-info": "The model's final action recommendation.",
|
||||||
"recommended-stake-inline": "Suggested size"
|
"recommended-stake-inline": "Suggested size",
|
||||||
|
"model-probability-short": "Model",
|
||||||
|
"market-probability-short": "Market",
|
||||||
|
"theoretical-edge-inline": "Theoretical edge",
|
||||||
|
"playable": "Playable",
|
||||||
|
"risky": "Risky",
|
||||||
|
"hit-probability": "Hit Probability",
|
||||||
|
"calibrated-confidence": "Calibrated Confidence",
|
||||||
|
"score-scenario-football": "Score Scenario",
|
||||||
|
"score-scenario-basketball": "Points Scenario",
|
||||||
|
"score-scenario-info-football": "Expected score and the most likely scenarios.",
|
||||||
|
"score-scenario-info-basketball": "Expected points distribution and the most likely match scenarios.",
|
||||||
|
"full-time-football": "Full Time",
|
||||||
|
"full-time-basketball": "Full-Time Points",
|
||||||
|
"half-time-football": "Half Time",
|
||||||
|
"half-time-basketball": "Half-Time Points",
|
||||||
|
"expected-total-football": "Total xG",
|
||||||
|
"expected-total-basketball": "Expected Total Points",
|
||||||
|
"live": "LIVE",
|
||||||
|
"pre-match-prediction": "Pre-match prediction",
|
||||||
|
"prediction-contradictions": "Prediction Contradictions",
|
||||||
|
"data-quality": "Data Quality",
|
||||||
|
"data-quality-info": "How reliable the lineup, odds, and match data are.",
|
||||||
|
"risk-info": "Upset probability and uncertainty level.",
|
||||||
|
"risk-commentary": "Risk Commentary",
|
||||||
|
"risk-default-comment": "The model asks for extra caution on this match.",
|
||||||
|
"surprise-score": "Upset score",
|
||||||
|
"match-commentary-title": "Match Commentary",
|
||||||
|
"match-commentary-info": "The model's human-readable summary of the match.",
|
||||||
|
"reasoning-info": "High-level summary of why the model reads this match this way.",
|
||||||
|
"bet-advice-play": "PLAY",
|
||||||
|
"bet-advice-pass": "PASS",
|
||||||
|
"signal-tier-core": "Core",
|
||||||
|
"signal-tier-value": "Value",
|
||||||
|
"signal-tier-lean": "Lean",
|
||||||
|
"signal-tier-longshot": "Longshot",
|
||||||
|
"signal-tier-pass": "Pass",
|
||||||
|
"confidence-high": "High",
|
||||||
|
"confidence-medium": "Medium",
|
||||||
|
"confidence-low": "Low",
|
||||||
|
"confidence-unknown": "Unknown",
|
||||||
|
"info": "Info"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"coupons": {
|
"coupons": {
|
||||||
"title": "Coupon Builder",
|
"title": "Coupon Builder",
|
||||||
"builder-title": "Coupon Builder",
|
"builder-title": "Coupon Builder",
|
||||||
@@ -307,6 +405,9 @@
|
|||||||
"candidate-pool-help": "Only football matches that have not started yet are listed here. Finished and live matches are excluded.",
|
"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",
|
"candidate-pool-subtitle": "Source: live_matches table • sport: football • status: not started",
|
||||||
"match-count-suffix": "matches",
|
"match-count-suffix": "matches",
|
||||||
|
"match-count-label": "Coupon Match Count",
|
||||||
|
"match-count-help": "How many matches should the AI coupon include? You can choose between 2 and 15. If you do not select any matches, the full bulletin is scanned.",
|
||||||
|
"match-count-auto": "Full bulletin ({count} matches)",
|
||||||
"upcoming-badge": "Upcoming",
|
"upcoming-badge": "Upcoming",
|
||||||
"upcoming-reference": "Upcoming pool",
|
"upcoming-reference": "Upcoming pool",
|
||||||
"finished-badge": "Finished",
|
"finished-badge": "Finished",
|
||||||
@@ -342,9 +443,37 @@
|
|||||||
"risk-label": "Risk",
|
"risk-label": "Risk",
|
||||||
"data-quality-label": "Data Quality",
|
"data-quality-label": "Data Quality",
|
||||||
"rejected-matches-title": "Rejected Matches",
|
"rejected-matches-title": "Rejected Matches",
|
||||||
"no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest."
|
"no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest.",
|
||||||
|
"freq-engine-title": "Frequency Engine",
|
||||||
|
"freq-engine-subtitle": "Analyzes teams' historical performance by odds band. Uses statistical database scans instead of AI models.",
|
||||||
|
"freq-suggest": "Generate Frequency Coupon",
|
||||||
|
"freq-suggest-loading": "Running frequency analysis...",
|
||||||
|
"freq-min-signal": "Minimum Signal",
|
||||||
|
"freq-min-signal-help": "Combined signal threshold (0.50-0.99). Lower = more matches, higher = more precise results.",
|
||||||
|
"freq-markets": "Markets",
|
||||||
|
"freq-markets-help": "Select markets to analyze. Leave empty to scan all markets.",
|
||||||
|
"freq-ev-label": "Expected Value (EV)",
|
||||||
|
"freq-ev-help": "Hit Rate × Total Odds. Above 1.0 means +EV (profitable).",
|
||||||
|
"freq-hit-rate": "Est. Hit Rate",
|
||||||
|
"freq-hit-rate-help": "Combined historical hit rate of all bets in the coupon.",
|
||||||
|
"freq-ev-positive": "+EV Positive",
|
||||||
|
"freq-ev-negative": "EV Negative",
|
||||||
|
"freq-home-signal": "Home Signal",
|
||||||
|
"freq-away-signal": "Away Signal",
|
||||||
|
"freq-combined-signal": "Combined Signal",
|
||||||
|
"freq-odds-band": "Odds Band",
|
||||||
|
"freq-league-profile": "League Profile",
|
||||||
|
"freq-league-golcu": "High-Scoring",
|
||||||
|
"freq-league-defansif": "Defensive",
|
||||||
|
"freq-league-normal": "Normal",
|
||||||
|
"freq-match-count": "Past Matches",
|
||||||
|
"freq-reasoning-title": "Analysis Details",
|
||||||
|
"freq-no-result": "No matches found meeting frequency analysis criteria. Try lowering the signal threshold.",
|
||||||
|
"freq-mode-active": "Frequency Engine active",
|
||||||
|
"ai-mode-active": "AI Engine active",
|
||||||
|
"engine-mode-label": "Engine Mode",
|
||||||
|
"engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis."
|
||||||
},
|
},
|
||||||
|
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"account-settings": "Account Settings",
|
"account-settings": "Account Settings",
|
||||||
@@ -369,15 +498,14 @@
|
|||||||
"win-rate": "Win Rate",
|
"win-rate": "Win Rate",
|
||||||
"total-profit": "Total Profit"
|
"total-profit": "Total Profit"
|
||||||
},
|
},
|
||||||
|
|
||||||
"leagues": {
|
"leagues": {
|
||||||
"title": "Leagues & Teams",
|
"title": "Leagues & Teams",
|
||||||
"countries": "Countries",
|
"countries": "Countries",
|
||||||
"leagues": "Leagues",
|
"leagues": "Leagues",
|
||||||
"countries-leagues": "Countries & Leagues",
|
"countries-leagues": "Countries & Leagues",
|
||||||
"search-at-least-2": "Type at least 2 characters to search teams."
|
"search-at-least-2": "Type at least 2 characters to search teams.",
|
||||||
|
"all": "All"
|
||||||
},
|
},
|
||||||
|
|
||||||
"h2h": {
|
"h2h": {
|
||||||
"title": "Head to Head",
|
"title": "Head to Head",
|
||||||
"team-1": "Team 1",
|
"team-1": "Team 1",
|
||||||
@@ -387,7 +515,6 @@
|
|||||||
"draws": "Draws",
|
"draws": "Draws",
|
||||||
"no-matches-found": "No head-to-head matches found between these teams."
|
"no-matches-found": "No head-to-head matches found between these teams."
|
||||||
},
|
},
|
||||||
|
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"title": "Multi-Match Analysis",
|
"title": "Multi-Match Analysis",
|
||||||
"select-matches": "Select Matches",
|
"select-matches": "Select Matches",
|
||||||
@@ -399,7 +526,6 @@
|
|||||||
"matches-analyzed": "matches analyzed",
|
"matches-analyzed": "matches analyzed",
|
||||||
"no-history": "No analysis history yet."
|
"no-history": "No analysis history yet."
|
||||||
},
|
},
|
||||||
|
|
||||||
"spor-toto": {
|
"spor-toto": {
|
||||||
"title": "Spor Toto",
|
"title": "Spor Toto",
|
||||||
"sync-bulletins": "Sync Bulletins",
|
"sync-bulletins": "Sync Bulletins",
|
||||||
@@ -427,7 +553,6 @@
|
|||||||
"rollover-stats": "Rollover Stats",
|
"rollover-stats": "Rollover Stats",
|
||||||
"prediction-generated": "Prediction generated successfully!"
|
"prediction-generated": "Prediction generated successfully!"
|
||||||
},
|
},
|
||||||
|
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
"subtitle": "Manage users, monitor predictions, and system overview.",
|
"subtitle": "Manage users, monitor predictions, and system overview.",
|
||||||
@@ -435,7 +560,9 @@
|
|||||||
"analytics": "Analytics Overview",
|
"analytics": "Analytics Overview",
|
||||||
"user-management": "User Management",
|
"user-management": "User Management",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
"premium-users": "Premium Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"subscription": "Subscription",
|
||||||
"usage-limits": "Usage Limits",
|
"usage-limits": "Usage Limits",
|
||||||
"total-users": "Total Users",
|
"total-users": "Total Users",
|
||||||
"active-users": "Active Users",
|
"active-users": "Active Users",
|
||||||
@@ -455,10 +582,31 @@
|
|||||||
"user-email": "Email",
|
"user-email": "Email",
|
||||||
"user-role": "Role",
|
"user-role": "Role",
|
||||||
"user-status": "Status",
|
"user-status": "Status",
|
||||||
"no-users": "No users found."
|
"no-users": "No users found.",
|
||||||
|
"restricted": "Restricted",
|
||||||
|
"admin-access-required": "Admin access required",
|
||||||
|
"admin-access-description": "This area is only available to superadmin accounts.",
|
||||||
|
"search-users-placeholder": "Search by email or name...",
|
||||||
|
"all-roles": "View All Roles",
|
||||||
|
"standard-user": "Standard User",
|
||||||
|
"superadmin": "System Administrator (Admin)",
|
||||||
|
"all-plans": "View All Plans",
|
||||||
|
"plan-free": "Free",
|
||||||
|
"plan-plus": "Plus Plan",
|
||||||
|
"plan-premium": "Premium Plan",
|
||||||
|
"plan-past-due": "Past Due",
|
||||||
|
"plan-cancelled": "Cancelled",
|
||||||
|
"edit-user-title": "Edit User: {email}",
|
||||||
|
"user-role-field": "User Role",
|
||||||
|
"subscription-plan-field": "Subscription Plan",
|
||||||
|
"subscription-end-date": "Subscription End Date (Optional)",
|
||||||
|
"account-active-question": "Is the account active?"
|
||||||
},
|
},
|
||||||
|
|
||||||
"common": {
|
"common": {
|
||||||
|
"limits": {
|
||||||
|
"analysis_left": "Analyses",
|
||||||
|
"out_of_analysis": "Daily analysis limit exceeded."
|
||||||
|
},
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -485,6 +633,166 @@
|
|||||||
"of": "of",
|
"of": "of",
|
||||||
"items-per-page": "Items per page",
|
"items-per-page": "Items per page",
|
||||||
"showing": "Showing",
|
"showing": "Showing",
|
||||||
"results": "results"
|
"results": "results",
|
||||||
|
"SUCCESS_USER_STATUS_UPDATED": "User status updated successfully.",
|
||||||
|
"SUCCESS_USER_ROLE_UPDATED": "User role updated successfully.",
|
||||||
|
"SUCCESS_USER_DELETED": "User deleted successfully.",
|
||||||
|
"SUCCESS_USER_LIMITS_RESET": "User limits reset successfully.",
|
||||||
|
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "User subscription updated successfully."
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"global": {
|
||||||
|
"title": "iddaai.com | AI-Powered Betting Predictions",
|
||||||
|
"description": "iddaai.com offers AI-powered betting predictions, detailed match analysis, and data-driven coupon building.",
|
||||||
|
"keywords": "betting, betting predictions, AI betting, match analysis, sure bets, football statistics"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Home",
|
||||||
|
"description": "AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons."
|
||||||
|
},
|
||||||
|
"h2h": {
|
||||||
|
"title": "Head to Head Comparison",
|
||||||
|
"description": "Compare football teams head to head. In-depth statistics, past matches, and predictions."
|
||||||
|
},
|
||||||
|
"analysis": {
|
||||||
|
"title": "Multi-Match Analysis",
|
||||||
|
"description": "Analyze multiple matches at once. Detailed statistics and AI-driven strategies."
|
||||||
|
},
|
||||||
|
"leagues": {
|
||||||
|
"title": "Leagues & Teams",
|
||||||
|
"description": "Explore football and basketball leagues, countries, and team statistics worldwide."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Admin Panel",
|
||||||
|
"description": "Admin panel for managing users and system settings."
|
||||||
|
},
|
||||||
|
"matches": {
|
||||||
|
"title": "Matches & Fixtures",
|
||||||
|
"description": "View upcoming matches, live scores, and past fixtures with AI predictions."
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About Us",
|
||||||
|
"description": "Learn more about iddaai.com, our AI technology, and how we deliver betting insights."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"description": "Your personalized dashboard for betting stats, predictions, and account overview."
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "My Profile",
|
||||||
|
"description": "Manage your user profile, subscription, and account settings."
|
||||||
|
},
|
||||||
|
"spor-toto": {
|
||||||
|
"title": "Spor Toto Predictions",
|
||||||
|
"description": "AI-powered Spor Toto predictions. Build coupons with conservative, balanced, or aggressive strategies."
|
||||||
|
},
|
||||||
|
"coupon-builder": {
|
||||||
|
"title": "AI Coupon Builder",
|
||||||
|
"description": "Automatically generate optimized betting coupons using advanced AI and statistical models."
|
||||||
|
},
|
||||||
|
"teams": {
|
||||||
|
"title": "Team Statistics",
|
||||||
|
"description": "Detailed statistics, form analysis, and predictive models for football teams."
|
||||||
|
},
|
||||||
|
"coupon-history": {
|
||||||
|
"title": "Coupon History",
|
||||||
|
"description": "Review your past betting coupons and track your performance."
|
||||||
|
},
|
||||||
|
"predictions": {
|
||||||
|
"title": "Betting Predictions",
|
||||||
|
"description": "Daily AI betting predictions, value odds, and high-confidence match tips."
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Sign Up",
|
||||||
|
"description": "Create your iddaai.com account to access AI-powered betting predictions."
|
||||||
|
},
|
||||||
|
"signin": {
|
||||||
|
"title": "Sign In",
|
||||||
|
"description": "Sign in to your iddaai.com account to access AI predictions and tools."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "Pricing — iddaai",
|
||||||
|
"description": "Explore iddaai AI-powered betting analysis plans. Free, Plus, and Premium plans available."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "Choose Your Plan",
|
||||||
|
"subtitle": "Boost your winning odds with AI-powered analyses",
|
||||||
|
"monthly": "Monthly",
|
||||||
|
"yearly": "Yearly",
|
||||||
|
"yearly-save": "Save 2 months",
|
||||||
|
"most-popular": "Most Popular",
|
||||||
|
"current-plan": "Current Plan",
|
||||||
|
"get-started": "Get Started",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"downgrade": "Downgrade",
|
||||||
|
"contact-sales": "Contact Us",
|
||||||
|
"per-month": "/mo",
|
||||||
|
"per-year": "/yr",
|
||||||
|
"free-forever": "Free forever",
|
||||||
|
"billed-yearly": "Billed yearly",
|
||||||
|
"compare-plans": "Compare Plans",
|
||||||
|
"faq-title": "Frequently Asked Questions",
|
||||||
|
"plan": {
|
||||||
|
"free": {
|
||||||
|
"name": "Free",
|
||||||
|
"description": "Get started with basic AI analyses"
|
||||||
|
},
|
||||||
|
"plus": {
|
||||||
|
"name": "Plus",
|
||||||
|
"description": "More analyses and exclusive features"
|
||||||
|
},
|
||||||
|
"premium": {
|
||||||
|
"name": "Premium",
|
||||||
|
"description": "Unlimited access and professional tools"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"daily-analyses": "Daily AI analyses",
|
||||||
|
"daily-coupons": "Daily coupons",
|
||||||
|
"basic-analysis": "Basic match analysis",
|
||||||
|
"detailed-analysis": "Detailed AI analysis",
|
||||||
|
"h2h-comparison": "H2H comparison",
|
||||||
|
"coupon-builder": "Coupon builder",
|
||||||
|
"spor-toto": "Spor Toto analysis",
|
||||||
|
"ad-free": "Ad-free experience",
|
||||||
|
"priority-support": "Priority support",
|
||||||
|
"unlimited": "Unlimited"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"q1": "Can I change my plan anytime?",
|
||||||
|
"a1": "Yes, you can upgrade or downgrade your plan anytime. Upgrades take effect immediately.",
|
||||||
|
"q2": "How does cancellation work?",
|
||||||
|
"a2": "Your access continues until the end of your current billing period. You'll automatically switch to the Free plan.",
|
||||||
|
"q3": "What payment methods are accepted?",
|
||||||
|
"a3": "You can pay securely with credit cards and debit cards. All payments are processed through Paddle.",
|
||||||
|
"q4": "Is there a trial period?",
|
||||||
|
"a4": "You can try all basic features with the Free plan. When you upgrade, you get instant access to premium features."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"title": "Subscription",
|
||||||
|
"current-plan": "Current Plan",
|
||||||
|
"plan-badge": {
|
||||||
|
"free": "Free",
|
||||||
|
"plus": "Plus",
|
||||||
|
"premium": "Premium"
|
||||||
|
},
|
||||||
|
"upgrade-cta": "Upgrade Plan",
|
||||||
|
"manage": "Manage Subscription",
|
||||||
|
"cancel": "Cancel Subscription",
|
||||||
|
"cancel-confirm-title": "Cancel Subscription",
|
||||||
|
"cancel-confirm-message": "Your access will continue until the end of your current billing period. Are you sure you want to cancel?",
|
||||||
|
"cancel-reason-placeholder": "Would you like to share your reason? (Optional)",
|
||||||
|
"cancelled-info": "Your subscription ends on {date}",
|
||||||
|
"next-billing": "Next billing date",
|
||||||
|
"usage": {
|
||||||
|
"title": "Daily Usage",
|
||||||
|
"analyses": "AI Analyses",
|
||||||
|
"coupons": "Coupons",
|
||||||
|
"of": "/",
|
||||||
|
"remaining": "remaining"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refund-policy": "Refund Policy"
|
||||||
}
|
}
|
||||||
+351
-31
@@ -5,8 +5,8 @@
|
|||||||
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
|
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
|
||||||
"artificial-intelligence": "Yapay Zeka",
|
"artificial-intelligence": "Yapay Zeka",
|
||||||
"error": {
|
"error": {
|
||||||
"not-found": "Aradığınız sayfa bulunamadı.",
|
|
||||||
"404": "404",
|
"404": "404",
|
||||||
|
"not-found": "Aradığınız sayfa bulunamadı.",
|
||||||
"back-to-home": "Ana sayfaya dön",
|
"back-to-home": "Ana sayfaya dön",
|
||||||
"generic": "Beklenmeyen bir hata oluştu.",
|
"generic": "Beklenmeyen bir hata oluştu.",
|
||||||
"network": "Ağ hatası. Lütfen bağlantınızı kontrol edin.",
|
"network": "Ağ hatası. Lütfen bağlantınızı kontrol edin.",
|
||||||
@@ -34,7 +34,11 @@
|
|||||||
"forgot-password": "Şifremi Unuttum?",
|
"forgot-password": "Şifremi Unuttum?",
|
||||||
"or-continue-with": "Veya şununla devam edin",
|
"or-continue-with": "Veya şununla devam edin",
|
||||||
"logging-in": "Giriş yapılıyor...",
|
"logging-in": "Giriş yapılıyor...",
|
||||||
"registering": "Hesap oluşturuluyor..."
|
"registering": "Hesap oluşturuluyor...",
|
||||||
|
"login-success": "Giriş başarılı!",
|
||||||
|
"register-success": "Kayıt başarılı!",
|
||||||
|
"login-required-title": "Giriş Yapmanız Gerekiyor",
|
||||||
|
"login-required-message": "Maç analizlerini görüntülemek için lütfen giriş yapın veya hesap oluşturun."
|
||||||
},
|
},
|
||||||
"all-right-reserved": "Tüm hakları saklıdır.",
|
"all-right-reserved": "Tüm hakları saklıdır.",
|
||||||
"privacy-policy": "Gizlilik Politikası",
|
"privacy-policy": "Gizlilik Politikası",
|
||||||
@@ -43,7 +47,6 @@
|
|||||||
"low": "Düşük",
|
"low": "Düşük",
|
||||||
"medium": "Orta",
|
"medium": "Orta",
|
||||||
"high": "Yüksek",
|
"high": "Yüksek",
|
||||||
|
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Anasayfa",
|
"home": "Anasayfa",
|
||||||
"dashboard": "Kontrol Paneli",
|
"dashboard": "Kontrol Paneli",
|
||||||
@@ -64,13 +67,12 @@
|
|||||||
"coupons": "Kuponlar",
|
"coupons": "Kuponlar",
|
||||||
"tools": "Araçlar"
|
"tools": "Araçlar"
|
||||||
},
|
},
|
||||||
|
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero-title": "Yapay Zeka Destekli Bahis Tahminleri",
|
"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.",
|
"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",
|
"get-started": "Başla",
|
||||||
"learn-more": "Daha Fazla",
|
"learn-more": "Daha Fazla",
|
||||||
"features-title": "Neden Suggest Bet?",
|
"features-title": "Neden Iddaai?",
|
||||||
"feature-ai": "Yapay Zeka Tahminleri",
|
"feature-ai": "Yapay Zeka Tahminleri",
|
||||||
"feature-ai-desc": "%95+ veri kalitesi puanlama ile V20 ensemble modeli tarafından desteklenmektedir.",
|
"feature-ai-desc": "%95+ veri kalitesi puanlama ile V20 ensemble modeli tarafından desteklenmektedir.",
|
||||||
"feature-value": "Değerli Bahisler",
|
"feature-value": "Değerli Bahisler",
|
||||||
@@ -84,7 +86,6 @@
|
|||||||
"stats-users": "Aktif Kullanıcı",
|
"stats-users": "Aktif Kullanıcı",
|
||||||
"stats-matches": "Analiz Edilen Maç"
|
"stats-matches": "Analiz Edilen Maç"
|
||||||
},
|
},
|
||||||
|
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Kontrol Paneli",
|
"title": "Kontrol Paneli",
|
||||||
"welcome": "Tekrar hoş geldiniz",
|
"welcome": "Tekrar hoş geldiniz",
|
||||||
@@ -97,7 +98,6 @@
|
|||||||
"no-matches": "Bugün maç bulunmuyor.",
|
"no-matches": "Bugün maç bulunmuyor.",
|
||||||
"no-predictions": "Tahmin bulunmuyor."
|
"no-predictions": "Tahmin bulunmuyor."
|
||||||
},
|
},
|
||||||
|
|
||||||
"matches": {
|
"matches": {
|
||||||
"title": "Maçlar",
|
"title": "Maçlar",
|
||||||
"filter-sport": "Spor",
|
"filter-sport": "Spor",
|
||||||
@@ -119,9 +119,44 @@
|
|||||||
"recent-matches": "Son Maçlar",
|
"recent-matches": "Son Maçlar",
|
||||||
"home-team": "Ev Sahibi",
|
"home-team": "Ev Sahibi",
|
||||||
"away-team": "Deplasman",
|
"away-team": "Deplasman",
|
||||||
"vs": "vs"
|
"vs": "vs",
|
||||||
|
"referee": "Hakem",
|
||||||
|
"sidelined": "Sakatlık & Eksikler",
|
||||||
|
"injury": "Sakatlık",
|
||||||
|
"suspended": "Cezalı",
|
||||||
|
"other-reason": "Diğer",
|
||||||
|
"matches-missed": "Kaçırılan Maç",
|
||||||
|
"position": "Pozisyon",
|
||||||
|
"no-sidelined": "Eksik oyuncu bilgisi yok",
|
||||||
|
"match-events": "Maç Olayları",
|
||||||
|
"goal": "Gol",
|
||||||
|
"yellow-card": "Sarı Kart",
|
||||||
|
"red-card": "Kırmızı Kart",
|
||||||
|
"substitution": "Oyuncu Değişikliği",
|
||||||
|
"starters": "İlk 11",
|
||||||
|
"substitutes": "Yedekler",
|
||||||
|
"all-matches": "Tüm Maçlar",
|
||||||
|
"today-matches": "Bugünün Maçları",
|
||||||
|
"next-1-hour": "1 Saat İçinde",
|
||||||
|
"possession": "Topa Sahip Olma",
|
||||||
|
"shots-on-target": "İsabetli Şut",
|
||||||
|
"shots-off-target": "İsabetsiz Şut",
|
||||||
|
"total-shots": "Toplam Şut",
|
||||||
|
"total-passes": "Toplam Pas",
|
||||||
|
"corners": "Korner",
|
||||||
|
"fouls": "Faul",
|
||||||
|
"offsides": "Ofsayt",
|
||||||
|
"officials": "Hakemler",
|
||||||
|
"main-referee": "Hakem",
|
||||||
|
"assistant-referee": "Yardımcı Hakem",
|
||||||
|
"fourth-official": "Dördüncü Hakem",
|
||||||
|
"var-referee": "VAR Hakemi",
|
||||||
|
"avar-referee": "AVAR Hakemi",
|
||||||
|
"penalty": "Penaltı",
|
||||||
|
"half-time": "İlk Yarı",
|
||||||
|
"second-half": "İkinci Yarı",
|
||||||
|
"assist": "Asist"
|
||||||
},
|
},
|
||||||
|
|
||||||
"predictions": {
|
"predictions": {
|
||||||
"title": "Tahminler",
|
"title": "Tahminler",
|
||||||
"upcoming": "Yaklaşan",
|
"upcoming": "Yaklaşan",
|
||||||
@@ -143,6 +178,8 @@
|
|||||||
"bet-summary": "Bahis Özeti",
|
"bet-summary": "Bahis Özeti",
|
||||||
"expected-value": "Beklenen Değer",
|
"expected-value": "Beklenen Değer",
|
||||||
"no-predictions": "Tahmin bulunmuyor.",
|
"no-predictions": "Tahmin bulunmuyor.",
|
||||||
|
"generate": "Yapay Zeka ile Analiz Et",
|
||||||
|
"pre-match-disclaimer": "Bu analiz maç başlamadan önceki verilere dayanmaktadır.",
|
||||||
"accuracy": "Doğruluk",
|
"accuracy": "Doğruluk",
|
||||||
"total-predictions": "Toplam Tahmin",
|
"total-predictions": "Toplam Tahmin",
|
||||||
"correct-predictions": "Doğru Tahmin",
|
"correct-predictions": "Doğru Tahmin",
|
||||||
@@ -185,10 +222,10 @@
|
|||||||
"missing_total_odds": "Alt/Üst oranları eksik.",
|
"missing_total_odds": "Alt/Üst oranları eksik.",
|
||||||
"missing_spread_odds": "Handikap 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ı.",
|
"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ı.",
|
"insufficient_play_score": "Model sinyali eşiğin altında kaldı.",
|
||||||
"no_ev_edge_minimum_stake": "Güvenlik kontrollerini geçti ancak matematik avantaj yok — minimum bahis uygulandı."
|
"no_ev_edge_minimum_stake": "Güvenlik kontrollerini geçti ancak matematik avantaj yok — minimum bahis uygulandı."
|
||||||
},
|
},
|
||||||
"ev-edge": "EV Edge",
|
"ev-edge": "Teorik Avantaj",
|
||||||
"implied-prob": "Piyasa Olasılığı",
|
"implied-prob": "Piyasa Olasılığı",
|
||||||
"model-prob": "Model Olasılığı",
|
"model-prob": "Model Olasılığı",
|
||||||
"kelly-stake": "Kelly Bahis",
|
"kelly-stake": "Kelly Bahis",
|
||||||
@@ -217,27 +254,48 @@
|
|||||||
"HTFT": "İlk Yarı / Maç Sonu",
|
"HTFT": "İlk Yarı / Maç Sonu",
|
||||||
"HT/FT": "İlk Yarı / Maç Sonu",
|
"HT/FT": "İlk Yarı / Maç Sonu",
|
||||||
"OE": "Tek / Çift",
|
"OE": "Tek / Çift",
|
||||||
"HT_OU05": "İlk Yarı 0.5 Gol"
|
"HT_OU05": "İlk Yarı 0.5 Gol",
|
||||||
|
"HT_OU15": "İlk Yarı 1.5 Gol",
|
||||||
|
"CARDS": "Kartlar 4.5",
|
||||||
|
"HCAP": "Handikap Sonucu"
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"summary-title": "Tahmin Özeti",
|
"summary-title": "Tahmin Özeti",
|
||||||
"summary-info": "Önce neyin oynanabileceğini, sonra bunun neden öne çıktığını gösterir.",
|
"summary-info": "Model sinyallerini ve belirsizlikleri sade şekilde gösterir.",
|
||||||
"main-recommendation": "Ana Öneri",
|
"model-signal-disclaimer": "Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.",
|
||||||
|
"main-recommendation": "Öne Çıkan Sinyal",
|
||||||
"best-market-copy": "marketinde en güçlü seçim.",
|
"best-market-copy": "marketinde en güçlü seçim.",
|
||||||
"confidence-label": "Güven",
|
"confidence-label": "Güven",
|
||||||
|
"confidence-interval": "Güven Aralığı",
|
||||||
|
"confidence-interval-warning": "Güven aralığı geniş. Sinyal olsa bile tek başına oynanması önerilmez.",
|
||||||
|
"confidence-band": "Band",
|
||||||
"odds-label": "Oran",
|
"odds-label": "Oran",
|
||||||
"edge-label": "Beklenen Avantaj (Edge)",
|
"edge-label": "Teorik Avantaj",
|
||||||
"edge-info": "Edge, model olasılığı ile piyasa olasılığı arasındaki farktır. Pozitifse model bu oranı avantajlı görüyor demektir.",
|
"edge-info": "Model olasılığı ile piyasa olasılığı arasındaki teorik farktır; tutma garantisi veya kesin kazanç beklentisi değildir.",
|
||||||
"stake-label": "Önerilen Miktar (Stake)",
|
"stake-label": "Önerilen Miktar (Stake)",
|
||||||
"stake-label-short": "Bahis Miktarı",
|
"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.",
|
"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ı",
|
"play-score-label": "Model Sinyali",
|
||||||
"playability-label": "Oynanabilirlik",
|
"playability-label": "Model sinyali",
|
||||||
"quick-read": "Hızlı yorum",
|
"quick-read": "Hızlı yorum",
|
||||||
"lineup-source": "Kadronun Kaynağı",
|
"lineup-source": "Kadronun Kaynağı",
|
||||||
|
"lineup-confirmed-live": "Onaylı ilk 11",
|
||||||
|
"lineup-probable-xi": "Muhtemel ilk 11",
|
||||||
|
"unknown": "Bilinmiyor",
|
||||||
"model-label": "Model",
|
"model-label": "Model",
|
||||||
"engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.",
|
"engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.",
|
||||||
"best-single-pick": "En İyi Tekli Seçim",
|
"engine-team-football": "Takım Gücü",
|
||||||
|
"engine-team-basketball": "Takım Formu",
|
||||||
|
"engine-player-football": "Oyuncu Etkisi",
|
||||||
|
"engine-player-basketball": "Kadro Etkisi",
|
||||||
|
"engine-odds": "Oran Analizi",
|
||||||
|
"engine-referee-football": "Hakem Etkisi",
|
||||||
|
"engine-referee-basketball": "Yardımcı Sinyaller",
|
||||||
|
"engine-label-high": "Yüksek",
|
||||||
|
"engine-label-medium": "Orta",
|
||||||
|
"engine-label-low": "Düşük",
|
||||||
|
"engine-label-very-low": "Çok Düşük",
|
||||||
|
"best-single-pick": "En Güçlü Sinyal",
|
||||||
"alternative-markets": "Alternatif Marketler",
|
"alternative-markets": "Alternatif Marketler",
|
||||||
"alternative-markets-info": "Ana tahmin dışındaki seçenekler.",
|
"alternative-markets-info": "Ana tahmin dışındaki seçenekler.",
|
||||||
"alternative": "Alternatif",
|
"alternative": "Alternatif",
|
||||||
@@ -246,10 +304,50 @@
|
|||||||
"all-markets-info": "Bütün seçenekleri tek tabloda karşılaştırır.",
|
"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ı.",
|
"market-board-info": "Modelin her markette gördüğü olasılık dağılımı.",
|
||||||
"bet-advice-info": "Modelin nihai aksiyon önerisi.",
|
"bet-advice-info": "Modelin nihai aksiyon önerisi.",
|
||||||
"recommended-stake-inline": "Önerilen miktar"
|
"recommended-stake-inline": "Önerilen miktar",
|
||||||
|
"model-probability-short": "Model",
|
||||||
|
"market-probability-short": "Piyasa",
|
||||||
|
"theoretical-edge-inline": "Teorik avantaj",
|
||||||
|
"playable": "Oynanabilir",
|
||||||
|
"risky": "Riskli",
|
||||||
|
"hit-probability": "Tutma Olasılığı",
|
||||||
|
"calibrated-confidence": "Kalibre Güven",
|
||||||
|
"score-scenario-football": "Skor Senaryosu",
|
||||||
|
"score-scenario-basketball": "Sayı Senaryosu",
|
||||||
|
"score-scenario-info-football": "Beklenen skor ve en olası senaryolar.",
|
||||||
|
"score-scenario-info-basketball": "Beklenen sayı dağılımı ve en olası maç senaryoları.",
|
||||||
|
"full-time-football": "Maç Sonu",
|
||||||
|
"full-time-basketball": "Maç Sonu Sayı",
|
||||||
|
"half-time-football": "İlk Yarı",
|
||||||
|
"half-time-basketball": "İlk Yarı Sayı",
|
||||||
|
"expected-total-football": "Toplam xG",
|
||||||
|
"expected-total-basketball": "Beklenen Toplam Sayı",
|
||||||
|
"live": "CANLI",
|
||||||
|
"pre-match-prediction": "Maç öncesi tahmin",
|
||||||
|
"prediction-contradictions": "Tahmin Çelişkileri",
|
||||||
|
"data-quality": "Veri Kalitesi",
|
||||||
|
"data-quality-info": "Kadro, oran ve maç verisinin ne kadar güvenilir olduğu.",
|
||||||
|
"risk-info": "Sürpriz ihtimali ve belirsizlik seviyesi.",
|
||||||
|
"risk-commentary": "Risk Yorumu",
|
||||||
|
"risk-default-comment": "Model bu maçta ekstra dikkat istiyor.",
|
||||||
|
"surprise-score": "Sürpriz skoru",
|
||||||
|
"match-commentary-title": "Maç Yorumu",
|
||||||
|
"match-commentary-info": "Modelin maç hakkındaki insan okunabilir özeti.",
|
||||||
|
"reasoning-info": "Modelin bu maçı neden bu şekilde okuduğunun üst seviye özeti.",
|
||||||
|
"bet-advice-play": "OYNA",
|
||||||
|
"bet-advice-pass": "OYNAMA",
|
||||||
|
"signal-tier-core": "Çekirdek",
|
||||||
|
"signal-tier-value": "Değer",
|
||||||
|
"signal-tier-lean": "Yorum",
|
||||||
|
"signal-tier-longshot": "Sürpriz",
|
||||||
|
"signal-tier-pass": "Pas",
|
||||||
|
"confidence-high": "Yüksek",
|
||||||
|
"confidence-medium": "Orta",
|
||||||
|
"confidence-low": "Düşük",
|
||||||
|
"confidence-unknown": "Belirsiz",
|
||||||
|
"info": "Bilgi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"coupons": {
|
"coupons": {
|
||||||
"title": "Kupon Oluşturucu",
|
"title": "Kupon Oluşturucu",
|
||||||
"builder-title": "Kupon Oluşturucu",
|
"builder-title": "Kupon Oluşturucu",
|
||||||
@@ -295,6 +393,8 @@
|
|||||||
"coupon": "Kupon",
|
"coupon": "Kupon",
|
||||||
"candidate-match-count": "Aday Maç",
|
"candidate-match-count": "Aday Maç",
|
||||||
"candidate-match-count-help": "Kupon oluşturmak için şu anda uygun olan yaklaşan futbol maçı sayısı.",
|
"candidate-match-count-help": "Kupon oluşturmak için şu anda uygun olan yaklaşan futbol maçı sayısı.",
|
||||||
|
"finished-match-count": "Biten Maç",
|
||||||
|
"finished-match-count-help": "Biten futbol maçları için isteğe bağlı referans listesi. Bunlar kupon tahmininde asla kullanılmaz.",
|
||||||
"selected-match-count": "Seçilen Maç",
|
"selected-match-count": "Seçilen Maç",
|
||||||
"selected-match-count-help": "Maçları siz seçerseniz AI kuponu sadece bu havuzdan üretir.",
|
"selected-match-count-help": "Maçları siz seçerseniz AI kuponu sadece bu havuzdan üretir.",
|
||||||
"suggested-bet-count": "Önerilen Bahis",
|
"suggested-bet-count": "Önerilen Bahis",
|
||||||
@@ -306,12 +406,24 @@
|
|||||||
"candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış",
|
"candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış",
|
||||||
"match-count-suffix": "maç",
|
"match-count-suffix": "maç",
|
||||||
"upcoming-badge": "Yaklaşan",
|
"upcoming-badge": "Yaklaşan",
|
||||||
|
"upcoming-reference": "Yaklaşan havuz",
|
||||||
|
"finished-badge": "Bitti",
|
||||||
|
"prediction-locked": "Tahmine Kapalı",
|
||||||
|
"read-only-short": "Salt okunur",
|
||||||
"selected-short": "Seçildi",
|
"selected-short": "Seçildi",
|
||||||
"select-match": "Seç",
|
"select-match": "Seç",
|
||||||
|
"match-state": "Maç Durumu",
|
||||||
"selection-mode": "AI Havuzu",
|
"selection-mode": "AI Havuzu",
|
||||||
"manual-pool": "Manuel havuz",
|
"manual-pool": "Manuel havuz",
|
||||||
"auto-pool": "Otomatik havuz",
|
"auto-pool": "Otomatik havuz",
|
||||||
|
"finished-reference-only": "Sadece referans",
|
||||||
"no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.",
|
"no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.",
|
||||||
|
"finished-matches-title": "Biten Maçlar",
|
||||||
|
"finished-matches-help": "Bu maçlar sadece referans için gösterilir. Seçilemezler ve kupon tahmini oluşturulmadan önce backend tarafından filtrelenirler.",
|
||||||
|
"finished-matches-subtitle": "İsteğe bağlı arşiv görünümü. Skorlar ve maç sonu istatistikleri kupon tahmin akışına gönderilmez.",
|
||||||
|
"show-finished-matches": "Biten maçları göster",
|
||||||
|
"hide-finished-matches": "Biten maçları gizle",
|
||||||
|
"no-finished-matches": "Geçerli görünüm için biten futbol maçı bulunamadı.",
|
||||||
"manual-selection-active": "AI yalnızca aşağıda seçtiğiniz maçları kullanacak.",
|
"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.",
|
"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-panel-title": "Seçili Maç Havuzu",
|
||||||
@@ -331,7 +443,36 @@
|
|||||||
"risk-label": "Risk",
|
"risk-label": "Risk",
|
||||||
"data-quality-label": "Veri Kalitesi",
|
"data-quality-label": "Veri Kalitesi",
|
||||||
"rejected-matches-title": "Elenen Maçlar",
|
"rejected-matches-title": "Elenen Maçlar",
|
||||||
"no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın."
|
"no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın.",
|
||||||
|
"freq-engine-title": "Frekans Motoru",
|
||||||
|
"freq-engine-subtitle": "Takımların oran bandına göre tarihsel performansını analiz eder. AI modeli yerine istatistiksel veritabanı taraması kullanır.",
|
||||||
|
"freq-suggest": "Frekans Kuponu Oluştur",
|
||||||
|
"freq-suggest-loading": "Frekans analizi çalışıyor...",
|
||||||
|
"freq-min-signal": "Minimum Sinyal",
|
||||||
|
"freq-min-signal-help": "Kombinasyon sinyal eşiği (0.50-0.99). Düşürürseniz daha fazla maç bulunur, yükseltirseniz daha kesin sonuçlar gelir.",
|
||||||
|
"freq-markets": "Marketler",
|
||||||
|
"freq-markets-help": "Analiz edilecek marketleri seçin. Boş bırakırsanız tüm marketler taranır.",
|
||||||
|
"freq-ev-label": "Beklenen Değer (EV)",
|
||||||
|
"freq-ev-help": "Hit Rate × Toplam Oran. 1.0'ın üzeri +EV (karlı) anlamına gelir.",
|
||||||
|
"freq-hit-rate": "Tahmini İsabet",
|
||||||
|
"freq-hit-rate-help": "Tüm bahislerin birleşik tarihsel isabet oranı.",
|
||||||
|
"freq-ev-positive": "+EV Pozitif",
|
||||||
|
"freq-ev-negative": "EV Negatif",
|
||||||
|
"freq-home-signal": "Ev Sinyali",
|
||||||
|
"freq-away-signal": "Dep Sinyali",
|
||||||
|
"freq-combined-signal": "Kombine Sinyal",
|
||||||
|
"freq-odds-band": "Oran Bandı",
|
||||||
|
"freq-league-profile": "Lig Profili",
|
||||||
|
"freq-league-golcu": "Golcü",
|
||||||
|
"freq-league-defansif": "Defansif",
|
||||||
|
"freq-league-normal": "Normal",
|
||||||
|
"freq-match-count": "Geçmiş Maç",
|
||||||
|
"freq-reasoning-title": "Analiz Detayları",
|
||||||
|
"freq-no-result": "Frekans analizine uygun yeterli maç bulunamadı. Sinyal eşiğini düşürmeyi deneyin.",
|
||||||
|
"freq-mode-active": "Frekans Motoru aktif",
|
||||||
|
"ai-mode-active": "AI Motoru aktif",
|
||||||
|
"engine-mode-label": "Motor Seçimi",
|
||||||
|
"engine-mode-help": "AI: Gemini tabanlı yapay zeka tahmini. Frekans: Veritabanı tabanlı istatistiksel analiz."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -357,15 +498,14 @@
|
|||||||
"win-rate": "Kazanma Oranı",
|
"win-rate": "Kazanma Oranı",
|
||||||
"total-profit": "Toplam Kâr"
|
"total-profit": "Toplam Kâr"
|
||||||
},
|
},
|
||||||
|
|
||||||
"leagues": {
|
"leagues": {
|
||||||
"title": "Ligler & Takımlar",
|
"title": "Ligler & Takımlar",
|
||||||
"countries": "Ülkeler",
|
"countries": "Ülkeler",
|
||||||
"leagues": "Ligler",
|
"leagues": "Ligler",
|
||||||
"countries-leagues": "Ülkeler & Ligler",
|
"countries-leagues": "Ülkeler & Ligler",
|
||||||
"search-at-least-2": "Takım aramak için en az 2 karakter yazın."
|
"search-at-least-2": "Takım aramak için en az 2 karakter yazın.",
|
||||||
|
"all": "Tümü"
|
||||||
},
|
},
|
||||||
|
|
||||||
"h2h": {
|
"h2h": {
|
||||||
"title": "Karşılıklı Karşılaşma",
|
"title": "Karşılıklı Karşılaşma",
|
||||||
"team-1": "Takım 1",
|
"team-1": "Takım 1",
|
||||||
@@ -375,7 +515,6 @@
|
|||||||
"draws": "Beraberlikler",
|
"draws": "Beraberlikler",
|
||||||
"no-matches-found": "Bu takımlar arasında karşılıklı maç bulunamadı."
|
"no-matches-found": "Bu takımlar arasında karşılıklı maç bulunamadı."
|
||||||
},
|
},
|
||||||
|
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"title": "Çoklu Maç Analizi",
|
"title": "Çoklu Maç Analizi",
|
||||||
"select-matches": "Maç Seç",
|
"select-matches": "Maç Seç",
|
||||||
@@ -387,7 +526,6 @@
|
|||||||
"matches-analyzed": "maç analiz edildi",
|
"matches-analyzed": "maç analiz edildi",
|
||||||
"no-history": "Henüz analiz geçmişi yok."
|
"no-history": "Henüz analiz geçmişi yok."
|
||||||
},
|
},
|
||||||
|
|
||||||
"spor-toto": {
|
"spor-toto": {
|
||||||
"title": "Spor Toto",
|
"title": "Spor Toto",
|
||||||
"sync-bulletins": "Bültenleri Senkronize Et",
|
"sync-bulletins": "Bültenleri Senkronize Et",
|
||||||
@@ -415,7 +553,6 @@
|
|||||||
"rollover-stats": "Devir İstatistikleri",
|
"rollover-stats": "Devir İstatistikleri",
|
||||||
"prediction-generated": "Tahmin başarıyla oluşturuldu!"
|
"prediction-generated": "Tahmin başarıyla oluşturuldu!"
|
||||||
},
|
},
|
||||||
|
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Yönetim Paneli",
|
"title": "Yönetim Paneli",
|
||||||
"subtitle": "Kullanıcıları yönetin, tahminleri takip edin ve sistemi izleyin.",
|
"subtitle": "Kullanıcıları yönetin, tahminleri takip edin ve sistemi izleyin.",
|
||||||
@@ -423,7 +560,9 @@
|
|||||||
"analytics": "Analitik Genel Bakış",
|
"analytics": "Analitik Genel Bakış",
|
||||||
"user-management": "Kullanıcı Yönetimi",
|
"user-management": "Kullanıcı Yönetimi",
|
||||||
"users": "Kullanıcılar",
|
"users": "Kullanıcılar",
|
||||||
|
"premium-users": "Premium Kullanıcı",
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
|
"subscription": "Abonelik",
|
||||||
"usage-limits": "Kullanım Limitleri",
|
"usage-limits": "Kullanım Limitleri",
|
||||||
"total-users": "Toplam Kullanıcı",
|
"total-users": "Toplam Kullanıcı",
|
||||||
"active-users": "Aktif Kullanıcı",
|
"active-users": "Aktif Kullanıcı",
|
||||||
@@ -443,10 +582,31 @@
|
|||||||
"user-email": "E-Posta",
|
"user-email": "E-Posta",
|
||||||
"user-role": "Rol",
|
"user-role": "Rol",
|
||||||
"user-status": "Durum",
|
"user-status": "Durum",
|
||||||
"no-users": "Kullanıcı bulunamadı."
|
"no-users": "Kullanıcı bulunamadı.",
|
||||||
|
"restricted": "Kısıtlı",
|
||||||
|
"admin-access-required": "Admin erişimi gerekli",
|
||||||
|
"admin-access-description": "Bu alan yalnızca superadmin hesapları tarafından kullanılabilir.",
|
||||||
|
"search-users-placeholder": "E-posta veya isim ara...",
|
||||||
|
"all-roles": "Tüm Rolleri Gör",
|
||||||
|
"standard-user": "Standart Kullanıcı",
|
||||||
|
"superadmin": "Sistem Yöneticisi (Admin)",
|
||||||
|
"all-plans": "Tüm Paketleri Gör",
|
||||||
|
"plan-free": "Ücretsiz (Free)",
|
||||||
|
"plan-plus": "Plus Paketi",
|
||||||
|
"plan-premium": "Premium Paketi",
|
||||||
|
"plan-past-due": "Ödeme Gecikti (Past Due)",
|
||||||
|
"plan-cancelled": "İptal Edildi (Cancelled)",
|
||||||
|
"edit-user-title": "Kullanıcı Düzenle: {email}",
|
||||||
|
"user-role-field": "Kullanıcı Rolü",
|
||||||
|
"subscription-plan-field": "Abonelik Paketi",
|
||||||
|
"subscription-end-date": "Abonelik Bitiş Tarihi (Opsiyonel)",
|
||||||
|
"account-active-question": "Hesap Aktif mi?"
|
||||||
},
|
},
|
||||||
|
|
||||||
"common": {
|
"common": {
|
||||||
|
"limits": {
|
||||||
|
"analysis_left": "Analiz",
|
||||||
|
"out_of_analysis": "Günlük analiz limitiniz doldu."
|
||||||
|
},
|
||||||
"loading": "Yükleniyor...",
|
"loading": "Yükleniyor...",
|
||||||
"save": "Kaydet",
|
"save": "Kaydet",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
@@ -473,6 +633,166 @@
|
|||||||
"of": "/",
|
"of": "/",
|
||||||
"items-per-page": "Sayfa başına öğe",
|
"items-per-page": "Sayfa başına öğe",
|
||||||
"showing": "Gösterilen",
|
"showing": "Gösterilen",
|
||||||
"results": "sonuç"
|
"results": "sonuç",
|
||||||
|
"SUCCESS_USER_STATUS_UPDATED": "Kullanıcı durumu başarıyla güncellendi.",
|
||||||
|
"SUCCESS_USER_ROLE_UPDATED": "Kullanıcı rolü başarıyla güncellendi.",
|
||||||
|
"SUCCESS_USER_DELETED": "Kullanıcı başarıyla silindi.",
|
||||||
|
"SUCCESS_USER_LIMITS_RESET": "Kullanıcı limitleri başarıyla sıfırlandı.",
|
||||||
|
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "Kullanıcı aboneliği başarıyla güncellendi."
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"global": {
|
||||||
|
"title": "iddaai.com | Yapay Zeka İddaa Tahminleri",
|
||||||
|
"description": "iddaai.com yapay zeka destekli iddaa tahminleri, detaylı maç analizleri ve veriye dayalı kupon oluşturma hizmeti sunar.",
|
||||||
|
"keywords": "iddaa, iddaa tahminleri, yapay zeka iddaa, maç analizi, banko kuponlar, futbol istatistikleri"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Ana Sayfa",
|
||||||
|
"description": "Yapay zeka destekli iddaa tahminleri. Maçları analiz edin, değerli bahisleri keşfedin ve kazandıran kuponlar oluşturun."
|
||||||
|
},
|
||||||
|
"h2h": {
|
||||||
|
"title": "Takım Karşılaştırma (H2H)",
|
||||||
|
"description": "Futbol takımlarını birebir karşılaştırın. Derinlemesine istatistikler, geçmiş maçlar ve tahminler."
|
||||||
|
},
|
||||||
|
"analysis": {
|
||||||
|
"title": "Çoklu Maç Analizi",
|
||||||
|
"description": "Aynı anda birden fazla maçı analiz edin. Detaylı istatistikler ve yapay zeka ile stratejiler geliştirin."
|
||||||
|
},
|
||||||
|
"leagues": {
|
||||||
|
"title": "Ligler ve Takımlar",
|
||||||
|
"description": "Dünya çapındaki futbol ve basketbol liglerini, ülkeleri ve takım istatistiklerini inceleyin."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Yönetici Paneli",
|
||||||
|
"description": "Kullanıcıları ve sistem ayarlarını yönetmek için yönetici paneli."
|
||||||
|
},
|
||||||
|
"matches": {
|
||||||
|
"title": "Maçlar ve Fikstür",
|
||||||
|
"description": "Yaklaşan maçları, canlı skorları ve yapay zeka tahminleriyle geçmiş fikstürleri görüntüleyin."
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Hakkımızda",
|
||||||
|
"description": "iddaai.com, yapay zeka teknolojimiz ve bahis öngörülerini nasıl sağladığımız hakkında daha fazla bilgi edinin."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Kullanıcı Paneli",
|
||||||
|
"description": "Bahis istatistikleri, tahminler ve hesaba genel bakış için kişiselleştirilmiş paneliniz."
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profilim",
|
||||||
|
"description": "Kullanıcı profilinizi, aboneliğinizi ve hesap ayarlarınızı yönetin."
|
||||||
|
},
|
||||||
|
"spor-toto": {
|
||||||
|
"title": "Spor Toto Tahminleri",
|
||||||
|
"description": "Yapay zeka destekli Spor Toto tahminleri. Muhafazakar, dengeli veya agresif stratejilerle kuponlar oluşturun."
|
||||||
|
},
|
||||||
|
"coupon-builder": {
|
||||||
|
"title": "Yapay Zeka Kupon Oluşturucu",
|
||||||
|
"description": "Gelişmiş yapay zeka ve istatistiksel modelleri kullanarak otomatik olarak optimize edilmiş bahis kuponları oluşturun."
|
||||||
|
},
|
||||||
|
"teams": {
|
||||||
|
"title": "Takım İstatistikleri",
|
||||||
|
"description": "Futbol takımları için detaylı istatistikler, form durumları ve tahmine dayalı modeller."
|
||||||
|
},
|
||||||
|
"coupon-history": {
|
||||||
|
"title": "Kupon Geçmişi",
|
||||||
|
"description": "Geçmişte oluşturduğunuz bahis kuponlarınızı ve performansınızı inceleyin."
|
||||||
|
},
|
||||||
|
"predictions": {
|
||||||
|
"title": "İddaa Tahminleri",
|
||||||
|
"description": "Günlük yapay zeka iddaa tahminleri, değerli oranlar ve yüksek güvenilirlikli maç tüyoları."
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Kayıt Ol",
|
||||||
|
"description": "Yapay zeka tahminlerine erişmek için iddaai.com hesabınızı oluşturun."
|
||||||
|
},
|
||||||
|
"signin": {
|
||||||
|
"title": "Giriş Yap",
|
||||||
|
"description": "Yapay zeka tahminlerine ve araçlarına erişmek için iddaai.com hesabınıza giriş yapın."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "Fiyatlandırma — iddaai",
|
||||||
|
"description": "iddaai AI destekli iddaa analiz planlarını keşfedin. Ücretsiz, Plus ve Premium planlar."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "Planınızı Seçin",
|
||||||
|
"subtitle": "AI destekli analizlerle kazanma şansınızı artırın",
|
||||||
|
"monthly": "Aylık",
|
||||||
|
"yearly": "Yıllık",
|
||||||
|
"yearly-save": "2 ay tasarruf",
|
||||||
|
"most-popular": "En Popüler",
|
||||||
|
"current-plan": "Mevcut Plan",
|
||||||
|
"get-started": "Başla",
|
||||||
|
"upgrade": "Yükselt",
|
||||||
|
"downgrade": "Düşür",
|
||||||
|
"contact-sales": "Bize Ulaşın",
|
||||||
|
"per-month": "/ay",
|
||||||
|
"per-year": "/yıl",
|
||||||
|
"free-forever": "Sonsuza kadar ücretsiz",
|
||||||
|
"billed-yearly": "Yıllık faturalandırılır",
|
||||||
|
"compare-plans": "Planları Karşılaştır",
|
||||||
|
"faq-title": "Sıkça Sorulan Sorular",
|
||||||
|
"plan": {
|
||||||
|
"free": {
|
||||||
|
"name": "Ücretsiz",
|
||||||
|
"description": "Temel AI analizleri ile başlayın"
|
||||||
|
},
|
||||||
|
"plus": {
|
||||||
|
"name": "Plus",
|
||||||
|
"description": "Daha fazla analiz ve özel özellikler"
|
||||||
|
},
|
||||||
|
"premium": {
|
||||||
|
"name": "Premium",
|
||||||
|
"description": "Sınırsız erişim ve profesyonel araçlar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"daily-analyses": "Günlük AI analiz",
|
||||||
|
"daily-coupons": "Günlük kupon",
|
||||||
|
"basic-analysis": "Temel maç analizi",
|
||||||
|
"detailed-analysis": "Detaylı AI analizi",
|
||||||
|
"h2h-comparison": "H2H karşılaştırma",
|
||||||
|
"coupon-builder": "Kupon oluşturucu",
|
||||||
|
"spor-toto": "Spor Toto analizi",
|
||||||
|
"ad-free": "Reklamsız deneyim",
|
||||||
|
"priority-support": "Öncelikli destek",
|
||||||
|
"unlimited": "Sınırsız"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"q1": "Planımı istediğim zaman değiştirebilir miyim?",
|
||||||
|
"a1": "Evet, planınızı istediğiniz zaman yükseltebilir veya düşürebilirsiniz. Yükseltmeler anında aktif olur.",
|
||||||
|
"q2": "İptal nasıl çalışır?",
|
||||||
|
"a2": "Mevcut fatura döneminizin sonuna kadar erişiminiz devam eder. Otomatik olarak Ücretsiz plana geçersiniz.",
|
||||||
|
"q3": "Ödeme yöntemleri nelerdir?",
|
||||||
|
"a3": "Kredi kartı ve banka kartı ile güvenli ödeme yapabilirsiniz. Tüm ödemeler Paddle altyapısı ile işlenir.",
|
||||||
|
"q4": "Deneme süresi var mı?",
|
||||||
|
"a4": "Ücretsiz plan ile tüm temel özellikleri deneyebilirsiniz. Yükseltme yaptığınızda anında premium özelliklere erişirsiniz."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"title": "Abonelik Bilgileri",
|
||||||
|
"current-plan": "Mevcut Plan",
|
||||||
|
"plan-badge": {
|
||||||
|
"free": "Ücretsiz",
|
||||||
|
"plus": "Plus",
|
||||||
|
"premium": "Premium"
|
||||||
|
},
|
||||||
|
"upgrade-cta": "Planı Yükselt",
|
||||||
|
"manage": "Aboneliği Yönet",
|
||||||
|
"cancel": "Aboneliği İptal Et",
|
||||||
|
"cancel-confirm-title": "Aboneliği İptal Et",
|
||||||
|
"cancel-confirm-message": "Mevcut fatura döneminizin sonuna kadar erişiminiz devam edecek. İptal etmek istediğinizden emin misiniz?",
|
||||||
|
"cancel-reason-placeholder": "İptal nedeninizi paylaşır mısınız? (Opsiyonel)",
|
||||||
|
"cancelled-info": "Aboneliğiniz {date} tarihinde sona erecek",
|
||||||
|
"next-billing": "Sonraki fatura tarihi",
|
||||||
|
"usage": {
|
||||||
|
"title": "Günlük Kullanım",
|
||||||
|
"analyses": "AI Analiz",
|
||||||
|
"coupons": "Kupon",
|
||||||
|
"of": "/",
|
||||||
|
"remaining": "kalan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refund-policy": "İade Politikası"
|
||||||
}
|
}
|
||||||
+29
-2
@@ -2,16 +2,43 @@ import type { NextConfig } from "next";
|
|||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: "standalone",
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@chakra-ui/react"],
|
optimizePackageImports: ["@chakra-ui/react"],
|
||||||
},
|
},
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{
|
||||||
|
key: "Strict-Transport-Security",
|
||||||
|
value: "max-age=31536000; includeSubDomains",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (!apiUrl) {
|
||||||
|
throw new Error("url is not defined");
|
||||||
|
}
|
||||||
|
// Remove the trailing /api to map uploads from the base backend url
|
||||||
|
const backendUrl = apiUrl.replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/backend/:path*",
|
source: "/api/backend/:path*",
|
||||||
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3005/api'}/:path*`,
|
destination: `${apiUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/uploads/:path*",
|
||||||
|
destination: `${backendUrl}/uploads/:path*`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+209
-180
@@ -1,27 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "Suggest-Bet-FE-v2",
|
"name": "iddaai-fe",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "Suggest-Bet-FE-v2",
|
"name": "iddaai-fe",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^3.28.0",
|
"@chakra-ui/react": "^3.28.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@paddle/paddle-js": "^1.6.4",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
"aos": "^2.3.4",
|
"aos": "^2.3.4",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"framer-motion": "^12.34.1",
|
"framer-motion": "^12.34.1",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"next": "16.0.0",
|
"next": "^16.2.5",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
@@ -147,6 +149,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -515,6 +518,7 @@
|
|||||||
"version": "3.33.0",
|
"version": "3.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.33.0.tgz",
|
||||||
"integrity": "sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==",
|
"integrity": "sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ark-ui/react": "^5.31.0",
|
"@ark-ui/react": "^5.31.0",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
@@ -625,6 +629,7 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/memoize": "^0.9.0"
|
"@emotion/memoize": "^0.9.0"
|
||||||
}
|
}
|
||||||
@@ -638,6 +643,7 @@
|
|||||||
"version": "11.14.0",
|
"version": "11.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -1276,51 +1282,30 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@formatjs/ecma402-abstract": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"@formatjs/fast-memoize": "3.1.0",
|
|
||||||
"@formatjs/intl-localematcher": "0.8.1",
|
|
||||||
"decimal.js": "^10.6.0",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@formatjs/fast-memoize": {
|
"node_modules/@formatjs/fast-memoize": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz",
|
||||||
"integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==",
|
"integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA=="
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@formatjs/icu-messageformat-parser": {
|
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||||
"version": "3.5.1",
|
"version": "3.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.7.tgz",
|
||||||
"integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==",
|
"integrity": "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/ecma402-abstract": "3.1.1",
|
"@formatjs/icu-skeleton-parser": "2.1.7"
|
||||||
"@formatjs/icu-skeleton-parser": "2.1.1",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@formatjs/icu-skeleton-parser": {
|
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.7.tgz",
|
||||||
"integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==",
|
"integrity": "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA=="
|
||||||
"dependencies": {
|
|
||||||
"@formatjs/ecma402-abstract": "3.1.1",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@formatjs/intl-localematcher": {
|
"node_modules/@formatjs/intl-localematcher": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz",
|
||||||
"integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==",
|
"integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/fast-memoize": "3.1.0",
|
"@formatjs/fast-memoize": "3.1.4"
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@google/genai": {
|
"node_modules/@google/genai": {
|
||||||
@@ -1849,6 +1834,7 @@
|
|||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -1931,9 +1917,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz",
|
||||||
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA=="
|
"integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA=="
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.0",
|
||||||
@@ -1945,9 +1931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz",
|
||||||
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==",
|
"integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1960,9 +1946,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz",
|
||||||
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==",
|
"integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1975,9 +1961,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz",
|
||||||
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==",
|
"integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1990,9 +1976,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz",
|
||||||
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==",
|
"integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2005,9 +1991,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz",
|
||||||
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==",
|
"integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2020,9 +2006,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz",
|
||||||
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==",
|
"integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2035,9 +2021,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz",
|
||||||
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==",
|
"integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2050,9 +2036,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz",
|
||||||
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==",
|
"integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2108,6 +2094,12 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@paddle/paddle-js": {
|
||||||
|
"version": "1.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.6.4.tgz",
|
||||||
|
"integrity": "sha512-ncfnS6I8mCX6krZ3Sgz2iAYivGmhdI81yt9mT6prtPj4Ipd9J3M12LCJRUFL4FB7BYeeuV04c33RSEnbZUBCaA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@pandacss/is-valid-prop": {
|
"node_modules/@pandacss/is-valid-prop": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.1.tgz",
|
||||||
@@ -2403,9 +2395,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2433,9 +2425,9 @@
|
|||||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
|
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
|
||||||
},
|
},
|
||||||
"node_modules/@protobufjs/codegen": {
|
"node_modules/@protobufjs/codegen": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="
|
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
|
||||||
},
|
},
|
||||||
"node_modules/@protobufjs/eventemitter": {
|
"node_modules/@protobufjs/eventemitter": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -2457,9 +2449,9 @@
|
|||||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
|
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@protobufjs/inquire": {
|
"node_modules/@protobufjs/inquire": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="
|
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="
|
||||||
},
|
},
|
||||||
"node_modules/@protobufjs/path": {
|
"node_modules/@protobufjs/path": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
@@ -2472,9 +2464,9 @@
|
|||||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
|
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
|
||||||
},
|
},
|
||||||
"node_modules/@protobufjs/utf8": {
|
"node_modules/@protobufjs/utf8": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="
|
||||||
},
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -2700,6 +2692,7 @@
|
|||||||
"version": "0.5.18",
|
"version": "0.5.18",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -2851,6 +2844,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -2906,6 +2900,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
||||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.0",
|
"@typescript-eslint/scope-manager": "8.56.0",
|
||||||
"@typescript-eslint/types": "8.56.0",
|
"@typescript-eslint/types": "8.56.0",
|
||||||
@@ -3044,21 +3039,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -4240,6 +4235,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4265,9 +4261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -4539,13 +4535,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.5",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
@@ -4576,6 +4572,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -4608,7 +4605,6 @@
|
|||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||||
"dev": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
@@ -4622,9 +4618,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -4662,6 +4658,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5014,11 +5011,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js": {
|
|
||||||
"version": "10.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
|
||||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
|
|
||||||
},
|
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -5375,6 +5367,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5567,6 +5560,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -5948,15 +5942,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6233,19 +6227,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/minimatch": {
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -6549,9 +6543,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/icu-minify": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.8.3",
|
"version": "4.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.0.tgz",
|
||||||
"integrity": "sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==",
|
"integrity": "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6610,14 +6604,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/intl-messageformat": {
|
"node_modules/intl-messageformat": {
|
||||||
"version": "11.1.2",
|
"version": "11.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.4.tgz",
|
||||||
"integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==",
|
"integrity": "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/ecma402-abstract": "3.1.1",
|
"@formatjs/fast-memoize": "3.1.4",
|
||||||
"@formatjs/fast-memoize": "3.1.0",
|
"@formatjs/icu-messageformat-parser": "3.5.7"
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.1",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
@@ -7341,9 +7333,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
@@ -7434,13 +7426,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.0.0",
|
"version": "16.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz",
|
||||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
"integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.0.0",
|
"@next/env": "16.2.5",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
"styled-jsx": "5.1.6"
|
"styled-jsx": "5.1.6"
|
||||||
@@ -7452,15 +7445,15 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.0.0",
|
"@next/swc-darwin-arm64": "16.2.5",
|
||||||
"@next/swc-darwin-x64": "16.0.0",
|
"@next/swc-darwin-x64": "16.2.5",
|
||||||
"@next/swc-linux-arm64-gnu": "16.0.0",
|
"@next/swc-linux-arm64-gnu": "16.2.5",
|
||||||
"@next/swc-linux-arm64-musl": "16.0.0",
|
"@next/swc-linux-arm64-musl": "16.2.5",
|
||||||
"@next/swc-linux-x64-gnu": "16.0.0",
|
"@next/swc-linux-x64-gnu": "16.2.5",
|
||||||
"@next/swc-linux-x64-musl": "16.0.0",
|
"@next/swc-linux-x64-musl": "16.2.5",
|
||||||
"@next/swc-win32-arm64-msvc": "16.0.0",
|
"@next/swc-win32-arm64-msvc": "16.2.5",
|
||||||
"@next/swc-win32-x64-msvc": "16.0.0",
|
"@next/swc-win32-x64-msvc": "16.2.5",
|
||||||
"sharp": "^0.34.4"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentelemetry/api": "^1.1.0",
|
"@opentelemetry/api": "^1.1.0",
|
||||||
@@ -7517,9 +7510,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-intl": {
|
"node_modules/next-intl": {
|
||||||
"version": "4.8.3",
|
"version": "4.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.0.tgz",
|
||||||
"integrity": "sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==",
|
"integrity": "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -7530,16 +7523,15 @@
|
|||||||
"@formatjs/intl-localematcher": "^0.8.1",
|
"@formatjs/intl-localematcher": "^0.8.1",
|
||||||
"@parcel/watcher": "^2.4.1",
|
"@parcel/watcher": "^2.4.1",
|
||||||
"@swc/core": "^1.15.2",
|
"@swc/core": "^1.15.2",
|
||||||
"icu-minify": "^4.8.3",
|
"icu-minify": "^4.11.0",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next-intl-swc-plugin-extractor": "^4.8.3",
|
"next-intl-swc-plugin-extractor": "^4.11.0",
|
||||||
"po-parser": "^2.1.1",
|
"po-parser": "^2.1.1",
|
||||||
"use-intl": "^4.8.3"
|
"use-intl": "^4.11.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
"typescript": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
@@ -7548,9 +7540,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-intl-swc-plugin-extractor": {
|
"node_modules/next-intl-swc-plugin-extractor": {
|
||||||
"version": "4.8.3",
|
"version": "4.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.0.tgz",
|
||||||
"integrity": "sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg=="
|
"integrity": "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A=="
|
||||||
},
|
},
|
||||||
"node_modules/next-themes": {
|
"node_modules/next-themes": {
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
@@ -7569,6 +7561,33 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next/node_modules/postcss": {
|
||||||
|
"version": "8.4.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.6",
|
||||||
|
"picocolors": "^1.0.0",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nextjs-toploader": {
|
"node_modules/nextjs-toploader": {
|
||||||
"version": "3.9.17",
|
"version": "3.9.17",
|
||||||
"resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz",
|
"resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz",
|
||||||
@@ -8020,9 +8039,9 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -8046,9 +8065,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -8064,9 +8083,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.6",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
@@ -8076,6 +8095,7 @@
|
|||||||
"version": "10.28.3",
|
"version": "10.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -8137,21 +8157,21 @@
|
|||||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
|
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
|
||||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@protobufjs/aspromise": "^1.1.2",
|
"@protobufjs/aspromise": "^1.1.2",
|
||||||
"@protobufjs/base64": "^1.1.2",
|
"@protobufjs/base64": "^1.1.2",
|
||||||
"@protobufjs/codegen": "^2.0.4",
|
"@protobufjs/codegen": "^2.0.5",
|
||||||
"@protobufjs/eventemitter": "^1.1.0",
|
"@protobufjs/eventemitter": "^1.1.0",
|
||||||
"@protobufjs/fetch": "^1.1.0",
|
"@protobufjs/fetch": "^1.1.0",
|
||||||
"@protobufjs/float": "^1.0.2",
|
"@protobufjs/float": "^1.0.2",
|
||||||
"@protobufjs/inquire": "^1.1.0",
|
"@protobufjs/inquire": "^1.1.1",
|
||||||
"@protobufjs/path": "^1.1.2",
|
"@protobufjs/path": "^1.1.2",
|
||||||
"@protobufjs/pool": "^1.1.0",
|
"@protobufjs/pool": "^1.1.0",
|
||||||
"@protobufjs/utf8": "^1.1.0",
|
"@protobufjs/utf8": "^1.1.1",
|
||||||
"@types/node": ">=13.7.0",
|
"@types/node": ">=13.7.0",
|
||||||
"long": "^5.0.0"
|
"long": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -8165,9 +8185,12 @@
|
|||||||
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="
|
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-memoize": {
|
"node_modules/proxy-memoize": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
@@ -8210,6 +8233,7 @@
|
|||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8218,6 +8242,7 @@
|
|||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -8229,6 +8254,7 @@
|
|||||||
"version": "7.71.1",
|
"version": "7.71.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -9075,10 +9101,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -9246,6 +9273,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -9391,9 +9419,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-intl": {
|
"node_modules/use-intl": {
|
||||||
"version": "4.8.3",
|
"version": "4.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.0.tgz",
|
||||||
"integrity": "sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==",
|
"integrity": "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -9403,7 +9431,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/fast-memoize": "^3.1.0",
|
"@formatjs/fast-memoize": "^3.1.0",
|
||||||
"@schummar/icu-type-parser": "1.21.5",
|
"@schummar/icu-type-parser": "1.21.5",
|
||||||
"icu-minify": "^4.8.3",
|
"icu-minify": "^4.11.0",
|
||||||
"intl-messageformat": "^11.1.0"
|
"intl-messageformat": "^11.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9642,9 +9670,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "1.10.2",
|
"version": "1.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
@@ -9677,6 +9705,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "Suggest-Bet-FE-v2",
|
"name": "iddaai-fe",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --webpack --experimental-https -p 3001",
|
"dev": "next dev -p 6195",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -13,16 +13,18 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@paddle/paddle-js": "^1.6.4",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
"aos": "^2.3.4",
|
"aos": "^2.3.4",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"framer-motion": "^12.34.1",
|
"framer-motion": "^12.34.1",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"next": "16.0.0",
|
"next": "^16.2.5",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 580 KiB |
@@ -1,12 +1,12 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Footer from '@/components/layout/footer/footer';
|
import Footer from "@/components/layout/footer/footer";
|
||||||
import { Box, Flex } from '@chakra-ui/react';
|
import { Box, Flex } from "@chakra-ui/react";
|
||||||
|
|
||||||
function AuthLayout({ children }: { children: React.ReactNode }) {
|
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Flex minH='100vh' direction='column'>
|
<Flex minH="100vh" direction="column">
|
||||||
<Box as='main'>{children}</Box>
|
<Box as="main">{children}</Box>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,231 +1,30 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import SignInForm from "./signin-form";
|
||||||
|
|
||||||
import {
|
export async function generateMetadata(props: {
|
||||||
Box,
|
params: Promise<{ locale: string }>;
|
||||||
Flex,
|
}): Promise<Metadata> {
|
||||||
Heading,
|
const params = await props.params;
|
||||||
Input,
|
const { locale } = params;
|
||||||
Link as ChakraLink,
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
Text,
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
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({
|
const pathSegment = "signin";
|
||||||
email: yup.string().email().required(),
|
|
||||||
password: yup.string().required(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SignInForm = yup.InferType<typeof schema>;
|
return {
|
||||||
|
title: t("signin.title"),
|
||||||
const defaultValues = {
|
description: t("signin.description"),
|
||||||
email: "test@test.com.tr",
|
alternates: {
|
||||||
password: "test1234",
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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");
|
export default function SignInPage() {
|
||||||
} catch (error) {
|
return <SignInForm />;
|
||||||
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,229 @@
|
|||||||
|
"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",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SignInForm() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,238 +1,30 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import SignUpForm from "./signup-form";
|
||||||
|
|
||||||
import {
|
export async function generateMetadata(props: {
|
||||||
Box,
|
params: Promise<{ locale: string }>;
|
||||||
Flex,
|
}): Promise<Metadata> {
|
||||||
Input,
|
const params = await props.params;
|
||||||
Link as ChakraLink,
|
const { locale } = params;
|
||||||
Text,
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
ClientOnly,
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
} 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/auth/service";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { toaster } from "@/components/ui/feedback/toaster";
|
|
||||||
|
|
||||||
const schema = yup.object({
|
const pathSegment = "signup";
|
||||||
name: yup.string().required(),
|
|
||||||
email: yup.string().email().required(),
|
|
||||||
password: yup.string().min(8).required(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SignUpForm = yup.InferType<typeof schema>;
|
return {
|
||||||
|
title: t("signup.title"),
|
||||||
function SignUpPage() {
|
description: t("signup.description"),
|
||||||
const t = useTranslations();
|
alternates: {
|
||||||
const router = useRouter();
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
const {
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
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: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
toaster.error({
|
|
||||||
title: error.message,
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// other errors are 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;
|
export default function SignUpPage() {
|
||||||
|
return <SignUpForm />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"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/auth/service";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
export default function SignUpForm() {
|
||||||
|
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: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
toaster.error({
|
||||||
|
title: error.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// other errors are 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export default function CatchAllPage() {
|
export default function CatchAllPage() {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -1,4 +1,33 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "about";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("about.title"),
|
||||||
|
description: t("about.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function AboutPage() {
|
function AboutPage() {
|
||||||
return <div>AboutPage</div>;
|
return <div>AboutPage</div>;
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import AdminContent from "@/components/admin/admin-content";
|
import AdminContent from "@/components/admin/admin-content";
|
||||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
import { authOptions } from "@/lib/auth/auth-options";
|
||||||
import { isAdminRole } from "@/lib/auth/roles";
|
import { isAdminRole } from "@/lib/auth/roles";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "admin";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("admin.title")} | Suggest Bet`,
|
title: t("admin.title"),
|
||||||
description:
|
description: t("admin.description"),
|
||||||
"Admin panel for managing users, monitoring predictions, and system overview.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import AnalysisContent from "@/components/analysis/analysis-content";
|
import AnalysisContent from "@/components/analysis/analysis-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "analysis";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("analysis.title")} | Suggest Bet`,
|
title: t("analysis.title"),
|
||||||
description: "AI-powered multi-match analysis for coupon generation.",
|
description: t("analysis.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import CouponBuilderContent from "@/components/coupons/coupon-builder-content";
|
import CouponBuilderContent from "@/components/coupons/coupon-builder-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "coupon-builder";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("coupons.builder-title")} | Suggest Bet`,
|
title: t("coupon-builder.title"),
|
||||||
description:
|
description: t("coupon-builder.description"),
|
||||||
"Build your coupon with AI-powered suggestions. Choose your strategy and let AI optimize your bets.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import CouponHistoryContent from "@/components/coupons/coupon-history-content";
|
import CouponHistoryContent from "@/components/coupons/coupon-history-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "coupon-history";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("coupons.history-title")} | Suggest Bet`,
|
title: t("coupon-history.title"),
|
||||||
description:
|
description: t("coupon-history.description"),
|
||||||
"View your coupon history, track wins and losses, and analyze your betting performance.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import DashboardContent from "@/components/dashboard/dashboard-content";
|
import DashboardContent from "@/components/dashboard/dashboard-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "dashboard";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("dashboard.title")} | Suggest Bet`,
|
title: t("dashboard.title"),
|
||||||
description:
|
description: t("dashboard.description"),
|
||||||
"Your personalized betting dashboard with predictions, value bets, and match insights.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import H2HContent from "@/components/h2h/h2h-content";
|
import H2HContent from "@/components/h2h/h2h-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "h2h";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("matches.head-to-head")} | Suggest Bet`,
|
title: t("h2h.title"),
|
||||||
description: "Compare two teams and view their head-to-head match history.",
|
description: t("h2h.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import HomeContent from "@/components/home/home-content";
|
import HomeContent from "@/components/home/home-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "home";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("home")} | Suggest Bet`,
|
title: t("home.title"),
|
||||||
description:
|
description: t("home.description"),
|
||||||
"AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Container, Flex } from '@chakra-ui/react';
|
import { Container, Flex } from "@chakra-ui/react";
|
||||||
import Header from '@/components/layout/header/header';
|
import Header from "@/components/layout/header/header";
|
||||||
import Footer from '@/components/layout/footer/footer';
|
import Footer from "@/components/layout/footer/footer";
|
||||||
import BackToTop from '@/components/ui/back-to-top';
|
import BackToTop from "@/components/ui/back-to-top";
|
||||||
|
|
||||||
function MainLayout({ children }: { children: React.ReactNode }) {
|
function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Flex minH='100vh' direction='column'>
|
<Flex minH="100vh" direction="column">
|
||||||
<Header />
|
<Header />
|
||||||
<Container as='main' maxW='8xl' flex='1' py={4}>
|
<Container as="main" maxW="8xl" flex="1" py={4}>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import LeagueDetailContent from "@/components/leagues/league-detail-content";
|
||||||
|
|
||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale, id } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
const pathSegment = `leagues/${id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${t("leagues.title")} - Detay`,
|
||||||
|
description: t("leagues.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LeagueDetailPage(props: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await props.params;
|
||||||
|
return <LeagueDetailContent leagueId={id} />;
|
||||||
|
}
|
||||||
@@ -1,11 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import LeaguesContent from "@/components/leagues/leagues-content";
|
import LeaguesContent from "@/components/leagues/leagues-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "leagues";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("leagues.title")} | Suggest Bet`,
|
title: t("leagues.title"),
|
||||||
description: "Browse football and basketball leagues, countries, and teams.",
|
description: t("leagues.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import MatchDetailContent from "@/components/matches/match-detail-content";
|
import MatchDetailContent from "@/components/matches/match-detail-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "matches/[id]";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("matches.match-details")} | Suggest Bet`,
|
title: t("matches.title"),
|
||||||
|
description: t("matches.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import MatchesContent from "@/components/matches/matches-content";
|
import MatchesContent from "@/components/matches/matches-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "matches";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("matches.title")} | Suggest Bet`,
|
|
||||||
description:
|
|
||||||
"Browse and analyze upcoming football and basketball matches with AI predictions.",
|
title: t("matches.title"),
|
||||||
|
description: t("matches.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import PredictionsContent from "@/components/predictions/predictions-content";
|
import PredictionsContent from "@/components/predictions/predictions-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "predictions";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("predictions.title")} | Suggest Bet`,
|
title: t("predictions.title"),
|
||||||
description:
|
description: t("predictions.description"),
|
||||||
"AI-powered match predictions with confidence scores, value bets, and prediction history.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import PricingContent from "@/components/pricing/pricing-content";
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
const pathSegment = "pricing";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("pricing.title"),
|
||||||
|
description: t("pricing.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
return <PricingContent />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import LegalPage from "@/components/legal/legal-page";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const isTr = locale === "tr";
|
||||||
|
return {
|
||||||
|
title: isTr ? "Gizlilik ve Güvenlik Politikası — iddaai" : "Privacy & Security Policy — iddaai",
|
||||||
|
description: isTr
|
||||||
|
? "iddaai olarak kişisel verilerinizi nasıl işlediğimizi öğrenin."
|
||||||
|
: "Learn how iddaai processes your personal data.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTR = {
|
||||||
|
title: "Gizlilik ve Güvenlik Politikası",
|
||||||
|
lastUpdated: "Son güncelleme: Mayıs 2026",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: "1. Toplanan Veriler",
|
||||||
|
content: [
|
||||||
|
"Hesap oluşturma sırasında: ad, e-posta adresi ve şifre (şifrelenmiş olarak saklanır).",
|
||||||
|
"Kullanım verileri: görüntülenen sayfalar, yapılan analizler, kupon işlemleri.",
|
||||||
|
"Ödeme işlemleri Paddle tarafından gerçekleştirilir; kredi kartı bilgileriniz hiçbir şekilde sunucularımızda saklanmaz.",
|
||||||
|
"Teknik veriler: IP adresi, tarayıcı türü, cihaz bilgisi.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "2. Verilerin Kullanım Amacı",
|
||||||
|
content: [
|
||||||
|
"Hizmet sunumu ve hesap yönetimi.",
|
||||||
|
"Kişiselleştirilmiş analiz ve tahmin deneyimi sağlamak.",
|
||||||
|
"Platform güvenliğini sağlamak ve kötüye kullanımı önlemek.",
|
||||||
|
"Yasal yükümlülükleri yerine getirmek.",
|
||||||
|
"Hizmet güncellemeleri ve önemli bildirimler için e-posta göndermek (pazarlama e-postaları için ayrıca onayınız alınır).",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3. Veri Paylaşımı",
|
||||||
|
content: [
|
||||||
|
"Kişisel verileriniz üçüncü taraflarla satılmaz veya kiralanmaz.",
|
||||||
|
"Paddle (ödeme işlemcisi) ve gerekli teknik altyapı sağlayıcıları ile yalnızca hizmet sunumu kapsamında paylaşılır.",
|
||||||
|
"Yasal zorunluluk halinde yetkili makamlarla paylaşım yapılabilir.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. Çerezler",
|
||||||
|
content: [
|
||||||
|
"Platform, oturum yönetimi ve kullanıcı deneyimini iyileştirmek amacıyla çerezler kullanır.",
|
||||||
|
"Zorunlu çerezler platformun çalışması için gereklidir. Analitik çerezler ise tarayıcı ayarlarınızdan devre dışı bırakılabilir.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Veri Güvenliği",
|
||||||
|
content: [
|
||||||
|
"Verileriniz HTTPS ile şifreli bağlantı üzerinden iletilir.",
|
||||||
|
"Şifreler bcrypt algoritmasıyla hashlenerek saklanır.",
|
||||||
|
"Düzenli güvenlik denetimleri gerçekleştirilir.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. Haklarınız",
|
||||||
|
content: [
|
||||||
|
"Verilerinize erişim, düzeltme ve silme hakkına sahipsiniz.",
|
||||||
|
"Pazarlama iletişimlerinden istediğiniz zaman çıkabilirsiniz.",
|
||||||
|
"Talep ve şikayetler için: destek@iddaai.com",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "7. Veri Saklama",
|
||||||
|
content: "Hesabınızı sildiğinizde kişisel verileriniz 30 gün içinde sistemlerimizden kalıcı olarak silinir. Yasal zorunluluk gerektiren veriler ilgili mevzuat süresince saklanır.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "8. İletişim",
|
||||||
|
content: "Gizlilik politikasıyla ilgili sorularınız için: destek@iddaai.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentEN = {
|
||||||
|
title: "Privacy & Security Policy",
|
||||||
|
lastUpdated: "Last updated: May 2026",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: "1. Data Collected",
|
||||||
|
content: [
|
||||||
|
"During account creation: name, email address, and password (stored encrypted).",
|
||||||
|
"Usage data: pages viewed, analyses performed, coupon transactions.",
|
||||||
|
"Payment transactions are handled by Paddle; your credit card information is never stored on our servers.",
|
||||||
|
"Technical data: IP address, browser type, device information.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "2. Purpose of Data Use",
|
||||||
|
content: [
|
||||||
|
"Service delivery and account management.",
|
||||||
|
"Providing a personalized analysis and prediction experience.",
|
||||||
|
"Ensuring platform security and preventing abuse.",
|
||||||
|
"Fulfilling legal obligations.",
|
||||||
|
"Sending emails for service updates and important notifications (marketing emails require separate consent).",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3. Data Sharing",
|
||||||
|
content: [
|
||||||
|
"Your personal data is never sold or rented to third parties.",
|
||||||
|
"Shared only with Paddle (payment processor) and necessary technical infrastructure providers within the scope of service delivery.",
|
||||||
|
"May be shared with authorities in case of legal obligation.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. Cookies",
|
||||||
|
content: [
|
||||||
|
"The platform uses cookies for session management and to improve user experience.",
|
||||||
|
"Essential cookies are required for the platform to function. Analytical cookies can be disabled in your browser settings.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Data Security",
|
||||||
|
content: [
|
||||||
|
"Your data is transmitted via HTTPS encrypted connection.",
|
||||||
|
"Passwords are stored hashed using the bcrypt algorithm.",
|
||||||
|
"Regular security audits are conducted.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. Your Rights",
|
||||||
|
content: [
|
||||||
|
"You have the right to access, correct, and delete your data.",
|
||||||
|
"You can opt out of marketing communications at any time.",
|
||||||
|
"For requests and complaints: support@iddaai.com",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "7. Data Retention",
|
||||||
|
content: "When you delete your account, your personal data will be permanently removed from our systems within 30 days. Data required by legal obligations will be retained for the relevant statutory period.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "8. Contact",
|
||||||
|
content: "For questions about the privacy policy: support@iddaai.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PrivacyPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const content = locale === "tr" ? contentTR : contentEN;
|
||||||
|
return <LegalPage {...content} />;
|
||||||
|
}
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import ProfileContent from "@/components/profile/profile-content";
|
import ProfileContent from "@/components/profile/profile-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "profile";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("profile.title")} | Suggest Bet`,
|
title: t("profile.title"),
|
||||||
description:
|
description: t("profile.description"),
|
||||||
"Manage your profile, view account info, and track your betting statistics.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import LegalPage from "@/components/legal/legal-page";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const isTr = locale === "tr";
|
||||||
|
return {
|
||||||
|
title: isTr ? "İade Politikası — iddaai" : "Refund Policy — iddaai",
|
||||||
|
description: isTr
|
||||||
|
? "iddaai abonelik iptali ve iade koşulları hakkında bilgi edinin."
|
||||||
|
: "Learn about iddaai subscription cancellation and refund conditions.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTR = {
|
||||||
|
title: "İade Politikası",
|
||||||
|
lastUpdated: "Son güncelleme: Mayıs 2026",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: "1. Genel İlkeler",
|
||||||
|
content: [
|
||||||
|
"iddaai olarak müşteri memnuniyetini ön planda tutuyoruz. Abonelik satın alımlarında adil bir iade politikası uygulamaktayız.",
|
||||||
|
"Tüm ödemeler Paddle altyapısı üzerinden güvenli şekilde işlenir.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "2. İptal ve İade Koşulları",
|
||||||
|
content: [
|
||||||
|
"Aylık abonelikler: Ödeme tarihinden itibaren 7 gün içinde herhangi bir neden göstermeksizin iade talebinde bulunabilirsiniz.",
|
||||||
|
"Yıllık abonelikler: Ödeme tarihinden itibaren 14 gün içinde iade talep edebilirsiniz.",
|
||||||
|
"Belirtilen süreler dolduktan sonra yapılan iade talepleri, istisnai durumlar dışında kabul edilmez.",
|
||||||
|
"Ücretsiz deneme süresini kullandıktan sonra yapılan ödemelerde deneme süresi iade kapsamı dışındadır.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3. İade Süreci",
|
||||||
|
content: [
|
||||||
|
"İade talebini destek@iddaai.com adresine e-posta göndererek veya hesabınızdaki destek kanalı üzerinden iletebilirsiniz.",
|
||||||
|
"Talebiniz en geç 3 iş günü içinde değerlendirilir.",
|
||||||
|
"Onaylanan iadeler, ödemenin yapıldığı ödeme yöntemine 5-10 iş günü içinde yansıtılır.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. İade Edilmeyecek Durumlar",
|
||||||
|
content: [
|
||||||
|
"Kullanım koşullarını ihlal etmeniz nedeniyle hesabınızın askıya alınması veya kapatılması.",
|
||||||
|
"İade süresinin dolmasından sonra yapılan talepler (istisnai durumlar hariç).",
|
||||||
|
"Kısmi ay kullanımları için orantılı iade yapılmaz.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Abonelik İptali",
|
||||||
|
content: [
|
||||||
|
"Aboneliğinizi istediğiniz zaman iptal edebilirsiniz. İptal işlemi, mevcut dönem sonunda geçerli olur.",
|
||||||
|
"İptal sonrasında hesabınız abonelik bitiş tarihine kadar aktif kalmaya devam eder.",
|
||||||
|
"İptal için hesabınızdaki Abonelik Yönetimi bölümünü veya destek@iddaai.com adresini kullanabilirsiniz.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. İletişim",
|
||||||
|
content: "İade ve iptal talepleriniz için: destek@iddaai.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentEN = {
|
||||||
|
title: "Refund Policy",
|
||||||
|
lastUpdated: "Last updated: May 2026",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: "1. General Principles",
|
||||||
|
content: [
|
||||||
|
"At iddaai, we prioritize customer satisfaction and apply a fair refund policy for subscription purchases.",
|
||||||
|
"All payments are securely processed via the Paddle infrastructure.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "2. Cancellation and Refund Conditions",
|
||||||
|
content: [
|
||||||
|
"Monthly subscriptions: You may request a refund within 7 days of payment without providing any reason.",
|
||||||
|
"Annual subscriptions: You may request a refund within 14 days of payment.",
|
||||||
|
"Refund requests made after the specified periods will not be accepted, except in exceptional circumstances.",
|
||||||
|
"Payments made after using a free trial period are not eligible for refund for the trial period.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3. Refund Process",
|
||||||
|
content: [
|
||||||
|
"You can submit a refund request by emailing support@iddaai.com or through the support channel in your account.",
|
||||||
|
"Your request will be reviewed within 3 business days.",
|
||||||
|
"Approved refunds will be reflected to the original payment method within 5-10 business days.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. Non-Refundable Situations",
|
||||||
|
content: [
|
||||||
|
"Suspension or closure of your account due to violation of terms of service.",
|
||||||
|
"Requests made after the refund period has expired (except in exceptional circumstances).",
|
||||||
|
"No proportional refunds are made for partial month usage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Subscription Cancellation",
|
||||||
|
content: [
|
||||||
|
"You may cancel your subscription at any time. The cancellation takes effect at the end of the current billing period.",
|
||||||
|
"After cancellation, your account remains active until the subscription end date.",
|
||||||
|
"To cancel, use the Subscription Management section in your account or contact support@iddaai.com.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. Contact",
|
||||||
|
content: "For refund and cancellation requests: support@iddaai.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RefundPolicyPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const content = locale === "tr" ? contentTR : contentEN;
|
||||||
|
return <LegalPage {...content} />;
|
||||||
|
}
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import SporTotoContent from "@/components/spor-toto/spor-toto-content";
|
import SporTotoContent from "@/components/spor-toto/spor-toto-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "spor-toto";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("spor-toto.title")} | Suggest Bet`,
|
title: t("spor-toto.title"),
|
||||||
description:
|
description: t("spor-toto.description"),
|
||||||
"Spor Toto predictions with AI-powered analysis. Generate optimized system coupons with contrarian parimutuel strategy.",
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import TeamDetailContent from "@/components/teams/team-detail-content";
|
import TeamDetailContent from "@/components/teams/team-detail-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "teams/[id]";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("nav.teams")} | Suggest Bet`,
|
title: t("teams.title"),
|
||||||
|
description: t("teams.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import TeamsContent from "@/components/teams/teams-content";
|
import TeamsContent from "@/components/teams/teams-content";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
import { Metadata } from "next";
|
||||||
const t = await getTranslations();
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const { locale } = params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
|
||||||
|
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
|
||||||
|
// We'll set alternates just for languages based on current path segment as a best effort
|
||||||
|
const pathSegment = "teams";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${t("nav.teams")} | Suggest Bet`,
|
title: t("teams.title"),
|
||||||
description: "Search and explore football teams, view match history and stats.",
|
description: t("teams.description"),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||||
|
languages: {
|
||||||
|
en: `${siteUrl}/en/${pathSegment}`,
|
||||||
|
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import LegalPage from "@/components/legal/legal-page";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const isTr = locale === "tr";
|
||||||
|
return {
|
||||||
|
title: isTr ? "Kullanım Koşulları — iddaai" : "Terms of Service — iddaai",
|
||||||
|
description: isTr
|
||||||
|
? "iddaai platformunu kullanmadan önce kullanım koşullarını okuyunuz."
|
||||||
|
: "Please read our terms of service before using the iddaai platform.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTR = {
|
||||||
|
title: "Kullanım Koşulları",
|
||||||
|
lastUpdated: "Son güncelleme: Mayıs 2026",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: "1. Genel Hükümler",
|
||||||
|
content: [
|
||||||
|
"Bu Kullanım Koşulları, iddaai.com platformunu kullanan tüm kullanıcılar için geçerlidir. Platformu kullanarak bu koşulları kabul etmiş sayılırsınız.",
|
||||||
|
"iddaai, yapay zeka destekli spor analiz ve tahmin hizmetleri sunan bir bilgi platformudur. Sunulan analizler bilgilendirme amaçlıdır; kesin sonuç garantisi içermez.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "2. Hizmetin Kapsamı",
|
||||||
|
content: [
|
||||||
|
"iddaai, futbol ve diğer spor dallarına yönelik AI tabanlı istatistik analizleri, maç tahminleri ve olasılık değerlendirmeleri sunar.",
|
||||||
|
"Platform, kullanıcılara bahis kararlarında yardımcı olmak amacıyla tasarlanmıştır. Ancak hiçbir analiz kesin kazanç garantisi vermez.",
|
||||||
|
"Hizmetlerimiz; Tüm Maçlar, Tahminler, Kadro Analizleri, Kuponlar ve Karşılıklı Karşılaşma istatistiklerini kapsar.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3. Kullanıcı Yükümlülükleri",
|
||||||
|
content: [
|
||||||
|
"Platformu yalnızca yasal amaçlarla kullanmayı kabul edersiniz.",
|
||||||
|
"Bahis oynamanın yasal olduğu ülke veya bölgede ikamet etmekten ve yasal yaşı (18+) karşılamaktan tamamen siz sorumlusunuz.",
|
||||||
|
"Hesap bilgilerinizi üçüncü şahıslarla paylaşmamalısınız.",
|
||||||
|
"Platformun içeriklerini izinsiz kopyalamak, dağıtmak veya ticari amaçla kullanmak yasaktır.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. Sorumluluk Reddi",
|
||||||
|
content: [
|
||||||
|
"iddaai'nin sunduğu analizler ve tahminler tamamen bilgilendirme amaçlıdır. Bahis kayıplarından iddaai sorumlu tutulamaz.",
|
||||||
|
"Platform, bahis şirketleri ile herhangi bir bağlantısı bulunmamaktadır ve herhangi bir bahis şirketini tavsiye etmez.",
|
||||||
|
"Sunulan istatistikler ve olasılıklar, geçmiş veriler ve yapay zeka modelleri kullanılarak üretilmekte olup geleceği kesin olarak tahmin etmez.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Ücretli Üyelik",
|
||||||
|
content: [
|
||||||
|
"Bazı özellikler ücretli abonelik gerektirir. Abonelik detayları Fiyatlandırma sayfasında belirtilmiştir.",
|
||||||
|
"Ödemeler Paddle altyapısı üzerinden güvenli biçimde işlenir.",
|
||||||
|
"İptal ve iade koşulları için Geri Ödeme Politikamızı inceleyiniz.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. Fikri Mülkiyet",
|
||||||
|
content: "Platform üzerindeki tüm içerik, tasarım, yazılım ve analizler iddaai'ye aittir. İzinsiz kullanım yasal işlem başlatılmasına neden olabilir.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "7. Değişiklikler",
|
||||||
|
content: "iddaai, bu koşulları önceden bildirim yapmaksızın değiştirme hakkını saklı tutar. Güncel koşullar her zaman bu sayfada yayınlanır.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "8. İletişim",
|
||||||
|
content: "Kullanım koşullarıyla ilgili sorularınız için: destek@iddaai.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentEN = {
|
||||||
|
title: "Terms of Service",
|
||||||
|
lastUpdated: "Last updated: May 2026",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: "1. General Terms",
|
||||||
|
content: [
|
||||||
|
"These Terms of Service apply to all users of the iddaai.com platform. By using the platform, you agree to these terms.",
|
||||||
|
"iddaai is an information platform offering AI-powered sports analysis and prediction services. The analyses provided are for informational purposes only and do not guarantee specific outcomes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "2. Scope of Service",
|
||||||
|
content: [
|
||||||
|
"iddaai provides AI-based statistical analyses, match predictions, and probability assessments for football and other sports.",
|
||||||
|
"The platform is designed to assist users in making betting decisions. However, no analysis guarantees a definite win.",
|
||||||
|
"Our services include All Matches, Predictions, Squad Analyses, Coupons, and Head-to-Head statistics.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3. User Obligations",
|
||||||
|
content: [
|
||||||
|
"You agree to use the platform for legal purposes only.",
|
||||||
|
"You are solely responsible for ensuring that sports betting is legal in your country or region and that you meet the legal age requirement (18+).",
|
||||||
|
"You must not share your account credentials with third parties.",
|
||||||
|
"Copying, distributing, or using the platform's content for commercial purposes without permission is prohibited.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. Disclaimer",
|
||||||
|
content: [
|
||||||
|
"The analyses and predictions provided by iddaai are purely for informational purposes. iddaai cannot be held responsible for betting losses.",
|
||||||
|
"The platform has no affiliation with any bookmaker and does not endorse any betting company.",
|
||||||
|
"Statistics and probabilities are generated using historical data and AI models and do not predict the future with certainty.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Paid Membership",
|
||||||
|
content: [
|
||||||
|
"Some features require a paid subscription. Subscription details are listed on the Pricing page.",
|
||||||
|
"Payments are securely processed via the Paddle infrastructure.",
|
||||||
|
"For cancellation and refund conditions, please review our Refund Policy.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. Intellectual Property",
|
||||||
|
content: "All content, design, software, and analyses on the platform belong to iddaai. Unauthorized use may result in legal action.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "7. Changes",
|
||||||
|
content: "iddaai reserves the right to modify these terms without prior notice. The current terms are always published on this page.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "8. Contact",
|
||||||
|
content: "For questions about the terms of service: support@iddaai.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TermsPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const content = locale === "tr" ? contentTR : contentEN;
|
||||||
|
return <LegalPage {...content} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ═══════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════
|
||||||
Suggest-Bet — Global CSS
|
iddaai — Global CSS
|
||||||
Premium animations, gradients, and utility keyframes
|
Premium animations, gradients, and utility keyframes
|
||||||
═══════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,42 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { dir } from "i18next";
|
import { dir } from "i18next";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "seo" });
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: new URL(siteUrl),
|
||||||
|
title: {
|
||||||
|
template: `%s | ${t("global.title").split(" | ")[0]}`,
|
||||||
|
default: t("global.title"),
|
||||||
|
},
|
||||||
|
description: t("global.description"),
|
||||||
|
keywords: t("global.keywords"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("global.title"),
|
||||||
|
description: t("global.description"),
|
||||||
|
siteName: t("global.title").split(" | ")[0],
|
||||||
|
locale: locale,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: t("global.title"),
|
||||||
|
description: t("global.description"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const bricolage = Bricolage_Grotesque({
|
const bricolage = Bricolage_Grotesque({
|
||||||
variable: "--font-bricolage",
|
variable: "--font-bricolage",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -23,6 +57,27 @@ export default async function RootLayout({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: "iddaai.com",
|
||||||
|
url: siteUrl,
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: `${siteUrl}/search?q={search_term_string}`,
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "iddaai.com",
|
||||||
|
url: siteUrl,
|
||||||
|
logo: `${siteUrl}/favicon/android-chrome-512x512.png`,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
@@ -35,8 +90,16 @@ export default async function RootLayout({
|
|||||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.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='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' /> */}
|
||||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={bricolage.variable}>
|
<body className={bricolage.variable} suppressHydrationWarning>
|
||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<Provider>{children}</Provider>
|
<Provider>{children}</Provider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from "@/i18n/navigation";
|
||||||
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
|
import { Flex, Text, Button, VStack, Heading } from "@chakra-ui/react";
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export default async function NotFoundPage() {
|
export default async function NotFoundPage() {
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
|
<Flex
|
||||||
|
h="100vh"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
textAlign="center"
|
||||||
|
px={6}
|
||||||
|
>
|
||||||
<VStack spaceY={6}>
|
<VStack spaceY={6}>
|
||||||
<Heading
|
<Heading
|
||||||
as='h1'
|
as="h1"
|
||||||
fontSize={{ base: '5xl', md: '6xl' }}
|
fontSize={{ base: "5xl", md: "6xl" }}
|
||||||
fontWeight='bold'
|
fontWeight="bold"
|
||||||
color={{ base: 'primary.600', _dark: 'primary.400' }}
|
color={{ base: "primary.600", _dark: "primary.400" }}
|
||||||
>
|
>
|
||||||
{t('error.404')}
|
{t("error.404")}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
|
<Text
|
||||||
{t('error.not-found')}
|
fontSize={{ base: "md", md: "lg" }}
|
||||||
|
color={{ base: "fg.muted", _dark: "white" }}
|
||||||
|
>
|
||||||
|
{t("error.not-found")}
|
||||||
</Text>
|
</Text>
|
||||||
<Link href='/home' passHref>
|
<Link href="/home" passHref>
|
||||||
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
|
<Button size={{ base: "md", md: "lg" }} rounded="md">
|
||||||
{t('error.back-to-home')}
|
{t("error.back-to-home")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
redirect('/home');
|
redirect("/home");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +1,5 @@
|
|||||||
import { authService } from "@/lib/api/auth/service";
|
import { authOptions } from "@/lib/auth/auth-options";
|
||||||
import { normalizeRoles } from "@/lib/auth/roles";
|
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import type { NextAuthOptions } from "next-auth";
|
|
||||||
import type { JWT } from "next-auth/jwt";
|
|
||||||
import type { Session, User } from "next-auth";
|
|
||||||
import Credentials from "next-auth/providers/credentials";
|
|
||||||
|
|
||||||
function randomToken() {
|
|
||||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
|
||||||
providers: [
|
|
||||||
Credentials({
|
|
||||||
name: "Credentials",
|
|
||||||
credentials: {
|
|
||||||
email: { label: "Email", type: "text" },
|
|
||||||
password: { label: "Password", type: "password" },
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
try {
|
|
||||||
console.log("Starting authorization with:", {
|
|
||||||
email: credentials?.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!credentials?.email || !credentials?.password) {
|
|
||||||
throw new Error("Email ve şifre gereklidir.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eğer mock mod aktifse backend'e gitme
|
|
||||||
if (isMockMode) {
|
|
||||||
console.log("Mock mode active, bypassing backend");
|
|
||||||
return {
|
|
||||||
id: credentials.email,
|
|
||||||
name: credentials.email.split("@")[0],
|
|
||||||
email: credentials.email,
|
|
||||||
accessToken: randomToken(),
|
|
||||||
refreshToken: randomToken(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal mod: backend'e istek at
|
|
||||||
console.log("Sending login request to backend...");
|
|
||||||
const res = await authService.login({
|
|
||||||
email: credentials.email,
|
|
||||||
password: credentials.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Backend response received:",
|
|
||||||
JSON.stringify(res, null, 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = res;
|
|
||||||
|
|
||||||
// Backend returns ApiResponse<TokenResponseDto>
|
|
||||||
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
|
||||||
if (!res.success || !response?.data?.accessToken) {
|
|
||||||
console.error("Login failed or no access token in response");
|
|
||||||
throw new Error(response?.message || "Giriş başarısız");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { accessToken, refreshToken, user } = response.data;
|
|
||||||
const normalizedRoles = normalizeRoles(user.roles);
|
|
||||||
|
|
||||||
console.log("Login successful, creating user session object");
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
name: user.firstName
|
|
||||||
? `${user.firstName} ${user.lastName || ""}`.trim()
|
|
||||||
: user.email.split("@")[0],
|
|
||||||
email: user.email,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
roles: normalizedRoles,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error("Authorize error detailed:", error);
|
|
||||||
const err = error as Error & {
|
|
||||||
response?: { data: unknown; status: number };
|
|
||||||
};
|
|
||||||
if (err.response) {
|
|
||||||
console.error("Error response data:", err.response.data);
|
|
||||||
console.error("Error response status:", err.response.status);
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
err.message || "An error occurred during authentication",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
async jwt({ token, user }: { token: JWT; user?: User }) {
|
|
||||||
if (user) {
|
|
||||||
token.accessToken = user.accessToken;
|
|
||||||
token.refreshToken = user.refreshToken;
|
|
||||||
token.id = user.id;
|
|
||||||
token.roles = normalizeRoles(user.roles);
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
async session({ session, token }: { session: Session; token: JWT }) {
|
|
||||||
session.user.id = token.id;
|
|
||||||
session.user.roles = normalizeRoles(token.roles);
|
|
||||||
session.accessToken = token.accessToken;
|
|
||||||
session.refreshToken = token.refreshToken;
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: "/signin",
|
|
||||||
error: "/signin",
|
|
||||||
},
|
|
||||||
session: { strategy: "jwt" },
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handler = NextAuth(authOptions);
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
import { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
disallow: ["/admin", "/*/dashboard", "/*/profile", "/*/coupon-history"],
|
||||||
|
},
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MetadataRoute } from "next";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
|
||||||
|
|
||||||
|
const staticPages = [
|
||||||
|
"",
|
||||||
|
"/home",
|
||||||
|
"/about",
|
||||||
|
"/analysis",
|
||||||
|
"/leagues",
|
||||||
|
"/matches",
|
||||||
|
"/teams",
|
||||||
|
"/predictions",
|
||||||
|
"/spor-toto",
|
||||||
|
"/coupon-builder",
|
||||||
|
"/h2h",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
staticPages.forEach((page) => {
|
||||||
|
routing.locales.forEach((locale) => {
|
||||||
|
sitemapEntries.push({
|
||||||
|
url: `${baseUrl}/${locale}${page}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: page === "" || page === "/home" ? 1.0 : 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return sitemapEntries;
|
||||||
|
}
|
||||||
@@ -13,8 +13,13 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
Separator,
|
Separator,
|
||||||
|
Input,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import {
|
||||||
|
NativeSelectRoot,
|
||||||
|
NativeSelectField,
|
||||||
|
} from "@/components/ui/forms/native-select";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import {
|
import {
|
||||||
SlideUp,
|
SlideUp,
|
||||||
@@ -25,11 +30,19 @@ import {
|
|||||||
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
||||||
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
||||||
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
|
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
|
||||||
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
|
import {
|
||||||
import { useState } from "react";
|
LuUsers,
|
||||||
|
LuChartBar,
|
||||||
|
LuActivity,
|
||||||
|
LuShield,
|
||||||
|
LuPencil,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { EditUserModal } from "./edit-user-modal";
|
||||||
|
import LeagueTiersContent from "./league-tiers-content";
|
||||||
|
|
||||||
type AdminTab = "overview" | "users";
|
type AdminTab = "overview" | "users" | "league-tiers";
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Admin Stat Card
|
// Admin Stat Card
|
||||||
@@ -82,7 +95,26 @@ function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
|
|||||||
export default function AdminContent() {
|
export default function AdminContent() {
|
||||||
const t = useTranslations("admin");
|
const t = useTranslations("admin");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const format = useFormatter();
|
||||||
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
|
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
|
||||||
|
const [editingUser, setEditingUser] = useState<AdminUserDto | null>(null);
|
||||||
|
const [searchParams, setSearchParams] = useState({
|
||||||
|
search: "",
|
||||||
|
role: "",
|
||||||
|
subscriptionStatus: "",
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedSearch(searchParams.search);
|
||||||
|
setSearchParams((prev) => ({ ...prev, page: 1 }));
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [searchParams.search]);
|
||||||
|
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const cardBg = useColorModeValue("white", "gray.800");
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
@@ -92,16 +124,24 @@ export default function AdminContent() {
|
|||||||
const { data: analyticsData, isLoading: analyticsLoading } =
|
const { data: analyticsData, isLoading: analyticsLoading } =
|
||||||
useAdminAnalytics(canAccessAdmin);
|
useAdminAnalytics(canAccessAdmin);
|
||||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers(
|
const { data: usersData, isLoading: usersLoading } = useAdminUsers(
|
||||||
undefined,
|
{
|
||||||
|
search: debouncedSearch,
|
||||||
|
role: searchParams.role,
|
||||||
|
subscriptionStatus: searchParams.subscriptionStatus,
|
||||||
|
page: searchParams.page,
|
||||||
|
limit: searchParams.limit,
|
||||||
|
},
|
||||||
canAccessAdmin,
|
canAccessAdmin,
|
||||||
);
|
);
|
||||||
|
|
||||||
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
||||||
const users = usersData?.data?.items ?? [];
|
const users = usersData?.data?.items ?? [];
|
||||||
|
const meta = usersData?.data?.meta;
|
||||||
|
|
||||||
const tabs: { key: AdminTab; label: string }[] = [
|
const tabs: { key: AdminTab; label: string }[] = [
|
||||||
{ key: "overview", label: t("overview") },
|
{ key: "overview", label: t("overview") },
|
||||||
{ key: "users", label: t("user-management") },
|
{ key: "users", label: t("user-management") },
|
||||||
|
{ key: "league-tiers", label: "Lig Tier" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getUserDisplayName = (user: AdminUserDto) => {
|
const getUserDisplayName = (user: AdminUserDto) => {
|
||||||
@@ -127,13 +167,13 @@ export default function AdminContent() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<Badge colorPalette="red" variant="subtle" borderRadius="full">
|
<Badge colorPalette="red" variant="subtle" borderRadius="full">
|
||||||
<LuShield />
|
<LuShield />
|
||||||
Restricted
|
{t("restricted")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading as="h2" size="md">
|
<Heading as="h2" size="md">
|
||||||
Admin access required
|
{t("admin-access-required")}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text color="fg.muted" textAlign="center" maxW="md">
|
<Text color="fg.muted" textAlign="center" maxW="md">
|
||||||
This area is only available to superadmin accounts.
|
{t("admin-access-description")}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
@@ -194,7 +234,9 @@ export default function AdminContent() {
|
|||||||
<StaggerItem>
|
<StaggerItem>
|
||||||
<AdminStat
|
<AdminStat
|
||||||
label={t("total-users")}
|
label={t("total-users")}
|
||||||
value={analytics?.totalUsers ?? analytics?.users?.total ?? 0}
|
value={
|
||||||
|
analytics?.totalUsers ?? analytics?.users?.total ?? 0
|
||||||
|
}
|
||||||
icon={<LuUsers />}
|
icon={<LuUsers />}
|
||||||
colorPalette="primary"
|
colorPalette="primary"
|
||||||
/>
|
/>
|
||||||
@@ -202,15 +244,27 @@ export default function AdminContent() {
|
|||||||
<StaggerItem>
|
<StaggerItem>
|
||||||
<AdminStat
|
<AdminStat
|
||||||
label={t("total-predictions")}
|
label={t("total-predictions")}
|
||||||
value={analytics?.totalPredictions ?? analytics?.predictions ?? 0}
|
value={
|
||||||
|
analytics?.totalPredictions ?? analytics?.predictions ?? 0
|
||||||
|
}
|
||||||
icon={<LuChartBar />}
|
icon={<LuChartBar />}
|
||||||
colorPalette="green"
|
colorPalette="green"
|
||||||
/>
|
/>
|
||||||
</StaggerItem>
|
</StaggerItem>
|
||||||
|
<StaggerItem>
|
||||||
|
<AdminStat
|
||||||
|
label={t("premium-users")}
|
||||||
|
value={analytics?.users?.premium ?? 0}
|
||||||
|
icon={<LuShield />}
|
||||||
|
colorPalette="purple"
|
||||||
|
/>
|
||||||
|
</StaggerItem>
|
||||||
<StaggerItem>
|
<StaggerItem>
|
||||||
<AdminStat
|
<AdminStat
|
||||||
label={t("active-users")}
|
label={t("active-users")}
|
||||||
value={analytics?.activeUsers ?? analytics?.users?.active ?? 0}
|
value={
|
||||||
|
analytics?.activeUsers ?? analytics?.users?.active ?? 0
|
||||||
|
}
|
||||||
icon={<LuActivity />}
|
icon={<LuActivity />}
|
||||||
colorPalette="orange"
|
colorPalette="orange"
|
||||||
/>
|
/>
|
||||||
@@ -228,13 +282,73 @@ export default function AdminContent() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Users Tab */}
|
{/* Users Tab */}
|
||||||
{activeTab === "users" &&
|
{activeTab === "users" && (
|
||||||
(usersLoading ? (
|
<VStack gap={4} align="stretch">
|
||||||
|
{/* Filters */}
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||||
|
<Card.Body py={4}>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} gap={4}>
|
||||||
|
<Input
|
||||||
|
placeholder={t("search-users-placeholder")}
|
||||||
|
value={searchParams.search}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
search: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NativeSelectRoot>
|
||||||
|
<NativeSelectField
|
||||||
|
placeholder={t("all-roles")}
|
||||||
|
value={searchParams.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
role: e.target.value,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{ label: t("standard-user"), value: "user" },
|
||||||
|
{ label: t("superadmin"), value: "superadmin" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
<NativeSelectRoot>
|
||||||
|
<NativeSelectField
|
||||||
|
placeholder={t("all-plans")}
|
||||||
|
value={searchParams.subscriptionStatus}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
subscriptionStatus: e.target.value,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{ label: t("plan-free"), value: "free" },
|
||||||
|
{ label: "Plus", value: "plus" },
|
||||||
|
{ label: "Premium", value: "premium" },
|
||||||
|
{ label: t("plan-past-due"), value: "past_due" },
|
||||||
|
{ label: t("plan-cancelled"), value: "cancelled" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{usersLoading ? (
|
||||||
<Flex justify="center" py={16}>
|
<Flex justify="center" py={16}>
|
||||||
<Spinner size="lg" color="primary.500" />
|
<Spinner size="lg" color="primary.500" />
|
||||||
</Flex>
|
</Flex>
|
||||||
) : users.length > 0 ? (
|
) : users.length > 0 ? (
|
||||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
<Card.Root
|
||||||
|
bg={cardBg}
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<VStack gap={0} align="stretch">
|
<VStack gap={0} align="stretch">
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
@@ -253,9 +367,13 @@ export default function AdminContent() {
|
|||||||
<Text flex={1} textAlign="center">
|
<Text flex={1} textAlign="center">
|
||||||
{t("user-role")}
|
{t("user-role")}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text flex={1} textAlign="center">
|
||||||
|
{t("subscription")}
|
||||||
|
</Text>
|
||||||
<Text flex={1} textAlign="center">
|
<Text flex={1} textAlign="center">
|
||||||
{t("user-status")}
|
{t("user-status")}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text width="40px" textAlign="center"></Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* User Rows */}
|
{/* User Rows */}
|
||||||
@@ -277,12 +395,19 @@ export default function AdminContent() {
|
|||||||
>
|
>
|
||||||
{getUserDisplayName(user)}
|
{getUserDisplayName(user)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
|
<Text
|
||||||
|
flex={2}
|
||||||
|
fontSize="sm"
|
||||||
|
color="fg.muted"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex flex={1} justify="center">
|
<Flex flex={1} justify="center">
|
||||||
<Badge
|
<Badge
|
||||||
colorPalette={isAdminRole([user.role]) ? "red" : "gray"}
|
colorPalette={
|
||||||
|
isAdminRole([user.role]) ? "red" : "gray"
|
||||||
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
fontSize="2xs"
|
fontSize="2xs"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
@@ -290,6 +415,61 @@ export default function AdminContent() {
|
|||||||
{formatRoleLabel(user.role)}
|
{formatRoleLabel(user.role)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
flex={1}
|
||||||
|
justify="center"
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
colorPalette={
|
||||||
|
user.subscriptionStatus === "premium" ||
|
||||||
|
user.subscriptionStatus === "plus"
|
||||||
|
? "purple"
|
||||||
|
: "gray"
|
||||||
|
}
|
||||||
|
variant="subtle"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
textTransform="capitalize"
|
||||||
|
>
|
||||||
|
{user.subscriptionStatus || "free"}
|
||||||
|
</Badge>
|
||||||
|
{user.subscriptionExpiresAt ? (
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{format.dateTime(
|
||||||
|
new Date(user.subscriptionExpiresAt),
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<Flex flex={1} justify="center">
|
||||||
|
<Badge
|
||||||
|
colorPalette={
|
||||||
|
user.subscriptionStatus === "premium"
|
||||||
|
? "purple"
|
||||||
|
: user.subscriptionStatus === "plus"
|
||||||
|
? "blue"
|
||||||
|
: "gray"
|
||||||
|
}
|
||||||
|
variant="subtle"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
textTransform="capitalize"
|
||||||
|
>
|
||||||
|
{user.subscriptionStatus || "free"}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
<Flex flex={1} justify="center">
|
<Flex flex={1} justify="center">
|
||||||
<Badge
|
<Badge
|
||||||
colorPalette={user.isActive ? "green" : "gray"}
|
colorPalette={user.isActive ? "green" : "gray"}
|
||||||
@@ -302,9 +482,63 @@ export default function AdminContent() {
|
|||||||
: tCommon("inactive")}
|
: tCommon("inactive")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Flex width="40px" justify="center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setEditingUser(user)}
|
||||||
|
>
|
||||||
|
<LuPencil />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{meta && meta.totalPages > 1 && (
|
||||||
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
pt={4}
|
||||||
|
pb={2}
|
||||||
|
gap={2}
|
||||||
|
borderTopWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
mt={2}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!meta.hasPreviousPage}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
page: meta.page - 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tCommon("previous")}
|
||||||
|
</Button>
|
||||||
|
<Flex align="center" gap={2} fontSize="sm">
|
||||||
|
<Text>
|
||||||
|
{tCommon("page")} {meta.page} / {meta.totalPages}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!meta.hasNextPage}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
page: meta.page + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tCommon("next")}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
@@ -312,7 +546,18 @@ export default function AdminContent() {
|
|||||||
<Flex justify="center" py={16}>
|
<Flex justify="center" py={16}>
|
||||||
<Text color="fg.muted">{t("no-users")}</Text>
|
<Text color="fg.muted">{t("no-users")}</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
))}
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* League Tiers Tab */}
|
||||||
|
{activeTab === "league-tiers" && <LeagueTiersContent />}
|
||||||
|
|
||||||
|
<EditUserModal
|
||||||
|
user={editingUser}
|
||||||
|
isOpen={!!editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</SlideUp>
|
</SlideUp>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, VStack, Input } from "@chakra-ui/react";
|
||||||
|
import {
|
||||||
|
DialogRoot,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogCloseTrigger,
|
||||||
|
} from "@/components/ui/overlays/dialog";
|
||||||
|
import { Field } from "@/components/ui/forms/field";
|
||||||
|
import {
|
||||||
|
NativeSelectRoot,
|
||||||
|
NativeSelectField,
|
||||||
|
} from "@/components/ui/forms/native-select";
|
||||||
|
import { Switch } from "@/components/ui/forms/switch";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AdminUserDto } from "@/lib/api/admin/types";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
useUpdateUserRole,
|
||||||
|
useUpdateUserSubscription,
|
||||||
|
useToggleUserActive,
|
||||||
|
} from "@/lib/api/admin/use-hooks";
|
||||||
|
|
||||||
|
interface EditUserModalProps {
|
||||||
|
user: AdminUserDto | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditUserModal({ user, isOpen, onClose }: EditUserModalProps) {
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditUserModalContent
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInputValue(value?: string | null): string {
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
return new Date(value).toISOString().split("T")[0];
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditUserModalContent({
|
||||||
|
user,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
user: AdminUserDto;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("admin");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
|
||||||
|
const [role, setRole] = useState(user.role || "user");
|
||||||
|
const [plan, setPlan] = useState(user.subscriptionStatus || "free");
|
||||||
|
const [expiresAt, setExpiresAt] = useState<string>(
|
||||||
|
formatDateInputValue(user.subscriptionExpiresAt),
|
||||||
|
);
|
||||||
|
const [isActive, setIsActive] = useState(user.isActive);
|
||||||
|
|
||||||
|
const { mutateAsync: updateRole, isPending: rolePending } =
|
||||||
|
useUpdateUserRole();
|
||||||
|
const { mutateAsync: updateSub, isPending: subPending } =
|
||||||
|
useUpdateUserSubscription();
|
||||||
|
const { mutateAsync: toggleActive, isPending: togglePending } =
|
||||||
|
useToggleUserActive();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
if (role !== user.role) {
|
||||||
|
await updateRole({ id: user.id, dto: { role } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentExpiresAtStr = user.subscriptionExpiresAt
|
||||||
|
? new Date(user.subscriptionExpiresAt).toISOString().split("T")[0]
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
plan !== user.subscriptionStatus ||
|
||||||
|
expiresAt !== currentExpiresAtStr
|
||||||
|
) {
|
||||||
|
await updateSub({
|
||||||
|
id: user.id,
|
||||||
|
dto: {
|
||||||
|
plan,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isActive !== user.isActive) {
|
||||||
|
await toggleActive(user.id);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = rolePending || subPending || togglePending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("edit-user-title", { email: user.email })}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
<Field label={t("user-role-field")}>
|
||||||
|
<NativeSelectRoot>
|
||||||
|
<NativeSelectField
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
items={[
|
||||||
|
{ label: t("standard-user"), value: "user" },
|
||||||
|
{ label: t("superadmin"), value: "superadmin" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={t("subscription-plan-field")}>
|
||||||
|
<NativeSelectRoot>
|
||||||
|
<NativeSelectField
|
||||||
|
value={plan}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newPlan = e.target.value;
|
||||||
|
setPlan(newPlan);
|
||||||
|
if (
|
||||||
|
(newPlan === "premium" || newPlan === "plus") &&
|
||||||
|
!expiresAt
|
||||||
|
) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 30);
|
||||||
|
setExpiresAt(d.toISOString().split("T")[0]);
|
||||||
|
} else if (
|
||||||
|
newPlan === "free" ||
|
||||||
|
newPlan === "cancelled" ||
|
||||||
|
newPlan === "past_due"
|
||||||
|
) {
|
||||||
|
setExpiresAt("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{ label: t("plan-free"), value: "free" },
|
||||||
|
{ label: t("plan-plus"), value: "plus" },
|
||||||
|
{ label: t("plan-premium"), value: "premium" },
|
||||||
|
{ label: t("plan-past-due"), value: "past_due" },
|
||||||
|
{ label: t("plan-cancelled"), value: "cancelled" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{plan !== "free" && (
|
||||||
|
<Field label={t("subscription-end-date")}>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => setExpiresAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label={t("account-active-question")}>
|
||||||
|
<Switch
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={(e) => setIsActive(e.checked)}
|
||||||
|
>
|
||||||
|
{isActive ? tCommon("active") : tCommon("inactive")}
|
||||||
|
</Switch>
|
||||||
|
</Field>
|
||||||
|
</VStack>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorPalette="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
{tCommon("save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
<DialogCloseTrigger />
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
SimpleGrid,
|
||||||
|
Card,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
Separator,
|
||||||
|
Input,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import {
|
||||||
|
NativeSelectRoot,
|
||||||
|
NativeSelectField,
|
||||||
|
} from "@/components/ui/forms/native-select";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { AnimatedCounter } from "@/components/motion";
|
||||||
|
import {
|
||||||
|
useLeagueTiers,
|
||||||
|
useLeagueTierStats,
|
||||||
|
useAddLeagueTier,
|
||||||
|
useUpdateLeagueTier,
|
||||||
|
useDeactivateLeagueTier,
|
||||||
|
useDeleteLeagueTier,
|
||||||
|
useSyncLeagueTiers,
|
||||||
|
useTriggerRetrain,
|
||||||
|
} from "@/lib/api/admin/use-hooks";
|
||||||
|
import { useLeagues } from "@/lib/api/leagues/use-hooks";
|
||||||
|
import type { LeagueTierDto } from "@/lib/api/admin/types";
|
||||||
|
import {
|
||||||
|
LuDiamond,
|
||||||
|
LuMedal,
|
||||||
|
LuShield,
|
||||||
|
LuPlus,
|
||||||
|
LuTrash2,
|
||||||
|
LuRefreshCw,
|
||||||
|
LuBrain,
|
||||||
|
LuSearch,
|
||||||
|
LuX,
|
||||||
|
LuChevronDown,
|
||||||
|
LuChevronUp,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
const TIER_CONFIG: Record<
|
||||||
|
number,
|
||||||
|
{ label: string; color: string; icon: React.ReactNode }
|
||||||
|
> = {
|
||||||
|
1: { label: "Elmas", color: "cyan", icon: <LuDiamond /> },
|
||||||
|
2: { label: "Altin", color: "yellow", icon: <LuMedal /> },
|
||||||
|
3: { label: "Gumus", color: "gray", icon: <LuShield /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeagueTiersContent() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [filterTier, setFilterTier] = useState<string>("");
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [addLeagueId, setAddLeagueId] = useState("");
|
||||||
|
const [addTier, setAddTier] = useState("2");
|
||||||
|
const [addNotes, setAddNotes] = useState("");
|
||||||
|
const [expandedTier, setExpandedTier] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||||
|
|
||||||
|
const { data: tiersData, isLoading: tiersLoading } = useLeagueTiers();
|
||||||
|
const { data: statsData, isLoading: statsLoading } = useLeagueTierStats();
|
||||||
|
const { data: leaguesData } = useLeagues();
|
||||||
|
|
||||||
|
const addMutation = useAddLeagueTier();
|
||||||
|
const updateMutation = useUpdateLeagueTier();
|
||||||
|
const deactivateMutation = useDeactivateLeagueTier();
|
||||||
|
const deleteMutation = useDeleteLeagueTier();
|
||||||
|
const syncMutation = useSyncLeagueTiers();
|
||||||
|
const retrainMutation = useTriggerRetrain();
|
||||||
|
|
||||||
|
const tiers = (tiersData?.data as LeagueTierDto[] | undefined) ?? [];
|
||||||
|
const stats = statsData?.data;
|
||||||
|
const leagues = leaguesData?.data ?? [];
|
||||||
|
|
||||||
|
// Group tiers by tier level
|
||||||
|
const tierGroups = useMemo(() => {
|
||||||
|
const groups: Record<number, LeagueTierDto[]> = { 1: [], 2: [], 3: [] };
|
||||||
|
tiers.forEach((t) => {
|
||||||
|
if (!groups[t.tier]) groups[t.tier] = [];
|
||||||
|
groups[t.tier].push(t);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [tiers]);
|
||||||
|
|
||||||
|
// Filter tiers
|
||||||
|
const filteredTiers = useMemo(() => {
|
||||||
|
let result = tiers;
|
||||||
|
if (filterTier) {
|
||||||
|
result = result.filter((t) => t.tier === parseInt(filterTier));
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(t) =>
|
||||||
|
t.league?.name?.toLowerCase().includes(q) ||
|
||||||
|
t.league?.country?.name?.toLowerCase().includes(q) ||
|
||||||
|
t.leagueId.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [tiers, filterTier, searchQuery]);
|
||||||
|
|
||||||
|
// Leagues not yet in tiers (for add form)
|
||||||
|
const availableLeagues = useMemo(() => {
|
||||||
|
const tierLeagueIds = new Set(tiers.map((t) => t.leagueId));
|
||||||
|
return (leagues as Array<{ id: string; name: string }>).filter(
|
||||||
|
(l) => !tierLeagueIds.has(l.id),
|
||||||
|
);
|
||||||
|
}, [leagues, tiers]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!addLeagueId) return;
|
||||||
|
await addMutation.mutateAsync({
|
||||||
|
leagueId: addLeagueId,
|
||||||
|
tier: parseInt(addTier),
|
||||||
|
notes: addNotes || undefined,
|
||||||
|
addedBy: "admin",
|
||||||
|
});
|
||||||
|
setAddLeagueId("");
|
||||||
|
setAddNotes("");
|
||||||
|
setShowAddForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTierChange = async (leagueId: string, newTier: number) => {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
leagueId,
|
||||||
|
dto: { tier: newTier },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async (leagueId: string) => {
|
||||||
|
await deactivateMutation.mutateAsync(leagueId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (leagueId: string) => {
|
||||||
|
await deleteMutation.mutateAsync(leagueId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tiersLoading) {
|
||||||
|
return (
|
||||||
|
<Flex justify="center" py={16}>
|
||||||
|
<Spinner size="lg" color="primary.500" />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={6} align="stretch">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4}>
|
||||||
|
{[1, 2, 3].map((tier) => {
|
||||||
|
const config = TIER_CONFIG[tier];
|
||||||
|
const count =
|
||||||
|
stats?.tiers?.find((t: { tier: number }) => t.tier === tier)
|
||||||
|
?.count ?? 0;
|
||||||
|
return (
|
||||||
|
<Card.Root
|
||||||
|
key={tier}
|
||||||
|
bg={cardBg}
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<Card.Body>
|
||||||
|
<HStack gap={3}>
|
||||||
|
<Flex
|
||||||
|
boxSize="40px"
|
||||||
|
bg={`${config.color}.subtle`}
|
||||||
|
borderRadius="lg"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
color={`${config.color}.fg`}
|
||||||
|
fontSize="lg"
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</Flex>
|
||||||
|
<VStack gap={0} align="flex-start">
|
||||||
|
<Text fontSize="xl" fontWeight="900" lineHeight="1">
|
||||||
|
<AnimatedCounter value={count} />
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{config.label}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||||
|
<Card.Body>
|
||||||
|
<HStack gap={3}>
|
||||||
|
<Flex
|
||||||
|
boxSize="40px"
|
||||||
|
bg="green.subtle"
|
||||||
|
borderRadius="lg"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
color="green.fg"
|
||||||
|
fontSize="lg"
|
||||||
|
>
|
||||||
|
<LuBrain />
|
||||||
|
</Flex>
|
||||||
|
<VStack gap={0} align="flex-start">
|
||||||
|
<Text fontSize="xl" fontWeight="900" lineHeight="1">
|
||||||
|
<AnimatedCounter
|
||||||
|
value={stats?.totalQualifiedMatches ?? 0}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Toplam Mac
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<HStack gap={2} flexWrap="wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
colorPalette="primary"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
>
|
||||||
|
<LuPlus />
|
||||||
|
Lig Ekle
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => syncMutation.mutate()}
|
||||||
|
disabled={syncMutation.isPending}
|
||||||
|
>
|
||||||
|
<LuRefreshCw />
|
||||||
|
{syncMutation.isPending ? "Senkronize ediliyor..." : "Sync JSON"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
colorPalette="purple"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => retrainMutation.mutate()}
|
||||||
|
disabled={retrainMutation.isPending}
|
||||||
|
>
|
||||||
|
<LuBrain />
|
||||||
|
{retrainMutation.isPending ? "Baslatiliyor..." : "Model Egit"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(syncMutation.isSuccess || retrainMutation.isSuccess) && (
|
||||||
|
<Badge colorPalette="green" variant="subtle" borderRadius="full">
|
||||||
|
Basarili
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Add League Form */}
|
||||||
|
{showAddForm && (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||||
|
<Card.Body>
|
||||||
|
<VStack gap={3} align="stretch">
|
||||||
|
<Heading size="sm">Yeni Lig Ekle</Heading>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} gap={3}>
|
||||||
|
<Input
|
||||||
|
placeholder="League ID gir..."
|
||||||
|
value={addLeagueId}
|
||||||
|
onChange={(e) => setAddLeagueId(e.target.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<NativeSelectRoot size="sm">
|
||||||
|
<NativeSelectField
|
||||||
|
value={addTier}
|
||||||
|
onChange={(e) => setAddTier(e.target.value)}
|
||||||
|
items={[
|
||||||
|
{ label: "Tier 1 - Elmas", value: "1" },
|
||||||
|
{ label: "Tier 2 - Altin", value: "2" },
|
||||||
|
{ label: "Tier 3 - Gumus", value: "3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
<Input
|
||||||
|
placeholder="Not (opsiyonel)"
|
||||||
|
value={addNotes}
|
||||||
|
onChange={(e) => setAddNotes(e.target.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<HStack>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
colorPalette="primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!addLeagueId || addMutation.isPending}
|
||||||
|
>
|
||||||
|
{addMutation.isPending ? "Ekleniyor..." : "Ekle"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAddForm(false)}
|
||||||
|
>
|
||||||
|
Iptal
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||||
|
<Card.Body py={3}>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
|
||||||
|
<HStack>
|
||||||
|
<LuSearch />
|
||||||
|
<Input
|
||||||
|
placeholder="Lig veya ulke ara..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
>
|
||||||
|
<LuX />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<NativeSelectRoot size="sm">
|
||||||
|
<NativeSelectField
|
||||||
|
placeholder="Tum Tierler"
|
||||||
|
value={filterTier}
|
||||||
|
onChange={(e) => setFilterTier(e.target.value)}
|
||||||
|
items={[
|
||||||
|
{ label: "Tier 1 - Elmas", value: "1" },
|
||||||
|
{ label: "Tier 2 - Altin", value: "2" },
|
||||||
|
{ label: "Tier 3 - Gumus", value: "3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Tier Groups */}
|
||||||
|
{[1, 2, 3].map((tier) => {
|
||||||
|
const config = TIER_CONFIG[tier];
|
||||||
|
const tierItems = filterTier
|
||||||
|
? filteredTiers.filter((t) => t.tier === tier)
|
||||||
|
: tierGroups[tier]?.filter((t) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
t.league?.name?.toLowerCase().includes(q) ||
|
||||||
|
t.league?.country?.name?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
if (filterTier && parseInt(filterTier) !== tier) return null;
|
||||||
|
|
||||||
|
const isExpanded = expandedTier === tier || expandedTier === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root
|
||||||
|
key={tier}
|
||||||
|
bg={cardBg}
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<Card.Body>
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
mb={isExpanded ? 3 : 0}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedTier(expandedTier === tier ? null : tier)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Badge
|
||||||
|
colorPalette={config.color}
|
||||||
|
variant="subtle"
|
||||||
|
borderRadius="full"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
Tier {tier} - {config.label}
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
({tierItems.length} lig)
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{isExpanded ? <LuChevronUp /> : <LuChevronDown />}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<VStack gap={0} align="stretch">
|
||||||
|
{/* Header */}
|
||||||
|
<Flex
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
bg="bg.muted"
|
||||||
|
borderRadius="lg"
|
||||||
|
mb={1}
|
||||||
|
fontWeight="semibold"
|
||||||
|
fontSize="xs"
|
||||||
|
color="fg.muted"
|
||||||
|
>
|
||||||
|
<Text flex={3}>Lig</Text>
|
||||||
|
<Text flex={2}>Ulke</Text>
|
||||||
|
<Text flex={1} textAlign="center">
|
||||||
|
Tier
|
||||||
|
</Text>
|
||||||
|
<Text flex={1} textAlign="center">
|
||||||
|
Durum
|
||||||
|
</Text>
|
||||||
|
<Text width="80px" textAlign="center">
|
||||||
|
Islem
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{tierItems.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
py={4}
|
||||||
|
textAlign="center"
|
||||||
|
color="fg.muted"
|
||||||
|
fontSize="sm"
|
||||||
|
>
|
||||||
|
Bu tier'da lig yok
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
tierItems.map((item, idx) => (
|
||||||
|
<Box key={item.id}>
|
||||||
|
{idx > 0 && <Separator />}
|
||||||
|
<Flex
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
align="center"
|
||||||
|
_hover={{ bg: "bg.muted" }}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
flex={3}
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="medium"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
{item.league?.name ?? item.leagueId}
|
||||||
|
</Text>
|
||||||
|
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
|
||||||
|
{item.league?.country?.name ?? "-"}
|
||||||
|
</Text>
|
||||||
|
<Flex flex={1} justify="center">
|
||||||
|
<NativeSelectRoot size="xs">
|
||||||
|
<NativeSelectField
|
||||||
|
value={String(item.tier)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTierChange(
|
||||||
|
item.leagueId,
|
||||||
|
parseInt(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{ label: "1", value: "1" },
|
||||||
|
{ label: "2", value: "2" },
|
||||||
|
{ label: "3", value: "3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</NativeSelectRoot>
|
||||||
|
</Flex>
|
||||||
|
<Flex flex={1} justify="center">
|
||||||
|
<Badge
|
||||||
|
colorPalette={item.isActive ? "green" : "gray"}
|
||||||
|
variant="subtle"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
{item.isActive ? "Aktif" : "Pasif"}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
<Flex width="80px" justify="center" gap={1}>
|
||||||
|
{item.isActive && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="orange"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeactivate(item.leagueId)
|
||||||
|
}
|
||||||
|
title="Pasif yap"
|
||||||
|
>
|
||||||
|
<LuX />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={() => handleDelete(item.leagueId)}
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,12 +41,7 @@ export default function AnalysisContent() {
|
|||||||
const toast = (opts: { title: string; status: string }) =>
|
const toast = (opts: { title: string; status: string }) =>
|
||||||
toaster.create({
|
toaster.create({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
type: opts.status as
|
type: opts.status as "success" | "warning" | "error" | "info" | "loading",
|
||||||
| "success"
|
|
||||||
| "warning"
|
|
||||||
| "error"
|
|
||||||
| "info"
|
|
||||||
| "loading",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleMatch = (id: string) => {
|
const toggleMatch = (id: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
|
import { Box, Heading, Input, Text, VStack, HStack } from "@chakra-ui/react";
|
||||||
import { Button } from "@/components/ui/buttons/button";
|
import { Button } from "@/components/ui/buttons/button";
|
||||||
import { Field } from "@/components/ui/forms/field";
|
import { Field } from "@/components/ui/forms/field";
|
||||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||||
@@ -19,37 +19,74 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { toaster } from "@/components/ui/feedback/toaster";
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { MdMail } from "react-icons/md";
|
import { MdMail } from "react-icons/md";
|
||||||
import { BiLock } from "react-icons/bi";
|
import { BiUser } from "react-icons/bi";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { authService } from "@/lib/api/auth/service";
|
||||||
|
|
||||||
const schema = yup.object({
|
/* ────────────────────────── Schemas ────────────────────────── */
|
||||||
|
|
||||||
|
const loginSchema = yup.object({
|
||||||
email: yup.string().email().required(),
|
email: yup.string().email().required(),
|
||||||
password: yup.string().min(6).required(),
|
password: yup.string().min(6).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginForm = yup.InferType<typeof schema>;
|
const registerSchema = yup.object({
|
||||||
|
name: yup.string().required(),
|
||||||
|
email: yup.string().email().required(),
|
||||||
|
password: yup.string().min(8).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = yup.InferType<typeof loginSchema>;
|
||||||
|
type RegisterForm = yup.InferType<typeof registerSchema>;
|
||||||
|
|
||||||
|
/* ────────────────────────── Props ────────────────────────── */
|
||||||
|
|
||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
initialMode?: "login" | "register";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
/* ────────────────────────── Component ────────────────────────── */
|
||||||
|
|
||||||
|
export function LoginModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
initialMode = "login",
|
||||||
|
}: LoginModalProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">(initialMode);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const {
|
// Update mode when modal opens
|
||||||
handleSubmit,
|
useEffect(() => {
|
||||||
register,
|
if (open) {
|
||||||
formState: { errors },
|
setMode(initialMode);
|
||||||
} = useForm<LoginForm>({
|
}
|
||||||
resolver: yupResolver(schema),
|
}, [open, initialMode]);
|
||||||
|
|
||||||
|
/* ── Login form ── */
|
||||||
|
const loginForm = useForm<LoginForm>({
|
||||||
|
resolver: yupResolver(loginSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (formData: LoginForm) => {
|
/* ── Register form ── */
|
||||||
|
const registerForm = useForm<RegisterForm>({
|
||||||
|
resolver: yupResolver(registerSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Switch mode ── */
|
||||||
|
const switchMode = (newMode: "login" | "register") => {
|
||||||
|
setMode(newMode);
|
||||||
|
loginForm.reset();
|
||||||
|
registerForm.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Handle login ── */
|
||||||
|
const onLogin = async (formData: LoginForm) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await signIn("credentials", {
|
const res = await signIn("credentials", {
|
||||||
@@ -64,12 +101,49 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
toaster.success({
|
toaster.success({
|
||||||
title: t("auth.login-success") || "Login successful!",
|
title: t("auth.login-success") || "Giriş başarılı!",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toaster.error({
|
toaster.error({
|
||||||
title: (error as Error).message || "Login failed!",
|
title: (error as Error).message || "Giriş yapılamadı!",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Handle register ── */
|
||||||
|
const onRegister = async (formData: RegisterForm) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await authService.register({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
firstName: formData.name,
|
||||||
|
lastName: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
toaster.success({
|
||||||
|
title: t("auth.register-success") || "Kayıt başarılı!",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toaster.error({
|
||||||
|
title: (error as Error).message || "Kayıt yapılamadı!",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,23 +152,67 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
<DialogRoot
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(e) => {
|
||||||
|
onOpenChange(e.open);
|
||||||
|
if (!e.open) {
|
||||||
|
// Reset to login when closing
|
||||||
|
setMode("login");
|
||||||
|
loginForm.reset();
|
||||||
|
registerForm.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Heading size="lg" color="primary.500">
|
<Heading as="span" size="lg" color="primary.500">
|
||||||
{t("auth.sign-in")}
|
{mode === "login" ? t("auth.sign-in") : t("auth.sign-up")}
|
||||||
</Heading>
|
</Heading>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogCloseTrigger />
|
<DialogCloseTrigger />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
|
{/* ────── Tab Switcher ────── */}
|
||||||
|
<HStack
|
||||||
|
gap={0}
|
||||||
|
mb={5}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={{ base: "gray.200", _dark: "gray.700" }}
|
||||||
|
borderRadius="xl"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "login" ? "solid" : "ghost"}
|
||||||
|
colorPalette={mode === "login" ? "primary" : "gray"}
|
||||||
|
borderRadius="0"
|
||||||
|
onClick={() => switchMode("login")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "register" ? "solid" : "ghost"}
|
||||||
|
colorPalette={mode === "register" ? "primary" : "gray"}
|
||||||
|
borderRadius="0"
|
||||||
|
onClick={() => switchMode("register")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* ────── LOGIN FORM ────── */}
|
||||||
|
{mode === "login" && (
|
||||||
|
<Box as="form" onSubmit={loginForm.handleSubmit(onLogin)}>
|
||||||
<VStack gap={4}>
|
<VStack gap={4}>
|
||||||
<Field
|
<Field
|
||||||
label={t("email")}
|
label={t("email")}
|
||||||
errorText={errors.email?.message}
|
errorText={loginForm.formState.errors.email?.message}
|
||||||
invalid={!!errors.email}
|
invalid={!!loginForm.formState.errors.email}
|
||||||
>
|
>
|
||||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||||
<Input
|
<Input
|
||||||
@@ -102,24 +220,23 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("email")}
|
placeholder={t("email")}
|
||||||
{...register("email")}
|
{...loginForm.register("email")}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("password")}
|
label={t("password")}
|
||||||
errorText={errors.password?.message}
|
errorText={loginForm.formState.errors.password?.message}
|
||||||
invalid={!!errors.password}
|
invalid={!!loginForm.formState.errors.password}
|
||||||
>
|
>
|
||||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
|
rootProps={{ w: "full" }}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
placeholder={t("password")}
|
placeholder={t("password")}
|
||||||
{...register("password")}
|
{...loginForm.register("password")}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -135,18 +252,98 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
|
|
||||||
<Text fontSize="sm" color="fg.muted">
|
<Text fontSize="sm" color="fg.muted">
|
||||||
{t("auth.dont-have-account")}{" "}
|
{t("auth.dont-have-account")}{" "}
|
||||||
<Link
|
<Text
|
||||||
href="/signup"
|
as="span"
|
||||||
style={{
|
color="primary.500"
|
||||||
color: "var(--chakra-colors-primary-500)",
|
fontWeight="bold"
|
||||||
fontWeight: "bold",
|
cursor="pointer"
|
||||||
}}
|
_hover={{ textDecoration: "underline" }}
|
||||||
|
onClick={() => switchMode("register")}
|
||||||
>
|
>
|
||||||
{t("auth.sign-up")}
|
{t("auth.sign-up")}
|
||||||
</Link>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ────── REGISTER FORM ────── */}
|
||||||
|
{mode === "register" && (
|
||||||
|
<Box as="form" onSubmit={registerForm.handleSubmit(onRegister)}>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Field
|
||||||
|
label={t("name")}
|
||||||
|
errorText={registerForm.formState.errors.name?.message}
|
||||||
|
invalid={!!registerForm.formState.errors.name}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
|
||||||
|
<Input
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("name")}
|
||||||
|
{...registerForm.register("name")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("email")}
|
||||||
|
errorText={registerForm.formState.errors.email?.message}
|
||||||
|
invalid={!!registerForm.formState.errors.email}
|
||||||
|
>
|
||||||
|
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||||
|
<Input
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("email")}
|
||||||
|
{...registerForm.register("email")}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("password")}
|
||||||
|
errorText={registerForm.formState.errors.password?.message}
|
||||||
|
invalid={!!registerForm.formState.errors.password}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
rootProps={{ w: "full" }}
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="sm"
|
||||||
|
placeholder={t("password")}
|
||||||
|
{...registerForm.register("password")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={loading}
|
||||||
|
type="submit"
|
||||||
|
bg="primary.400"
|
||||||
|
w="100%"
|
||||||
|
color="white"
|
||||||
|
_hover={{ bg: "primary.500" }}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{t("auth.already-have-an-account")}{" "}
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
color="primary.500"
|
||||||
|
fontWeight="bold"
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{ textDecoration: "underline" }}
|
||||||
|
onClick={() => switchMode("login")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-in")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
LuBadgeAlert,
|
LuBadgeAlert,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuCircleHelp,
|
LuCircleHelp,
|
||||||
|
LuDatabase,
|
||||||
LuEye,
|
LuEye,
|
||||||
LuEyeOff,
|
LuEyeOff,
|
||||||
LuLayers3,
|
LuLayers3,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
import { SlideUp } from "@/components/motion";
|
import { SlideUp } from "@/components/motion";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||||
|
import FrequencyPanel from "@/components/coupons/frequency-panel";
|
||||||
import { useSuggestCoupon } from "@/lib/api/coupons/use-hooks";
|
import { useSuggestCoupon } from "@/lib/api/coupons/use-hooks";
|
||||||
import type {
|
import type {
|
||||||
CouponItemDto,
|
CouponItemDto,
|
||||||
@@ -352,6 +354,7 @@ export default function CouponBuilderContent() {
|
|||||||
SmartCouponResultDto | undefined
|
SmartCouponResultDto | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [matchCount, setMatchCount] = React.useState<number>(5); // Default: 5 matches
|
const [matchCount, setMatchCount] = React.useState<number>(5); // Default: 5 matches
|
||||||
|
const [engineMode, setEngineMode] = React.useState<"ai" | "frequency">("ai");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!upcomingQuery.data && !upcomingQuery.isPending) {
|
if (!upcomingQuery.data && !upcomingQuery.isPending) {
|
||||||
@@ -763,6 +766,63 @@ export default function CouponBuilderContent() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body pt={0}>
|
<Card.Body pt={0}>
|
||||||
|
{/* Engine Mode Toggle */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon
|
||||||
|
as={engineMode === "ai" ? LuSparkles : LuDatabase}
|
||||||
|
color={engineMode === "ai" ? "teal.500" : "cyan.500"}
|
||||||
|
/>
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("engine-mode-label")}
|
||||||
|
</Text>
|
||||||
|
<InfoIcon
|
||||||
|
content={t("engine-mode-help")}
|
||||||
|
label={t("engine-mode-label")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Badge
|
||||||
|
colorPalette={engineMode === "ai" ? "teal" : "gray"}
|
||||||
|
variant={engineMode === "ai" ? "solid" : "outline"}
|
||||||
|
cursor="pointer"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
onClick={() => setEngineMode("ai")}
|
||||||
|
>
|
||||||
|
<LuSparkles /> AI
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
colorPalette={
|
||||||
|
engineMode === "frequency" ? "cyan" : "gray"
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
engineMode === "frequency" ? "solid" : "outline"
|
||||||
|
}
|
||||||
|
cursor="pointer"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
onClick={() => setEngineMode("frequency")}
|
||||||
|
>
|
||||||
|
<LuDatabase /> Frekans
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color={engineMode === "ai" ? "teal.500" : "cyan.500"}
|
||||||
|
>
|
||||||
|
{engineMode === "ai"
|
||||||
|
? t("ai-mode-active")
|
||||||
|
: t("freq-mode-active")}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Separator mb={4} />
|
||||||
|
|
||||||
|
{engineMode === "frequency" ? (
|
||||||
|
<FrequencyPanel />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Text
|
<Text
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
color="fg.muted"
|
color="fg.muted"
|
||||||
@@ -780,7 +840,9 @@ export default function CouponBuilderContent() {
|
|||||||
key={entry.key}
|
key={entry.key}
|
||||||
p={3}
|
p={3}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={active ? `${palette}.400` : borderColor}
|
borderColor={
|
||||||
|
active ? `${palette}.400` : borderColor
|
||||||
|
}
|
||||||
bg={active ? `${palette}.50` : mutedBg}
|
bg={active ? `${palette}.50` : mutedBg}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
@@ -793,7 +855,9 @@ export default function CouponBuilderContent() {
|
|||||||
>
|
>
|
||||||
{entry.label}
|
{entry.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
{active ? <LuCheck color="currentColor" /> : null}
|
{active ? (
|
||||||
|
<LuCheck color="currentColor" />
|
||||||
|
) : null}
|
||||||
</HStack>
|
</HStack>
|
||||||
<Text fontSize="sm" color="fg.muted">
|
<Text fontSize="sm" color="fg.muted">
|
||||||
{entry.description}
|
{entry.description}
|
||||||
@@ -827,7 +891,9 @@ export default function CouponBuilderContent() {
|
|||||||
min="2"
|
min="2"
|
||||||
max="15"
|
max="15"
|
||||||
value={matchCount}
|
value={matchCount}
|
||||||
onChange={(e) => setMatchCount(Number(e.target.value))}
|
onChange={(e) =>
|
||||||
|
setMatchCount(Number(e.target.value))
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
accentColor: "teal",
|
accentColor: "teal",
|
||||||
@@ -841,7 +907,9 @@ export default function CouponBuilderContent() {
|
|||||||
>
|
>
|
||||||
<Text>2</Text>
|
<Text>2</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{t("match-count-auto", { count: allMatches.length })}
|
{t("match-count-auto", {
|
||||||
|
count: allMatches.length,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>15</Text>
|
<Text>15</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -920,6 +988,8 @@ export default function CouponBuilderContent() {
|
|||||||
? t("manual-selection-helper")
|
? t("manual-selection-helper")
|
||||||
: t("automatic-selection-helper")}
|
: t("automatic-selection-helper")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Separator,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LuBadgeAlert,
|
||||||
|
LuChartBar,
|
||||||
|
LuCircleHelp,
|
||||||
|
LuDatabase,
|
||||||
|
LuTrendingUp,
|
||||||
|
LuZap,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||||
|
import { useGenerateFrequencyCoupon } from "@/lib/api/coupons/use-hooks";
|
||||||
|
import type {
|
||||||
|
FrequencyCouponResultDto,
|
||||||
|
FrequencyCouponBetDto,
|
||||||
|
} from "@/lib/api/coupons/types";
|
||||||
|
import { ApiError } from "@/lib/api/create-api-client";
|
||||||
|
import { useCouponStore } from "@/lib/stores/coupon-store";
|
||||||
|
|
||||||
|
const AVAILABLE_MARKETS = ["OU1.5", "OU2.5", "OU3.5", "BTTS", "MS"];
|
||||||
|
|
||||||
|
function InfoIcon({ content, label }: { content: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={content}
|
||||||
|
showArrow
|
||||||
|
positioning={{ placement: "top" }}
|
||||||
|
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label={label}
|
||||||
|
variant="ghost"
|
||||||
|
size="2xs"
|
||||||
|
colorPalette="gray"
|
||||||
|
>
|
||||||
|
<LuCircleHelp />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileColor = (p: string) =>
|
||||||
|
({ GOLCU: "red", DEFANSIF: "blue", NORMAL: "gray" })[p] || "gray";
|
||||||
|
|
||||||
|
const profileLabel = (p: string, t: ReturnType<typeof useTranslations>) =>
|
||||||
|
({
|
||||||
|
GOLCU: t("freq-league-golcu"),
|
||||||
|
DEFANSIF: t("freq-league-defansif"),
|
||||||
|
NORMAL: t("freq-league-normal"),
|
||||||
|
})[p] || p;
|
||||||
|
|
||||||
|
export default function FrequencyPanel() {
|
||||||
|
const t = useTranslations("coupons");
|
||||||
|
const { addItem, clearCoupon } = useCouponStore();
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const mutedBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
|
||||||
|
const freqMutation = useGenerateFrequencyCoupon();
|
||||||
|
|
||||||
|
const [minSignal, setMinSignal] = React.useState(0.65);
|
||||||
|
const [maxMatches, setMaxMatches] = React.useState(3);
|
||||||
|
const [selectedMarkets, setSelectedMarkets] = React.useState<string[]>([]);
|
||||||
|
const [result, setResult] = React.useState<
|
||||||
|
FrequencyCouponResultDto | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const toggleMarket = (m: string) =>
|
||||||
|
setSelectedMarkets((prev) =>
|
||||||
|
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
freqMutation.mutate(
|
||||||
|
{
|
||||||
|
maxMatches,
|
||||||
|
minSignal,
|
||||||
|
markets: selectedMarkets.length > 0 ? selectedMarkets : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const data = (response as any)?.data ?? response;
|
||||||
|
setResult(data as FrequencyCouponResultDto);
|
||||||
|
// Sync to coupon store
|
||||||
|
if (data && Array.isArray((data as any).bets)) {
|
||||||
|
clearCoupon();
|
||||||
|
(data as FrequencyCouponResultDto).bets.forEach(
|
||||||
|
(bet: FrequencyCouponBetDto) =>
|
||||||
|
addItem({
|
||||||
|
matchId: bet.match_id,
|
||||||
|
matchName: bet.match_name,
|
||||||
|
market: bet.market,
|
||||||
|
pick: bet.pick,
|
||||||
|
odd: bet.odds,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => setResult(undefined),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
freqMutation.error instanceof ApiError
|
||||||
|
? freqMutation.error.message
|
||||||
|
: freqMutation.error instanceof Error
|
||||||
|
? freqMutation.error.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
{/* Controls Card */}
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Header>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuDatabase} color="cyan.500" boxSize={4.5} />
|
||||||
|
<Heading as="h3" size="sm">
|
||||||
|
{t("freq-engine-title")}
|
||||||
|
</Heading>
|
||||||
|
<InfoIcon
|
||||||
|
label={t("freq-engine-title")}
|
||||||
|
content={t("freq-engine-subtitle")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="fg.muted" mt={1}>
|
||||||
|
{t("freq-engine-subtitle")}
|
||||||
|
</Text>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
{/* Min Signal Slider */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuTrendingUp} color="cyan.500" />
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("freq-min-signal")}
|
||||||
|
</Text>
|
||||||
|
<InfoIcon
|
||||||
|
content={t("freq-min-signal-help")}
|
||||||
|
label={t("freq-min-signal")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Badge colorPalette="cyan" variant="subtle">
|
||||||
|
{(minSignal * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="50"
|
||||||
|
max="95"
|
||||||
|
value={minSignal * 100}
|
||||||
|
onChange={(e) => setMinSignal(Number(e.target.value) / 100)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
accentColor: "#0891b2",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
||||||
|
<Text>50%</Text>
|
||||||
|
<Text>95%</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Max Matches */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuChartBar} color="purple.500" />
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("match-count-label")}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge colorPalette="purple" variant="subtle">
|
||||||
|
{maxMatches}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="5"
|
||||||
|
value={maxMatches}
|
||||||
|
onChange={(e) => setMaxMatches(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
accentColor: "#9333ea",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
||||||
|
<Text>2</Text>
|
||||||
|
<Text>5</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Separator mb={4} />
|
||||||
|
|
||||||
|
{/* Market Filter */}
|
||||||
|
<VStack align="stretch" gap={2} mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{t("freq-markets")}
|
||||||
|
</Text>
|
||||||
|
<InfoIcon
|
||||||
|
content={t("freq-markets-help")}
|
||||||
|
label={t("freq-markets")}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={2} flexWrap="wrap">
|
||||||
|
{AVAILABLE_MARKETS.map((m) => {
|
||||||
|
const active = selectedMarkets.includes(m);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
colorPalette={active ? "cyan" : "gray"}
|
||||||
|
variant={active ? "solid" : "outline"}
|
||||||
|
cursor="pointer"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
onClick={() => toggleMarket(m)}
|
||||||
|
_hover={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
{selectedMarkets.length === 0 && (
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Tüm marketler taranacak
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
colorPalette="cyan"
|
||||||
|
size="lg"
|
||||||
|
width="full"
|
||||||
|
borderRadius="xl"
|
||||||
|
loading={freqMutation.isPending}
|
||||||
|
loadingText={t("freq-suggest-loading")}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
>
|
||||||
|
<LuZap />
|
||||||
|
{t("freq-suggest")}
|
||||||
|
</Button>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Results Card */}
|
||||||
|
{result && result.bets.length > 0 && (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Header>
|
||||||
|
<HStack justify="space-between" align="center">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuZap} color="cyan.500" boxSize={4.5} />
|
||||||
|
<Heading as="h3" size="sm">
|
||||||
|
{t("freq-engine-title")}
|
||||||
|
</Heading>
|
||||||
|
</HStack>
|
||||||
|
<Badge
|
||||||
|
colorPalette={result.ev_positive ? "green" : "red"}
|
||||||
|
variant="solid"
|
||||||
|
>
|
||||||
|
{result.ev_positive
|
||||||
|
? t("freq-ev-positive")
|
||||||
|
: t("freq-ev-negative")}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
<VStack align="stretch" gap={3}>
|
||||||
|
{/* EV Stats */}
|
||||||
|
<Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={3}>
|
||||||
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-ev-label")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="lg"
|
||||||
|
color={result.ev_positive ? "green.500" : "red.500"}
|
||||||
|
>
|
||||||
|
{result.expected_value.toFixed(3)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-hit-rate")}
|
||||||
|
</Text>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" color="cyan.500">
|
||||||
|
{(result.expected_hit_rate * 100).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("total-odds")}
|
||||||
|
</Text>
|
||||||
|
<Text fontWeight="bold" fontSize="lg" color="purple.500">
|
||||||
|
{result.total_odds.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Bets */}
|
||||||
|
{result.bets.map((bet: FrequencyCouponBetDto) => (
|
||||||
|
<Box
|
||||||
|
key={`${bet.match_id}-${bet.market}`}
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
bg={mutedBg}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
gap={3}
|
||||||
|
mb={3}
|
||||||
|
>
|
||||||
|
<VStack align="flex-start" gap={1}>
|
||||||
|
<Text fontWeight="bold">{bet.match_name}</Text>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{bet.league} • {bet.market}: {bet.pick}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Badge colorPalette="cyan" variant="solid">
|
||||||
|
{bet.odds.toFixed(2)}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
<Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={2}>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-home-signal")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
{(bet.home_signal * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{bet.home_odds_band}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-away-signal")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
{(bet.away_signal * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{bet.away_odds_band}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
||||||
|
{t("freq-combined-signal")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="cyan.500">
|
||||||
|
{(bet.combined_signal * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<HStack gap={2} mt={3} flexWrap="wrap">
|
||||||
|
<Badge
|
||||||
|
colorPalette={profileColor(bet.league_profile)}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t("freq-league-profile")}:{" "}
|
||||||
|
{profileLabel(bet.league_profile, t)}
|
||||||
|
</Badge>
|
||||||
|
<Badge colorPalette="gray" variant="subtle">
|
||||||
|
{t("freq-match-count")}: {bet.home_match_count}/
|
||||||
|
{bet.away_match_count}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Reasoning */}
|
||||||
|
{result.reasoning.length > 0 && (
|
||||||
|
<Box p={4} bg={mutedBg} borderRadius="xl">
|
||||||
|
<Text fontWeight="semibold" fontSize="sm" mb={2}>
|
||||||
|
{t("freq-reasoning-title")}
|
||||||
|
</Text>
|
||||||
|
<VStack align="stretch" gap={1}>
|
||||||
|
{result.reasoning.map((r, i) => (
|
||||||
|
<Text key={i} fontSize="xs" color="fg.muted">
|
||||||
|
• {r}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rejected */}
|
||||||
|
{result.rejected_matches.length > 0 && (
|
||||||
|
<Box p={4} bg="orange.50" borderRadius="xl">
|
||||||
|
<HStack gap={2} mb={2}>
|
||||||
|
<Icon as={LuBadgeAlert} color="orange.500" />
|
||||||
|
<Text fontWeight="semibold">
|
||||||
|
{t("rejected-matches-title")}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<VStack align="stretch" gap={1}>
|
||||||
|
{result.rejected_matches.map((entry, i) => (
|
||||||
|
<Text key={i} fontSize="sm" color="fg.muted">
|
||||||
|
{entry.match_name}: {entry.reason}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No result message */}
|
||||||
|
{result && result.bets.length === 0 && (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Body py={8}>
|
||||||
|
<Text textAlign="center" color="fg.muted">
|
||||||
|
{t("freq-no-result")}
|
||||||
|
</Text>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{errorMessage && (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg="red.50"
|
||||||
|
borderRadius="xl"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="red.200"
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="red.700">
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { SlideUp, StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
import {
|
||||||
|
SlideUp,
|
||||||
|
StaggerContainer,
|
||||||
|
StaggerItem,
|
||||||
|
ScrollSlideUp,
|
||||||
|
} from "@/components/motion";
|
||||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||||
import { MatchCard } from "@/components/matches";
|
import { MatchCard } from "@/components/matches";
|
||||||
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
|
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
|
||||||
@@ -26,8 +31,14 @@ import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu";
|
import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { LeagueWithMatchesDto, MatchResponseDto } from "@/lib/api/matches/types";
|
import type {
|
||||||
import type { MatchPredictionDto, ValueBetDto } from "@/lib/api/predictions/types";
|
LeagueWithMatchesDto,
|
||||||
|
MatchResponseDto,
|
||||||
|
} from "@/lib/api/matches/types";
|
||||||
|
import type {
|
||||||
|
MatchPredictionDto,
|
||||||
|
ValueBetDto,
|
||||||
|
} from "@/lib/api/predictions/types";
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Stats Card
|
// Stats Card
|
||||||
@@ -181,8 +192,11 @@ export default function DashboardContent() {
|
|||||||
queryMatches.mutate({ sport: "football", limit: 20 });
|
queryMatches.mutate({ sport: "football", limit: 20 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayMatches: MatchResponseDto[] = queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ?? [];
|
const todayMatches: MatchResponseDto[] =
|
||||||
const upcomingPredictions: MatchPredictionDto[] = upcomingData?.data?.matches ?? [];
|
queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ??
|
||||||
|
[];
|
||||||
|
const upcomingPredictions: MatchPredictionDto[] =
|
||||||
|
upcomingData?.data?.matches ?? [];
|
||||||
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
|
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
|
||||||
const userStats = statsData?.data;
|
const userStats = statsData?.data;
|
||||||
|
|
||||||
@@ -328,7 +342,9 @@ export default function DashboardContent() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
) : upcomingPredictions.length > 0 ? (
|
) : upcomingPredictions.length > 0 ? (
|
||||||
<VStack gap={2} align="stretch">
|
<VStack gap={2} align="stretch">
|
||||||
{upcomingPredictions.slice(0, 4).map((pred: MatchPredictionDto, idx: number) => (
|
{upcomingPredictions
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((pred: MatchPredictionDto, idx: number) => (
|
||||||
<Box
|
<Box
|
||||||
key={idx}
|
key={idx}
|
||||||
p={2.5}
|
p={2.5}
|
||||||
@@ -396,7 +412,9 @@ export default function DashboardContent() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
) : valueBets.length > 0 ? (
|
) : valueBets.length > 0 ? (
|
||||||
<VStack gap={2} align="stretch">
|
<VStack gap={2} align="stretch">
|
||||||
{valueBets.slice(0, 5).map((vb: ValueBetDto, idx: number) => (
|
{valueBets
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((vb: ValueBetDto, idx: number) => (
|
||||||
<ValueBetMiniCard
|
<ValueBetMiniCard
|
||||||
key={idx}
|
key={idx}
|
||||||
matchName={vb.matchName}
|
matchName={vb.matchName}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { SlideUp } from "@/components/motion";
|
import { SlideUp } from "@/components/motion";
|
||||||
import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks";
|
import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks";
|
||||||
import type { TeamDto, HeadToHeadDto } from "@/lib/api/leagues/types";
|
import type { TeamDto } from "@/lib/api/leagues/types";
|
||||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||||
import { LuSearch, LuArrowLeftRight } from "react-icons/lu";
|
import { LuSearch, LuArrowLeftRight } from "react-icons/lu";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
function TeamSearchInput({
|
function TeamSearchInput({
|
||||||
@@ -134,7 +134,7 @@ export default function H2HContent() {
|
|||||||
?.data
|
?.data
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: team1?.name || t("team1"),
|
label: team1?.name || t("team-1"),
|
||||||
value: h2h.data.data.team1Wins,
|
value: h2h.data.data.team1Wins,
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,7 @@ export default function H2HContent() {
|
|||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: team2?.name || t("team2"),
|
label: team2?.name || t("team-2"),
|
||||||
value: h2h.data.data.team2Wins,
|
value: h2h.data.data.team2Wins,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -309,7 +309,11 @@ export default function HomeContent() {
|
|||||||
shadow="lg"
|
shadow="lg"
|
||||||
>
|
>
|
||||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={6}>
|
<SimpleGrid columns={{ base: 2, md: 4 }} gap={6}>
|
||||||
<StatBlock value={15000} label={t("stats-predictions")} suffix="+" />
|
<StatBlock
|
||||||
|
value={15000}
|
||||||
|
label={t("stats-predictions")}
|
||||||
|
suffix="+"
|
||||||
|
/>
|
||||||
<StatBlock value={72} label={t("stats-accuracy")} suffix="%" />
|
<StatBlock value={72} label={t("stats-accuracy")} suffix="%" />
|
||||||
<StatBlock value={3200} label={t("stats-users")} suffix="+" />
|
<StatBlock value={3200} label={t("stats-users")} suffix="+" />
|
||||||
<StatBlock value={50000} label={t("stats-matches")} suffix="+" />
|
<StatBlock value={50000} label={t("stats-matches")} suffix="+" />
|
||||||
@@ -320,7 +324,13 @@ export default function HomeContent() {
|
|||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<Box mb={16}>
|
<Box mb={16}>
|
||||||
<ScrollScaleIn>
|
<ScrollScaleIn>
|
||||||
<Heading as="h2" size="xl" textAlign="center" mb={3} fontWeight="bold">
|
<Heading
|
||||||
|
as="h2"
|
||||||
|
size="xl"
|
||||||
|
textAlign="center"
|
||||||
|
mb={3}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
{t("features-title")}
|
{t("features-title")}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function Footer() {
|
|||||||
focusRing="none"
|
focusRing="none"
|
||||||
fontWeight="semibold"
|
fontWeight="semibold"
|
||||||
>
|
>
|
||||||
Suggest Bet
|
iddaai
|
||||||
</ChakraLink>
|
</ChakraLink>
|
||||||
. {t("all-right-reserved")}
|
. {t("all-right-reserved")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -61,6 +61,20 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
{t("terms-of-service")}
|
{t("terms-of-service")}
|
||||||
</ChakraLink>
|
</ChakraLink>
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href="/refund-policy"
|
||||||
|
fontSize="sm"
|
||||||
|
color="fg.muted"
|
||||||
|
focusRing="none"
|
||||||
|
textDecor="none"
|
||||||
|
transition="color 0.2s"
|
||||||
|
_hover={{
|
||||||
|
color: { base: "primary.500", _dark: "primary.300" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("refund-policy")}
|
||||||
|
</ChakraLink>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ClientOnly,
|
ClientOnly,
|
||||||
Text,
|
Text,
|
||||||
Separator,
|
Separator,
|
||||||
|
Badge,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { Link, useRouter } from "@/i18n/navigation";
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
import { ColorModeButton } from "@/components/ui/color-mode";
|
import { ColorModeButton } from "@/components/ui/color-mode";
|
||||||
@@ -40,19 +41,27 @@ import { signOut, useSession } from "next-auth/react";
|
|||||||
import { authConfig } from "@/config/auth";
|
import { authConfig } from "@/config/auth";
|
||||||
import { LoginModal } from "@/components/auth/login-modal";
|
import { LoginModal } from "@/components/auth/login-modal";
|
||||||
import { isAdminRole } from "@/lib/auth/roles";
|
import { isAdminRole } from "@/lib/auth/roles";
|
||||||
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
|
import { LuLogIn, LuUser, LuShield, LuZap, LuCrown } from "react-icons/lu";
|
||||||
|
import { PlanBadge } from "@/components/subscription";
|
||||||
import GlobalSearch from "@/components/search/global-search";
|
import GlobalSearch from "@/components/search/global-search";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useGetMe } from "@/lib/api/users/use-hooks";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">(
|
||||||
|
"login",
|
||||||
|
);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const isAuthenticated = !!session;
|
const isAuthenticated = !!session;
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
|
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
|
||||||
|
const { data: meData } = useGetMe(isAuthenticated);
|
||||||
|
const usageLimit = meData?.data?.usageLimit;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => setIsSticky(window.scrollY >= 10);
|
const handleScroll = () => setIsSticky(window.scrollY >= 10);
|
||||||
@@ -63,16 +72,39 @@ export default function Header() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut({ redirect: false });
|
await signOut({ redirect: false });
|
||||||
if (authConfig.isAuthRequired) {
|
if (authConfig.isAuthRequired) {
|
||||||
router.replace("/signin");
|
router.replace("/home");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAuthModal = (mode: "login" | "register") => {
|
||||||
|
setLoginModalMode(mode);
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Desktop auth section
|
// Desktop auth section
|
||||||
const renderAuthSection = () => {
|
const renderAuthSection = () => {
|
||||||
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
|
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
|
<HStack gap={2}>
|
||||||
|
{usageLimit && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
colorPalette={
|
||||||
|
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
|
||||||
|
? "green"
|
||||||
|
: "red"
|
||||||
|
}
|
||||||
|
variant="subtle"
|
||||||
|
display={{ base: "none", sm: "inline-flex" }}
|
||||||
|
>
|
||||||
|
<LuZap style={{ marginRight: "4px" }} />
|
||||||
|
{usageLimit.maxAnalyses - usageLimit.analysisCount}{" "}
|
||||||
|
{t("common.limits.analysis_left", { defaultValue: "Analiz" })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
|
||||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||||
<MenuTrigger rounded="full" focusRing="none">
|
<MenuTrigger rounded="full" focusRing="none">
|
||||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||||
@@ -82,6 +114,15 @@ export default function Header() {
|
|||||||
<LuUser />
|
<LuUser />
|
||||||
{t("nav.profile")}
|
{t("nav.profile")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
|
||||||
|
<MenuItem
|
||||||
|
value="pricing"
|
||||||
|
onClick={() => router.push("/pricing")}
|
||||||
|
>
|
||||||
|
<LuCrown />
|
||||||
|
{t("nav.pricing")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{session?.user && isAdminRole(session.user.roles) && (
|
{session?.user && isAdminRole(session.user.roles) && (
|
||||||
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
||||||
<LuShield />
|
<LuShield />
|
||||||
@@ -93,20 +134,32 @@ export default function Header() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuContent>
|
</MenuContent>
|
||||||
</MenuRoot>
|
</MenuRoot>
|
||||||
|
</HStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
colorPalette="gray"
|
||||||
|
size="sm"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => openAuthModal("register")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
colorPalette="primary"
|
colorPalette="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
onClick={() => setLoginModalOpen(true)}
|
onClick={() => openAuthModal("login")}
|
||||||
>
|
>
|
||||||
<LuLogIn />
|
<LuLogIn />
|
||||||
{t("auth.sign-in")}
|
{t("auth.sign-in")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,9 +176,24 @@ export default function Header() {
|
|||||||
variant="solid"
|
variant="solid"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Text fontSize="sm" fontWeight="semibold" truncate>
|
<Text fontSize="sm" fontWeight="semibold" truncate flex={1}>
|
||||||
{session?.user?.name || session?.user?.email}
|
{session?.user?.name || session?.user?.email}
|
||||||
</Text>
|
</Text>
|
||||||
|
{usageLimit && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
colorPalette={
|
||||||
|
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
|
||||||
|
? "green"
|
||||||
|
: "red"
|
||||||
|
}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<LuZap style={{ marginRight: "4px" }} />
|
||||||
|
{usageLimit.maxAnalyses - usageLimit.analysisCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
|
||||||
</Flex>
|
</Flex>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -137,6 +205,18 @@ export default function Header() {
|
|||||||
<LuUser />
|
<LuUser />
|
||||||
{t("nav.profile")}
|
{t("nav.profile")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
width="full"
|
||||||
|
colorPalette="primary"
|
||||||
|
onClick={() => router.push("/pricing")}
|
||||||
|
>
|
||||||
|
<LuCrown />
|
||||||
|
{t("nav.pricing")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="surface"
|
variant="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -150,17 +230,29 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<VStack gap={2} w="full">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
colorPalette="gray"
|
||||||
|
size="sm"
|
||||||
|
width="full"
|
||||||
|
borderRadius="full"
|
||||||
|
onClick={() => openAuthModal("register")}
|
||||||
|
>
|
||||||
|
{t("auth.sign-up")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
colorPalette="primary"
|
colorPalette="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
width="full"
|
width="full"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
onClick={() => setLoginModalOpen(true)}
|
onClick={() => openAuthModal("login")}
|
||||||
>
|
>
|
||||||
<LuLogIn />
|
<LuLogIn />
|
||||||
{t("auth.sign-in")}
|
{t("auth.sign-in")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,36 +290,22 @@ export default function Header() {
|
|||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
mr={6}
|
mr={6}
|
||||||
>
|
>
|
||||||
<Flex
|
<img
|
||||||
boxSize="32px"
|
src="/logo.png"
|
||||||
bg="primary.500"
|
alt="iddaai logo"
|
||||||
borderRadius="lg"
|
width={36}
|
||||||
align="center"
|
height={36}
|
||||||
justify="center"
|
style={{ objectFit: "contain" }}
|
||||||
shadow="sm"
|
/>
|
||||||
>
|
|
||||||
<LuZap color="white" size={18} />
|
|
||||||
</Flex>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text
|
<Text
|
||||||
fontSize="md"
|
fontSize="xl"
|
||||||
fontWeight="800"
|
fontWeight="900"
|
||||||
lineHeight="1"
|
lineHeight="1"
|
||||||
color={{ base: "gray.900", _dark: "white" }}
|
color={{ base: "gray.900", _dark: "white" }}
|
||||||
letterSpacing="-0.02em"
|
letterSpacing="-0.04em"
|
||||||
>
|
>
|
||||||
Suggest
|
iddaai
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="600"
|
|
||||||
lineHeight="1"
|
|
||||||
mt="1px"
|
|
||||||
color={{ base: "primary.600", _dark: "primary.300" }}
|
|
||||||
letterSpacing="0.08em"
|
|
||||||
textTransform="uppercase"
|
|
||||||
>
|
|
||||||
BET
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</ChakraLink>
|
</ChakraLink>
|
||||||
@@ -273,6 +351,10 @@ export default function Header() {
|
|||||||
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
||||||
<PopoverBody>
|
<PopoverBody>
|
||||||
<VStack mt="2" align="start" spaceY="2" w="full">
|
<VStack mt="2" align="start" spaceY="2" w="full">
|
||||||
|
{/* Mobile Search */}
|
||||||
|
<Box w="full">
|
||||||
|
<GlobalSearch />
|
||||||
|
</Box>
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<MobileHeaderLink key={item.href} item={item} />
|
<MobileHeaderLink key={item.href} item={item} />
|
||||||
))}
|
))}
|
||||||
@@ -296,7 +378,11 @@ export default function Header() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Login Modal */}
|
{/* Login Modal */}
|
||||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onOpenChange={setLoginModalOpen}
|
||||||
|
initialMode={loginModalMode}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
Spinner,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Link as ChakraLink } from "@chakra-ui/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { SlideUp } from "@/components/motion";
|
||||||
|
import { useLeagueById } from "@/lib/api/leagues/use-hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { matchesService } from "@/lib/api/matches/service";
|
||||||
|
import MatchList from "@/components/matches/match-list";
|
||||||
|
import { LuTrophy, LuMapPin, LuArrowLeft } from "react-icons/lu";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
export default function LeagueDetailContent({
|
||||||
|
leagueId,
|
||||||
|
}: {
|
||||||
|
leagueId: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("leagues");
|
||||||
|
|
||||||
|
const leagueQuery = useLeagueById(leagueId);
|
||||||
|
const league = leagueQuery.data?.data;
|
||||||
|
|
||||||
|
const matchesQuery = useQuery({
|
||||||
|
queryKey: ["league-matches", leagueId, league?.sport],
|
||||||
|
queryFn: () =>
|
||||||
|
matchesService.queryMatches({
|
||||||
|
sport: league?.sport || "football",
|
||||||
|
leagueId: leagueId,
|
||||||
|
status: "Finished",
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
enabled: !!league,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bgGradient = useColorModeValue(
|
||||||
|
"linear(to-r, primary.500, primary.700)",
|
||||||
|
"linear(to-r, primary.600, primary.900)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const flatMatches = matchesQuery.data?.data?.[0]?.matches || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box minH="calc(100vh - 80px)">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Box
|
||||||
|
bgGradient={bgGradient}
|
||||||
|
color="white"
|
||||||
|
pt={16}
|
||||||
|
pb={20}
|
||||||
|
px={6}
|
||||||
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="-20%"
|
||||||
|
right="-10%"
|
||||||
|
opacity={0.1}
|
||||||
|
transform="rotate(15deg)"
|
||||||
|
>
|
||||||
|
<LuTrophy size={400} />
|
||||||
|
</Box>
|
||||||
|
<Box maxW="7xl" mx="auto" position="relative" zIndex={1}>
|
||||||
|
<SlideUp>
|
||||||
|
<VStack align="flex-start" gap={4} maxW="3xl">
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
href="/leagues"
|
||||||
|
color="whiteAlpha.900"
|
||||||
|
_hover={{ color: "white" }}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={2}
|
||||||
|
mb={2}
|
||||||
|
fontWeight="medium"
|
||||||
|
>
|
||||||
|
<LuArrowLeft /> Liglere Dön
|
||||||
|
</ChakraLink>
|
||||||
|
|
||||||
|
{leagueQuery.isLoading ? (
|
||||||
|
<Spinner color="white" borderWidth="3px" size="xl" />
|
||||||
|
) : league ? (
|
||||||
|
<>
|
||||||
|
<HStack gap={3}>
|
||||||
|
<Badge
|
||||||
|
colorScheme={
|
||||||
|
league.sport === "football" ? "green" : "orange"
|
||||||
|
}
|
||||||
|
variant="solid"
|
||||||
|
bg="whiteAlpha.300"
|
||||||
|
size="lg"
|
||||||
|
px={4}
|
||||||
|
py={1}
|
||||||
|
rounded="full"
|
||||||
|
>
|
||||||
|
{league.sport}
|
||||||
|
</Badge>
|
||||||
|
{league.season && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
color="white"
|
||||||
|
borderColor="whiteAlpha.400"
|
||||||
|
size="lg"
|
||||||
|
px={4}
|
||||||
|
py={1}
|
||||||
|
rounded="full"
|
||||||
|
>
|
||||||
|
SEZON: {league.season}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Heading
|
||||||
|
as="h1"
|
||||||
|
fontSize={{ base: "3xl", md: "5xl" }}
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="tight"
|
||||||
|
>
|
||||||
|
{league.name}
|
||||||
|
</Heading>
|
||||||
|
<HStack fontSize="lg" color="whiteAlpha.900">
|
||||||
|
<LuMapPin />
|
||||||
|
<Text>{league.country?.name || "Global"}</Text>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Heading>Lig Bulunamadı</Heading>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</SlideUp>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<Box
|
||||||
|
maxW="7xl"
|
||||||
|
mx="auto"
|
||||||
|
px={6}
|
||||||
|
mt={-10}
|
||||||
|
position="relative"
|
||||||
|
zIndex={2}
|
||||||
|
pb={20}
|
||||||
|
>
|
||||||
|
<SlideUp
|
||||||
|
transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg={useColorModeValue("white", "gray.900")}
|
||||||
|
p={{ base: 4, md: 8 }}
|
||||||
|
shadow="xl"
|
||||||
|
borderRadius="2xl"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={useColorModeValue("gray.200", "gray.800")}
|
||||||
|
>
|
||||||
|
<Heading size="md" mb={6}>
|
||||||
|
Geçmiş Maçlar
|
||||||
|
</Heading>
|
||||||
|
<MatchList
|
||||||
|
flatMatches={flatMatches}
|
||||||
|
isLoading={matchesQuery.isLoading || leagueQuery.isLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SlideUp>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Spinner,
|
Spinner,
|
||||||
Input,
|
Input,
|
||||||
Tabs,
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
Icon,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
@@ -22,8 +24,15 @@ import {
|
|||||||
useSearchTeams,
|
useSearchTeams,
|
||||||
} from "@/lib/api/leagues/use-hooks";
|
} from "@/lib/api/leagues/use-hooks";
|
||||||
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
|
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
|
||||||
import { LuSearch, LuGlobe, LuTrophy, LuUsers } from "react-icons/lu";
|
import {
|
||||||
import { useState } from "react";
|
LuSearch,
|
||||||
|
LuGlobe,
|
||||||
|
LuTrophy,
|
||||||
|
LuUsers,
|
||||||
|
LuArrowRight,
|
||||||
|
LuMapPin,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||||
@@ -33,13 +42,26 @@ export default function LeaguesContent() {
|
|||||||
const t = useTranslations("leagues");
|
const t = useTranslations("leagues");
|
||||||
const tMatches = useTranslations("matches");
|
const tMatches = useTranslations("matches");
|
||||||
|
|
||||||
const cardBg = useColorModeValue("white", "gray.800");
|
const bgGradient = useColorModeValue(
|
||||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
"linear(to-r, primary.500, primary.700)",
|
||||||
|
"linear(to-r, primary.600, primary.900)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue("white", "gray.900");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.800");
|
||||||
|
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
|
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
|
||||||
const [sportFilter, setSportFilter] = useState<string>("");
|
const [sportFilter, setSportFilter] = useState<string>("");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [selectedCountryId, setSelectedCountryId] = useState<string | null>(
|
||||||
const debouncedQuery = useDebounce(searchQuery, 300);
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [teamSearchQuery, setTeamSearchQuery] = useState("");
|
||||||
|
const debouncedTeamQuery = useDebounce(teamSearchQuery, 300);
|
||||||
|
|
||||||
|
const [countrySearchQuery, setCountrySearchQuery] = useState("");
|
||||||
|
const debouncedCountryQuery = useDebounce(countrySearchQuery, 300);
|
||||||
|
|
||||||
const countries = useCountries();
|
const countries = useCountries();
|
||||||
const leagues = useLeagues(
|
const leagues = useLeagues(
|
||||||
@@ -48,182 +70,519 @@ export default function LeaguesContent() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
const searchTeams = useSearchTeams(
|
const searchTeams = useSearchTeams(
|
||||||
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" },
|
debouncedTeamQuery.length >= 2 ? { q: debouncedTeamQuery } : { q: "" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredCountries = useMemo(() => {
|
||||||
|
if (!countries.data?.data) return [];
|
||||||
|
if (!debouncedCountryQuery) return countries.data.data;
|
||||||
|
return countries.data.data.filter((c) =>
|
||||||
|
c.name.toLowerCase().includes(debouncedCountryQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
}, [countries.data?.data, debouncedCountryQuery]);
|
||||||
|
|
||||||
|
const displayedLeagues = useMemo(() => {
|
||||||
|
let sourceLeagues: LeagueDto[] = leagues.data?.data || [];
|
||||||
|
|
||||||
|
if (selectedCountryId) {
|
||||||
|
sourceLeagues = sourceLeagues.filter(
|
||||||
|
(l) => l.countryId === selectedCountryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sport filter if selected
|
||||||
|
if (sportFilter) {
|
||||||
|
return sourceLeagues.filter((l) => l.sport === sportFilter);
|
||||||
|
}
|
||||||
|
return sourceLeagues;
|
||||||
|
}, [selectedCountryId, leagues.data?.data, sportFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideUp>
|
<Box minH="calc(100vh - 80px)">
|
||||||
<Box maxW="6xl" mx="auto">
|
{/* Hero Section */}
|
||||||
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
|
<Box
|
||||||
{t("title")}
|
bgGradient={bgGradient}
|
||||||
</Heading>
|
color="white"
|
||||||
|
pt={16}
|
||||||
<Tabs.Root
|
pb={20}
|
||||||
value={activeTab}
|
px={6}
|
||||||
onValueChange={(e) => setActiveTab(e.value as "leagues" | "teams")}
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Box
|
||||||
<Tabs.Trigger value="leagues">
|
position="absolute"
|
||||||
<LuGlobe />
|
top="-20%"
|
||||||
{t("countries-leagues")}
|
right="-10%"
|
||||||
</Tabs.Trigger>
|
opacity={0.1}
|
||||||
<Tabs.Trigger value="teams">
|
transform="rotate(15deg)"
|
||||||
<LuUsers />
|
>
|
||||||
{tMatches("search-teams")}
|
<LuTrophy size={400} />
|
||||||
</Tabs.Trigger>
|
</Box>
|
||||||
</Tabs.List>
|
<Box maxW="7xl" mx="auto" position="relative" zIndex={1}>
|
||||||
|
<SlideUp>
|
||||||
|
<VStack
|
||||||
|
align="center"
|
||||||
|
gap={4}
|
||||||
|
textAlign="center"
|
||||||
|
maxW="3xl"
|
||||||
|
mx="auto"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
colorScheme="whiteAlpha"
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
px={4}
|
||||||
|
py={1}
|
||||||
|
rounded="full"
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</Badge>
|
||||||
|
<Heading
|
||||||
|
as="h1"
|
||||||
|
fontSize={{ base: "3xl", md: "5xl" }}
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="tight"
|
||||||
|
>
|
||||||
|
{activeTab === "leagues"
|
||||||
|
? t("countries-leagues")
|
||||||
|
: tMatches("search-teams")}
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize="lg" color="whiteAlpha.800" maxW="xl">
|
||||||
|
{activeTab === "leagues"
|
||||||
|
? "Explore top football and basketball leagues around the world. Filter by country and analyze historical matches."
|
||||||
|
: "Find your favorite teams across all leagues. Get deep insights and head-to-head statistics."}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</SlideUp>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Countries & Leagues Tab */}
|
{/* Main Content Area - Pulled up to overlap hero */}
|
||||||
<Tabs.Content value="leagues">
|
<Box
|
||||||
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
|
maxW="7xl"
|
||||||
{/* Countries Sidebar */}
|
mx="auto"
|
||||||
<Box w={{ base: "full", lg: "280px" }} flexShrink={0}>
|
px={6}
|
||||||
|
mt={-10}
|
||||||
|
position="relative"
|
||||||
|
zIndex={2}
|
||||||
|
pb={20}
|
||||||
|
>
|
||||||
|
<SlideUp
|
||||||
|
transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
>
|
||||||
<Card.Root
|
<Card.Root
|
||||||
bg={cardBg}
|
bg={cardBg}
|
||||||
|
shadow="xl"
|
||||||
|
borderRadius="2xl"
|
||||||
|
borderWidth="1px"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
borderRadius="xl"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Card.Header>
|
{/* Tab Navigation */}
|
||||||
<Heading as="h4" size="sm">
|
<Flex
|
||||||
<HStack gap={2}>
|
borderBottomWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
|
||||||
|
>
|
||||||
|
<Flex flex={1}>
|
||||||
|
<Box
|
||||||
|
flex={1}
|
||||||
|
py={4}
|
||||||
|
textAlign="center"
|
||||||
|
cursor="pointer"
|
||||||
|
borderBottomWidth="2px"
|
||||||
|
borderColor={
|
||||||
|
activeTab === "leagues" ? "primary.500" : "transparent"
|
||||||
|
}
|
||||||
|
color={activeTab === "leagues" ? "primary.500" : "fg.muted"}
|
||||||
|
fontWeight={activeTab === "leagues" ? "bold" : "medium"}
|
||||||
|
onClick={() => setActiveTab("leagues")}
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ bg: hoverBg }}
|
||||||
|
>
|
||||||
|
<HStack justify="center" gap={2}>
|
||||||
<LuGlobe />
|
<LuGlobe />
|
||||||
<Text>{t("countries")}</Text>
|
<Text>{t("countries-leagues")}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Heading>
|
</Box>
|
||||||
</Card.Header>
|
<Box
|
||||||
<Card.Body pt={0} maxH="600px" overflowY="auto">
|
flex={1}
|
||||||
|
py={4}
|
||||||
|
textAlign="center"
|
||||||
|
cursor="pointer"
|
||||||
|
borderBottomWidth="2px"
|
||||||
|
borderColor={
|
||||||
|
activeTab === "teams" ? "primary.500" : "transparent"
|
||||||
|
}
|
||||||
|
color={activeTab === "teams" ? "primary.500" : "fg.muted"}
|
||||||
|
fontWeight={activeTab === "teams" ? "bold" : "medium"}
|
||||||
|
onClick={() => setActiveTab("teams")}
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ bg: hoverBg }}
|
||||||
|
>
|
||||||
|
<HStack justify="center" gap={2}>
|
||||||
|
<LuUsers />
|
||||||
|
<Text>{tMatches("search-teams")}</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* LEAGUES TAB */}
|
||||||
|
{activeTab === "leagues" && (
|
||||||
|
<Flex direction={{ base: "column", lg: "row" }} minH="600px">
|
||||||
|
{/* Left Sidebar: Countries */}
|
||||||
|
<Box
|
||||||
|
w={{ base: "full", lg: "320px" }}
|
||||||
|
borderRightWidth={{ lg: "1px" }}
|
||||||
|
borderColor={borderColor}
|
||||||
|
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
|
||||||
|
>
|
||||||
|
<VStack align="stretch" h="full" gap={0}>
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
borderBottomWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
bg={cardBg}
|
||||||
|
>
|
||||||
|
<InputGroup
|
||||||
|
startElement={<LuSearch color="gray.400" />}
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={t("countries") + "..."}
|
||||||
|
variant="subtle"
|
||||||
|
borderRadius="full"
|
||||||
|
value={countrySearchQuery}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCountrySearchQuery(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
flex={1}
|
||||||
|
overflowY="auto"
|
||||||
|
maxH={{ base: "300px", lg: "600px" }}
|
||||||
|
p={2}
|
||||||
|
>
|
||||||
{countries.isLoading ? (
|
{countries.isLoading ? (
|
||||||
<Flex justify="center" py={4}>
|
<Flex justify="center" py={10}>
|
||||||
<Spinner size="sm" />
|
<Spinner color="primary.500" />
|
||||||
</Flex>
|
</Flex>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={1} align="stretch">
|
<VStack gap={1} align="stretch">
|
||||||
{countries.data?.data?.map((country: CountryDto) => (
|
<Box
|
||||||
<Flex
|
px={4}
|
||||||
key={country.id}
|
py={3}
|
||||||
px={3}
|
borderRadius="lg"
|
||||||
py={2}
|
|
||||||
borderRadius="md"
|
|
||||||
_hover={{
|
|
||||||
bg: "gray.50",
|
|
||||||
_dark: { bg: "gray.750" },
|
|
||||||
}}
|
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
justify="space-between"
|
bg={
|
||||||
align="center"
|
selectedCountryId === null
|
||||||
|
? "primary.500"
|
||||||
|
: "transparent"
|
||||||
|
}
|
||||||
|
color={selectedCountryId === null ? "white" : "fg"}
|
||||||
|
_hover={{
|
||||||
|
bg:
|
||||||
|
selectedCountryId === null
|
||||||
|
? "primary.600"
|
||||||
|
: hoverBg,
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedCountryId(null)}
|
||||||
|
transition="all 0.2s"
|
||||||
>
|
>
|
||||||
<HStack gap={2}>
|
<HStack justify="space-between">
|
||||||
|
<HStack gap={3}>
|
||||||
|
<LuGlobe />
|
||||||
|
<Text
|
||||||
|
fontWeight={
|
||||||
|
selectedCountryId === null
|
||||||
|
? "bold"
|
||||||
|
: "medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("all")}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
bg={
|
||||||
|
selectedCountryId === null
|
||||||
|
? "whiteAlpha.300"
|
||||||
|
: "gray.100"
|
||||||
|
}
|
||||||
|
color={
|
||||||
|
selectedCountryId === null ? "white" : "fg"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{leagues.data?.data?.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{filteredCountries.map((country: CountryDto) => {
|
||||||
|
const isSelected = selectedCountryId === country.id;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={country.id}
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
borderRadius="lg"
|
||||||
|
cursor="pointer"
|
||||||
|
bg={isSelected ? "primary.500" : "transparent"}
|
||||||
|
color={isSelected ? "white" : "fg"}
|
||||||
|
_hover={{
|
||||||
|
bg: isSelected ? "primary.600" : hoverBg,
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedCountryId(country.id)}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack gap={3}>
|
||||||
{country.flag ? (
|
{country.flag ? (
|
||||||
<img
|
<img
|
||||||
src={country.flag}
|
src={country.flag}
|
||||||
width="16"
|
width="20"
|
||||||
height="16"
|
height="20"
|
||||||
style={{ borderRadius: "2px" }}
|
style={{
|
||||||
|
borderRadius: "50%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
alt={country.name}
|
alt={country.name}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
<Text fontSize="sm">{country.name}</Text>
|
<LuMapPin />
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
fontWeight={
|
||||||
|
isSelected ? "bold" : "medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{country.name}
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Badge size="xs" colorScheme="gray">
|
<Badge
|
||||||
{country.leagues?.length || 0}
|
size="sm"
|
||||||
|
bg={
|
||||||
|
isSelected ? "whiteAlpha.300" : "gray.100"
|
||||||
|
}
|
||||||
|
color={isSelected ? "white" : "fg"}
|
||||||
|
>
|
||||||
|
{leagues.data?.data?.filter(
|
||||||
|
(l) => l.countryId === country.id,
|
||||||
|
).length || 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Flex>
|
</HStack>
|
||||||
))}
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</Card.Body>
|
</Box>
|
||||||
</Card.Root>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Leagues List */}
|
{/* Right Area: Leagues Grid */}
|
||||||
<Box flex={1}>
|
<Box flex={1} p={{ base: 4, md: 8 }} bg={cardBg}>
|
||||||
<Card.Root
|
{/* Top Filters */}
|
||||||
bg={cardBg}
|
<Flex
|
||||||
borderColor={borderColor}
|
justify="space-between"
|
||||||
borderRadius="xl"
|
align="center"
|
||||||
|
mb={6}
|
||||||
|
direction={{ base: "column", sm: "row" }}
|
||||||
|
gap={4}
|
||||||
>
|
>
|
||||||
<Card.Header>
|
<Heading size="md" fontWeight="bold">
|
||||||
<Flex justify="space-between" align="center">
|
{selectedCountryId
|
||||||
<Heading as="h4" size="sm">
|
? `${countries.data?.data?.find((c) => c.id === selectedCountryId)?.name} ${t("leagues")}`
|
||||||
<HStack gap={2}>
|
: t("leagues")}
|
||||||
<LuTrophy />
|
<Text
|
||||||
<Text>{t("leagues")}</Text>
|
as="span"
|
||||||
</HStack>
|
color="fg.muted"
|
||||||
|
ml={2}
|
||||||
|
fontWeight="normal"
|
||||||
|
fontSize="sm"
|
||||||
|
>
|
||||||
|
({displayedLeagues.length})
|
||||||
|
</Text>
|
||||||
</Heading>
|
</Heading>
|
||||||
<HStack gap={2}>
|
|
||||||
<Badge
|
<HStack
|
||||||
cursor="pointer"
|
gap={2}
|
||||||
colorScheme={!sportFilter ? "primary" : "gray"}
|
bg={useColorModeValue("gray.100", "gray.800")}
|
||||||
onClick={() => setSportFilter("")}
|
p={1}
|
||||||
|
borderRadius="full"
|
||||||
>
|
>
|
||||||
{tMatches("all")}
|
<Box
|
||||||
</Badge>
|
px={4}
|
||||||
<Badge
|
py={1.5}
|
||||||
|
borderRadius="full"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
colorScheme={
|
fontSize="sm"
|
||||||
sportFilter === "football" ? "green" : "gray"
|
fontWeight="medium"
|
||||||
|
bg={!sportFilter ? "white" : "transparent"}
|
||||||
|
color={!sportFilter ? "black" : "fg.muted"}
|
||||||
|
shadow={!sportFilter ? "sm" : "none"}
|
||||||
|
onClick={() => setSportFilter("")}
|
||||||
|
transition="all 0.2s"
|
||||||
|
_dark={{
|
||||||
|
bg: !sportFilter ? "gray.600" : "transparent",
|
||||||
|
color: !sportFilter ? "white" : "gray.400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("all")}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={1.5}
|
||||||
|
borderRadius="full"
|
||||||
|
cursor="pointer"
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="medium"
|
||||||
|
bg={
|
||||||
|
sportFilter === "football"
|
||||||
|
? "green.500"
|
||||||
|
: "transparent"
|
||||||
}
|
}
|
||||||
|
color={
|
||||||
|
sportFilter === "football" ? "white" : "fg.muted"
|
||||||
|
}
|
||||||
|
shadow={sportFilter === "football" ? "sm" : "none"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSportFilter(
|
setSportFilter(
|
||||||
sportFilter === "football" ? "" : "football",
|
sportFilter === "football" ? "" : "football",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
transition="all 0.2s"
|
||||||
>
|
>
|
||||||
{tMatches("football")}
|
{tMatches("football")}
|
||||||
</Badge>
|
</Box>
|
||||||
<Badge
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={1.5}
|
||||||
|
borderRadius="full"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
colorScheme={
|
fontSize="sm"
|
||||||
sportFilter === "basketball" ? "orange" : "gray"
|
fontWeight="medium"
|
||||||
|
bg={
|
||||||
|
sportFilter === "basketball"
|
||||||
|
? "orange.500"
|
||||||
|
: "transparent"
|
||||||
}
|
}
|
||||||
|
color={
|
||||||
|
sportFilter === "basketball" ? "white" : "fg.muted"
|
||||||
|
}
|
||||||
|
shadow={sportFilter === "basketball" ? "sm" : "none"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSportFilter(
|
setSportFilter(
|
||||||
sportFilter === "basketball" ? "" : "basketball",
|
sportFilter === "basketball" ? "" : "basketball",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
transition="all 0.2s"
|
||||||
>
|
>
|
||||||
{tMatches("basketball")}
|
{tMatches("basketball")}
|
||||||
</Badge>
|
</Box>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card.Header>
|
|
||||||
<Card.Body pt={0}>
|
{/* Leagues Grid */}
|
||||||
{leagues.isLoading ? (
|
{leagues.isLoading ? (
|
||||||
<Flex justify="center" py={6}>
|
<Flex justify="center" py={20}>
|
||||||
<Spinner size="sm" />
|
<Spinner
|
||||||
|
size="xl"
|
||||||
|
color="primary.500"
|
||||||
|
borderWidth="3px"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
) : displayedLeagues.length === 0 ? (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
py={20}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg="gray.100"
|
||||||
|
_dark={{ bg: "gray.800" }}
|
||||||
|
p={6}
|
||||||
|
borderRadius="full"
|
||||||
|
mb={4}
|
||||||
|
>
|
||||||
|
<LuTrophy size={40} color="gray" />
|
||||||
|
</Box>
|
||||||
|
<Heading size="md" mb={2}>
|
||||||
|
Bulunamadı
|
||||||
|
</Heading>
|
||||||
|
<Text color="fg.muted">
|
||||||
|
Seçili kriterlere uygun lig bulunamadı.
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={2}>
|
<Grid
|
||||||
{leagues.data?.data?.map((league: LeagueDto) => (
|
templateColumns={{
|
||||||
|
base: "1fr",
|
||||||
|
md: "repeat(2, 1fr)",
|
||||||
|
xl: "repeat(3, 1fr)",
|
||||||
|
}}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
{displayedLeagues.map((league: LeagueDto) => (
|
||||||
|
<GridItem key={league.id}>
|
||||||
<ChakraLink
|
<ChakraLink
|
||||||
key={league.id}
|
|
||||||
as={Link}
|
as={Link}
|
||||||
href="/matches"
|
href={`/leagues/${league.id}`}
|
||||||
p={3}
|
display="block"
|
||||||
borderRadius="md"
|
h="full"
|
||||||
|
p={5}
|
||||||
|
borderRadius="xl"
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
|
bg={cardBg}
|
||||||
_hover={{
|
_hover={{
|
||||||
borderColor: "primary.300",
|
borderColor: "primary.300",
|
||||||
bg: "primary.50",
|
shadow: "md",
|
||||||
_dark: { bg: "gray.750" },
|
transform: "translateY(-2px)",
|
||||||
}}
|
}}
|
||||||
display="flex"
|
transition="all 0.2s"
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
textDecoration="none"
|
textDecoration="none"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
data-group
|
||||||
>
|
>
|
||||||
<VStack align="start" gap={0}>
|
<Flex
|
||||||
<Text fontWeight="semibold">{league.name}</Text>
|
justify="space-between"
|
||||||
<Text fontSize="xs" color="fg.muted">
|
align="flex-start"
|
||||||
{league.country?.name || ""}
|
mb={4}
|
||||||
</Text>
|
>
|
||||||
</VStack>
|
<Box
|
||||||
<HStack gap={2}>
|
p={2}
|
||||||
{league.sport ? (
|
borderRadius="lg"
|
||||||
|
bg={
|
||||||
|
league.sport === "football"
|
||||||
|
? "green.50"
|
||||||
|
: "orange.50"
|
||||||
|
}
|
||||||
|
_dark={{
|
||||||
|
bg:
|
||||||
|
league.sport === "football"
|
||||||
|
? "green.900"
|
||||||
|
: "orange.900",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuTrophy
|
||||||
|
size={20}
|
||||||
|
color={
|
||||||
|
league.sport === "football"
|
||||||
|
? "var(--chakra-colors-green-500)"
|
||||||
|
: "var(--chakra-colors-orange-500)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Badge
|
<Badge
|
||||||
size="xs"
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
colorScheme={
|
colorScheme={
|
||||||
league.sport === "football"
|
league.sport === "football"
|
||||||
? "green"
|
? "green"
|
||||||
@@ -232,104 +591,234 @@ export default function LeaguesContent() {
|
|||||||
>
|
>
|
||||||
{league.sport}
|
{league.sport}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
</Flex>
|
||||||
{league.season ? (
|
|
||||||
<Text fontSize="xs" color="fg.muted">
|
<Heading
|
||||||
{league.season}
|
size="sm"
|
||||||
|
mb={1}
|
||||||
|
lineClamp={1}
|
||||||
|
_groupHover={{ color: "primary.500" }}
|
||||||
|
>
|
||||||
|
{league.name}
|
||||||
|
</Heading>
|
||||||
|
<HStack color="fg.muted" fontSize="sm" gap={1}>
|
||||||
|
<LuMapPin size={14} />
|
||||||
|
<Text lineClamp={1}>
|
||||||
|
{league.country?.name || "Global"}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</ChakraLink>
|
|
||||||
))}
|
{league.season && (
|
||||||
</VStack>
|
<Flex
|
||||||
|
mt={4}
|
||||||
|
pt={4}
|
||||||
|
borderTopWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color="fg.muted"
|
||||||
|
fontWeight="medium"
|
||||||
|
>
|
||||||
|
SEZON: {league.season}
|
||||||
|
</Text>
|
||||||
|
<Icon
|
||||||
|
as={LuArrowRight}
|
||||||
|
color="gray.400"
|
||||||
|
_groupHover={{
|
||||||
|
color: "primary.500",
|
||||||
|
transform: "translateX(4px)",
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</ChakraLink>
|
||||||
|
</GridItem>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Card.Body>
|
|
||||||
</Card.Root>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tabs.Content>
|
)}
|
||||||
|
|
||||||
{/* Teams Search Tab */}
|
{/* TEAMS TAB */}
|
||||||
<Tabs.Content value="teams">
|
{activeTab === "teams" && (
|
||||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
<Box p={{ base: 4, md: 8 }}>
|
||||||
<Card.Body>
|
<Box maxW="2xl" mx="auto" mb={10}>
|
||||||
<InputGroup startElement={<LuSearch />} mb={4}>
|
<InputGroup
|
||||||
|
startElement={<LuSearch color="gray.400" size={20} />}
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder={tMatches("search-teams")}
|
placeholder={tMatches("search-teams") + "..."}
|
||||||
value={searchQuery}
|
value={teamSearchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setTeamSearchQuery(e.target.value)}
|
||||||
|
variant="outline"
|
||||||
|
borderRadius="xl"
|
||||||
|
fontSize="lg"
|
||||||
|
py={6}
|
||||||
|
boxShadow="sm"
|
||||||
|
_focus={{
|
||||||
|
boxShadow: "0 0 0 2px var(--chakra-colors-primary-500)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
{debouncedQuery.length < 2 ? (
|
</Box>
|
||||||
<Text color="fg.muted" textAlign="center" py={8}>
|
|
||||||
|
{debouncedTeamQuery.length < 2 ? (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
py={20}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg="primary.50"
|
||||||
|
_dark={{ bg: "primary.900" }}
|
||||||
|
p={8}
|
||||||
|
borderRadius="full"
|
||||||
|
mb={6}
|
||||||
|
>
|
||||||
|
<LuUsers
|
||||||
|
size={64}
|
||||||
|
color="var(--chakra-colors-primary-500)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Heading size="lg" mb={3}>
|
||||||
{t("search-at-least-2")}
|
{t("search-at-least-2")}
|
||||||
|
</Heading>
|
||||||
|
<Text color="fg.muted" maxW="md">
|
||||||
|
Find detailed statistics, upcoming matches, and
|
||||||
|
head-to-head analysis by searching for any team worldwide.
|
||||||
</Text>
|
</Text>
|
||||||
|
</Flex>
|
||||||
) : searchTeams.isLoading ? (
|
) : searchTeams.isLoading ? (
|
||||||
<Flex justify="center" py={6}>
|
<Flex justify="center" py={20}>
|
||||||
<Spinner size="md" />
|
<Spinner size="xl" color="primary.500" borderWidth="3px" />
|
||||||
|
</Flex>
|
||||||
|
) : searchTeams.data?.data?.length === 0 ? (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
py={20}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Heading size="md" mb={2}>
|
||||||
|
Takım Bulunamadı
|
||||||
|
</Heading>
|
||||||
|
<Text color="fg.muted">
|
||||||
|
"{debouncedTeamQuery}" aramasıyla eşleşen bir takım
|
||||||
|
bulunamadı.
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={2}>
|
<Grid
|
||||||
|
templateColumns={{
|
||||||
|
base: "1fr",
|
||||||
|
md: "repeat(2, 1fr)",
|
||||||
|
xl: "repeat(3, 1fr)",
|
||||||
|
}}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
{searchTeams.data?.data?.map((team: TeamDto) => (
|
{searchTeams.data?.data?.map((team: TeamDto) => (
|
||||||
|
<GridItem key={team.id}>
|
||||||
<ChakraLink
|
<ChakraLink
|
||||||
key={team.id}
|
|
||||||
as={Link}
|
as={Link}
|
||||||
href={`/teams/${team.id}`}
|
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"
|
display="flex"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap={3}
|
p={4}
|
||||||
|
borderRadius="xl"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
bg={cardBg}
|
||||||
|
_hover={{
|
||||||
|
borderColor: "primary.300",
|
||||||
|
shadow: "md",
|
||||||
|
transform: "translateY(-2px)",
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
textDecoration="none"
|
textDecoration="none"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
data-group
|
||||||
>
|
>
|
||||||
{team.logo ? (
|
{team.logo ? (
|
||||||
|
<Box
|
||||||
|
w={12}
|
||||||
|
h={12}
|
||||||
|
borderRadius="full"
|
||||||
|
overflow="hidden"
|
||||||
|
flexShrink={0}
|
||||||
|
mr={4}
|
||||||
|
bg="white"
|
||||||
|
p={1}
|
||||||
|
shadow="sm"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={team.logo}
|
src={team.logo}
|
||||||
width="32"
|
width="100%"
|
||||||
height="32"
|
height="100%"
|
||||||
style={{ borderRadius: "50%" }}
|
style={{ objectFit: "contain" }}
|
||||||
alt={team.name}
|
alt={team.name}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Flex
|
||||||
boxSize="32px"
|
w={12}
|
||||||
|
h={12}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
bg="gray.200"
|
bg="gray.100"
|
||||||
_dark={{ bg: "gray.600" }}
|
_dark={{ bg: "gray.700" }}
|
||||||
/>
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
flexShrink={0}
|
||||||
|
mr={4}
|
||||||
|
>
|
||||||
|
<LuUsers size={20} color="gray" />
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<VStack align="start" gap={0}>
|
|
||||||
<Text fontWeight="semibold">{team.name}</Text>
|
<VStack align="start" gap={0} flex={1}>
|
||||||
<Text fontSize="xs" color="fg.muted">
|
<Heading
|
||||||
{team.country || ""}
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
_groupHover={{ color: "primary.500" }}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</Heading>
|
||||||
|
<HStack color="fg.muted" fontSize="xs" gap={1}>
|
||||||
|
<LuMapPin size={12} />
|
||||||
|
<Text lineClamp={1}>
|
||||||
|
{team.country || "Global"}
|
||||||
</Text>
|
</Text>
|
||||||
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
ml="auto"
|
ml={2}
|
||||||
size="xs"
|
size="sm"
|
||||||
colorScheme={
|
colorScheme={
|
||||||
team.sport === "football" ? "green" : "orange"
|
team.sport === "football" ? "green" : "orange"
|
||||||
}
|
}
|
||||||
|
variant="subtle"
|
||||||
>
|
>
|
||||||
{team.sport}
|
{team.sport}
|
||||||
</Badge>
|
</Badge>
|
||||||
</ChakraLink>
|
</ChakraLink>
|
||||||
|
</GridItem>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Card.Body>
|
|
||||||
</Card.Root>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs.Root>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card.Root>
|
||||||
</SlideUp>
|
</SlideUp>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
content: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegalPageProps {
|
||||||
|
title: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
sections: Section[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LegalPage({ title, lastUpdated, sections }: LegalPageProps) {
|
||||||
|
return (
|
||||||
|
<Box maxW="3xl" mx="auto" px={{ base: 4, md: 8 }} py={12}>
|
||||||
|
<VStack align="start" gap={8}>
|
||||||
|
<Box>
|
||||||
|
<Heading as="h1" size="2xl" mb={2}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{lastUpdated}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<Box key={i} w="full">
|
||||||
|
<Heading as="h2" size="md" mb={3}>
|
||||||
|
{section.title}
|
||||||
|
</Heading>
|
||||||
|
{Array.isArray(section.content) ? (
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
{section.content.map((para, j) => (
|
||||||
|
<Text key={j} color="fg.muted" lineHeight="1.8">
|
||||||
|
{para}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Text color="fg.muted" lineHeight="1.8">
|
||||||
|
{section.content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { default as MatchCard } from "./match-card";
|
|||||||
export { default as MatchList } from "./match-list";
|
export { default as MatchList } from "./match-list";
|
||||||
export { default as SportFilter } from "./sport-filter";
|
export { default as SportFilter } from "./sport-filter";
|
||||||
export { default as LeagueSidebar } from "./league-sidebar";
|
export { default as LeagueSidebar } from "./league-sidebar";
|
||||||
|
export { default as LeagueFilterBar } from "./league-filter-bar";
|
||||||
export { default as PredictionCard } from "./prediction-card";
|
export { default as PredictionCard } from "./prediction-card";
|
||||||
export { default as MatchDetailContent } from "./match-detail-content";
|
export { default as MatchDetailContent } from "./match-detail-content";
|
||||||
export { default as MatchesContent } from "./matches-content";
|
export { default as MatchesContent } from "./matches-content";
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Flex, Text, Badge, Image, ScrollArea } 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 LeagueFilterBarProps {
|
||||||
|
leagues: ActiveLeagueDto[];
|
||||||
|
selectedLeagueId: string | null;
|
||||||
|
onSelect: (leagueId: string | null) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeagueFilterBar — Horizontal scrollable league filter chips for mobile.
|
||||||
|
* Shows country flag, league name, country name, and live/match count badges.
|
||||||
|
*/
|
||||||
|
export default function LeagueFilterBar({
|
||||||
|
leagues,
|
||||||
|
selectedLeagueId,
|
||||||
|
onSelect,
|
||||||
|
isLoading,
|
||||||
|
}: LeagueFilterBarProps) {
|
||||||
|
const t = useTranslations("matches");
|
||||||
|
|
||||||
|
const chipBg = useColorModeValue("white", "gray.800");
|
||||||
|
const chipBorder = useColorModeValue("gray.200", "gray.600");
|
||||||
|
const activeBg = useColorModeValue("primary.50", "primary.900");
|
||||||
|
const activeBorder = useColorModeValue("primary.400", "primary.500");
|
||||||
|
const countryText = useColorModeValue("gray.500", "gray.400");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Flex gap={2} overflow="hidden" pb={2}>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
h="42px"
|
||||||
|
w="120px"
|
||||||
|
bg="bg.muted"
|
||||||
|
borderRadius="full"
|
||||||
|
flexShrink={0}
|
||||||
|
animation="pulse 1.5s ease-in-out infinite"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea.Root width="full" size="xs">
|
||||||
|
<ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Content py="1">
|
||||||
|
<Flex gap={2} flexWrap="nowrap" pb={1}>
|
||||||
|
{/* "All Leagues" chip */}
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
onClick={() => onSelect(null)}
|
||||||
|
px={3.5}
|
||||||
|
py={2}
|
||||||
|
borderRadius="full"
|
||||||
|
borderWidth="1.5px"
|
||||||
|
borderColor={
|
||||||
|
selectedLeagueId === null ? activeBorder : chipBorder
|
||||||
|
}
|
||||||
|
bg={selectedLeagueId === null ? activeBg : chipBg}
|
||||||
|
cursor="pointer"
|
||||||
|
flexShrink={0}
|
||||||
|
transition="all 0.15s"
|
||||||
|
_hover={{ borderColor: activeBorder }}
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight={selectedLeagueId === null ? "bold" : "medium"}
|
||||||
|
color={selectedLeagueId === null ? "primary.fg" : "fg"}
|
||||||
|
>
|
||||||
|
{t("all-leagues")}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* League chips */}
|
||||||
|
{leagues.map((league) => {
|
||||||
|
const isActive = selectedLeagueId === league.id;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
key={league.id}
|
||||||
|
onClick={() => onSelect(league.id)}
|
||||||
|
px={3}
|
||||||
|
py={1.5}
|
||||||
|
borderRadius="full"
|
||||||
|
borderWidth="1.5px"
|
||||||
|
borderColor={isActive ? activeBorder : chipBorder}
|
||||||
|
bg={isActive ? activeBg : chipBg}
|
||||||
|
cursor="pointer"
|
||||||
|
flexShrink={0}
|
||||||
|
transition="all 0.15s"
|
||||||
|
_hover={{ borderColor: activeBorder }}
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={1.5}>
|
||||||
|
{/* Flag or fallback */}
|
||||||
|
{league.countryFlag ? (
|
||||||
|
<Image
|
||||||
|
src={league.countryFlag}
|
||||||
|
alt={league.countryName || ""}
|
||||||
|
boxSize="14px"
|
||||||
|
objectFit="contain"
|
||||||
|
borderRadius="xs"
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
) : league.countryName ? (
|
||||||
|
<Flex
|
||||||
|
boxSize="14px"
|
||||||
|
bg="gray.200"
|
||||||
|
borderRadius="xs"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
flexShrink={0}
|
||||||
|
fontSize="6px"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="gray.600"
|
||||||
|
>
|
||||||
|
{league.countryName.slice(0, 2).toUpperCase()}
|
||||||
|
</Flex>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* League name + country */}
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="flex-start"
|
||||||
|
gap={0}
|
||||||
|
lineHeight="1"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight={isActive ? "bold" : "medium"}
|
||||||
|
color={isActive ? "primary.fg" : "fg"}
|
||||||
|
>
|
||||||
|
{league.name}
|
||||||
|
</Text>
|
||||||
|
{league.countryName && (
|
||||||
|
<Text fontSize="2xs" color={countryText} lineHeight="1">
|
||||||
|
{league.countryName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Live badge */}
|
||||||
|
{league.liveCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
colorPalette="red"
|
||||||
|
variant="solid"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="2xs"
|
||||||
|
px={1}
|
||||||
|
ml={0.5}
|
||||||
|
>
|
||||||
|
{league.liveCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</ScrollArea.Content>
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Scrollbar orientation="horizontal" />
|
||||||
|
<ScrollArea.Corner />
|
||||||
|
</ScrollArea.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export default function LeagueSidebar({
|
|||||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||||
const activeBg = useColorModeValue("primary.50", "primary.900");
|
const activeBg = useColorModeValue("primary.50", "primary.900");
|
||||||
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
||||||
|
const countryTextColor = useColorModeValue("gray.500", "gray.400");
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -111,26 +112,58 @@ export default function LeagueSidebar({
|
|||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<Flex align="center" gap={2} minW={0} flex={1}>
|
<Flex align="center" gap={2} minW={0} flex={1}>
|
||||||
{league.countryFlag && (
|
{/* Country Flag or Fallback */}
|
||||||
|
{league.countryFlag ? (
|
||||||
<Image
|
<Image
|
||||||
src={league.countryFlag}
|
src={league.countryFlag}
|
||||||
alt={league.countryName || ""}
|
alt={league.countryName || ""}
|
||||||
boxSize="16px"
|
boxSize="18px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
|
borderRadius="sm"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
boxSize="18px"
|
||||||
|
bg="gray.200"
|
||||||
|
borderRadius="sm"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
flexShrink={0}
|
||||||
|
fontSize="8px"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="gray.600"
|
||||||
|
>
|
||||||
|
{league.countryName?.slice(0, 2)?.toUpperCase() || "??"}
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* League Name + Country */}
|
||||||
|
<Box minW={0} flex={1}>
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
fontWeight={isActive ? "bold" : "medium"}
|
fontWeight={isActive ? "bold" : "medium"}
|
||||||
color={isActive ? "primary.fg" : "fg"}
|
color={isActive ? "primary.fg" : "fg"}
|
||||||
truncate
|
truncate
|
||||||
|
lineHeight="1.3"
|
||||||
>
|
>
|
||||||
{league.name}
|
{league.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{league.countryName && (
|
||||||
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
color={countryTextColor}
|
||||||
|
truncate
|
||||||
|
lineHeight="1.2"
|
||||||
|
>
|
||||||
|
{league.countryName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex gap={1.5} flexShrink={0}>
|
{/* Badges */}
|
||||||
|
<Flex gap={1.5} flexShrink={0} ml={2}>
|
||||||
{league.liveCount > 0 && (
|
{league.liveCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
colorPalette="red"
|
colorPalette="red"
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Badge,
|
||||||
|
SimpleGrid,
|
||||||
|
Icon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import {
|
||||||
|
LuUsers,
|
||||||
|
LuUser,
|
||||||
|
LuInfo,
|
||||||
|
LuShieldCheck,
|
||||||
|
LuClock,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||||
|
import type { MatchPredictionDto } from "@/lib/api/predictions/types";
|
||||||
|
|
||||||
|
interface LineupsCardProps {
|
||||||
|
match: MatchResponseDto;
|
||||||
|
prediction?: MatchPredictionDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lineup source metadata used for title, badge, and informational banners.
|
||||||
|
*/
|
||||||
|
function getLineupSourceMeta(source?: string) {
|
||||||
|
switch (source) {
|
||||||
|
case "confirmed_live":
|
||||||
|
return {
|
||||||
|
title: "Resmi İlk 11",
|
||||||
|
badge: "Onaylı Kadro",
|
||||||
|
badgeColor: "green" as const,
|
||||||
|
icon: LuShieldCheck,
|
||||||
|
description: "Kadro resmi olarak onaylandı.",
|
||||||
|
};
|
||||||
|
case "confirmed_participation":
|
||||||
|
return {
|
||||||
|
title: "Onaylı Kadro",
|
||||||
|
badge: "Onaylı",
|
||||||
|
badgeColor: "green" as const,
|
||||||
|
icon: LuShieldCheck,
|
||||||
|
description: "Kadro maç katılım verilerinden alındı.",
|
||||||
|
};
|
||||||
|
case "probable_xi":
|
||||||
|
return {
|
||||||
|
title: "Muhtemel Kadro",
|
||||||
|
badge: "Muhtemel",
|
||||||
|
badgeColor: "orange" as const,
|
||||||
|
icon: LuUsers,
|
||||||
|
description:
|
||||||
|
"Son maçlardaki ilk 11 verilerine dayalı muhtemel kadro. AI analizi bu kadro üzerinden yapılmaktadır.",
|
||||||
|
};
|
||||||
|
case "none":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "Kadro Bilgisi",
|
||||||
|
badge: "Kadro Bekleniyor",
|
||||||
|
badgeColor: "gray" as const,
|
||||||
|
icon: LuClock,
|
||||||
|
description:
|
||||||
|
"Kadro henüz açıklanmadı. AI analizi, takımların genel güç dengesi ve istatistiklerine dayalı olarak üretilmiştir.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LineupsCard({ match, prediction }: LineupsCardProps) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||||
|
const headerBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||||||
|
const infoBg = useColorModeValue("blue.50", "whiteAlpha.100");
|
||||||
|
const infoBorder = useColorModeValue("blue.200", "blue.800");
|
||||||
|
|
||||||
|
let homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
|
||||||
|
let awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
|
||||||
|
|
||||||
|
// Determine lineup source from prediction data quality
|
||||||
|
const source = prediction?.data_quality?.lineup_source;
|
||||||
|
const meta = getLineupSourceMeta(source);
|
||||||
|
|
||||||
|
// Fallback: If no starting players are marked, but we have players, treat them as probable XI
|
||||||
|
if (
|
||||||
|
homeLineups.length === 0 &&
|
||||||
|
match.lineups?.home &&
|
||||||
|
match.lineups.home.length > 0
|
||||||
|
) {
|
||||||
|
homeLineups = match.lineups.home.slice(0, 11);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
awayLineups.length === 0 &&
|
||||||
|
match.lineups?.away &&
|
||||||
|
match.lineups.away.length > 0
|
||||||
|
) {
|
||||||
|
awayLineups = match.lineups.away.slice(0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLineups = homeLineups.length > 0 || awayLineups.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||||
|
<Card.Body>
|
||||||
|
{/* ── Header ────────────────────────────────── */}
|
||||||
|
<Flex justify="space-between" align="center" mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={meta.icon} boxSize={5} color="fg.muted" />
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{meta.title}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge colorPalette={meta.badgeColor} variant="subtle">
|
||||||
|
{meta.badge}
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* ── Info Banner ───────────────────────────── */}
|
||||||
|
{source !== "confirmed_live" && (
|
||||||
|
<Flex
|
||||||
|
bg={infoBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={infoBorder}
|
||||||
|
borderRadius="md"
|
||||||
|
p={3}
|
||||||
|
mb={4}
|
||||||
|
align="center"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Icon as={LuInfo} color="blue.500" flexShrink={0} />
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{meta.description}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Lineups Grid ─────────────────────────── */}
|
||||||
|
{hasLineups ? (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={6}>
|
||||||
|
{/* Home Team Lineup */}
|
||||||
|
<Box>
|
||||||
|
<Flex
|
||||||
|
bg={headerBg}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
mb={3}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Text fontWeight="bold">{match.homeTeamName}</Text>
|
||||||
|
<Badge size="sm" variant="outline" colorPalette="blue">
|
||||||
|
Ev Sahibi
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
{homeLineups.length > 0 ? (
|
||||||
|
<VStack align="stretch" gap={2}>
|
||||||
|
{homeLineups.map((p, idx) => (
|
||||||
|
<HStack
|
||||||
|
key={p.player?.id || idx}
|
||||||
|
p={2}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<Icon as={LuUser} color="fg.muted" />
|
||||||
|
{p.shirtNumber && (
|
||||||
|
<Text fontSize="xs" fontWeight="bold" w="20px">
|
||||||
|
{p.shirtNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{p.player?.name || "Bilinmiyor"}
|
||||||
|
</Text>
|
||||||
|
{p.position && (
|
||||||
|
<Badge ml="auto" size="sm" variant="surface">
|
||||||
|
{p.position}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
direction="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||||
|
Kadro henüz belli değil
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.subtle">
|
||||||
|
Maç saatine yakın güncellenecek
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Away Team Lineup */}
|
||||||
|
<Box>
|
||||||
|
<Flex
|
||||||
|
bg={headerBg}
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
mb={3}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Text fontWeight="bold">{match.awayTeamName}</Text>
|
||||||
|
<Badge size="sm" variant="outline" colorPalette="red">
|
||||||
|
Deplasman
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
{awayLineups.length > 0 ? (
|
||||||
|
<VStack align="stretch" gap={2}>
|
||||||
|
{awayLineups.map((p, idx) => (
|
||||||
|
<HStack
|
||||||
|
key={p.player?.id || idx}
|
||||||
|
p={2}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<Icon as={LuUser} color="fg.muted" />
|
||||||
|
{p.shirtNumber && (
|
||||||
|
<Text fontSize="xs" fontWeight="bold" w="20px">
|
||||||
|
{p.shirtNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{p.player?.name || "Bilinmiyor"}
|
||||||
|
</Text>
|
||||||
|
{p.position && (
|
||||||
|
<Badge ml="auto" size="sm" variant="surface">
|
||||||
|
{p.position}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="md"
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
direction="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||||
|
Kadro henüz belli değil
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.subtle">
|
||||||
|
Maç saatine yakın güncellenecek
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
/* ── Empty State: No lineups at all ─────── */
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
py={8}
|
||||||
|
gap={3}
|
||||||
|
>
|
||||||
|
<Icon as={LuClock} boxSize={8} color="fg.subtle" />
|
||||||
|
<VStack gap={1}>
|
||||||
|
<Text fontWeight="semibold" color="fg.muted">
|
||||||
|
Kadro Henüz Açıklanmadı
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color="fg.subtle"
|
||||||
|
textAlign="center"
|
||||||
|
maxW="sm"
|
||||||
|
>
|
||||||
|
{match.homeTeamName} ve {match.awayTeamName} kadroları maç
|
||||||
|
saatine yakın güncellenecektir. AI analizi, takım istatistikleri
|
||||||
|
ve güç dengesi üzerinden yapılmaktadır.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,13 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { slideUpVariants } from "@/components/motion";
|
import { slideUpVariants } from "@/components/motion";
|
||||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LoginModal } from "@/components/auth/login-modal";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: MatchResponseDto;
|
match: MatchResponseDto;
|
||||||
@@ -24,7 +27,10 @@ const MotionBox = motion.create(Box);
|
|||||||
|
|
||||||
export default function MatchCard({ match }: MatchCardProps) {
|
export default function MatchCard({ match }: MatchCardProps) {
|
||||||
const t = useTranslations("matches");
|
const t = useTranslations("matches");
|
||||||
|
const tAuth = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
|
||||||
const cardBg = useColorModeValue("white", "gray.800");
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
const cardBorder = useColorModeValue("gray.100", "gray.700");
|
const cardBorder = useColorModeValue("gray.100", "gray.700");
|
||||||
@@ -42,6 +48,10 @@ export default function MatchCard({ match }: MatchCardProps) {
|
|||||||
: t("not-started");
|
: t("not-started");
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
if (!session) {
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(`/matches/${match.id}`);
|
router.push(`/matches/${match.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +59,7 @@ export default function MatchCard({ match }: MatchCardProps) {
|
|||||||
const matchDate = new Date(match.mstUtc);
|
const matchDate = new Date(match.mstUtc);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<MotionBox
|
<MotionBox
|
||||||
variants={slideUpVariants}
|
variants={slideUpVariants}
|
||||||
bg={cardBg}
|
bg={cardBg}
|
||||||
@@ -223,6 +234,30 @@ export default function MatchCard({ match }: MatchCardProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auth hint for unauthenticated users */}
|
||||||
|
{!session && (
|
||||||
|
<Flex
|
||||||
|
mt={2}
|
||||||
|
pt={2}
|
||||||
|
borderTopWidth="1px"
|
||||||
|
borderColor={cardBorder}
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" color="orange.500" fontWeight="semibold">
|
||||||
|
🔒 {tAuth("login-required-title")}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</MotionBox>
|
</MotionBox>
|
||||||
|
|
||||||
|
{/* Login Modal — shown when unauthenticated user clicks a match */}
|
||||||
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onOpenChange={setLoginModalOpen}
|
||||||
|
initialMode="login"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,11 @@
|
|||||||
import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react";
|
import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
import {
|
||||||
|
StaggerContainer,
|
||||||
|
StaggerItem,
|
||||||
|
ScrollSlideUp,
|
||||||
|
} from "@/components/motion";
|
||||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||||
import MatchCard from "./match-card";
|
import MatchCard from "./match-card";
|
||||||
import type {
|
import type {
|
||||||
@@ -53,7 +57,13 @@ function MatchCardSkeleton() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* League */}
|
{/* League */}
|
||||||
<Flex mt={3} pt={2} borderTopWidth="1px" borderColor={border} justify="center">
|
<Flex
|
||||||
|
mt={3}
|
||||||
|
pt={2}
|
||||||
|
borderTopWidth="1px"
|
||||||
|
borderColor={border}
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
<Skeleton height="12px" width="120px" />
|
<Skeleton height="12px" width="120px" />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -117,6 +127,10 @@ export default function MatchList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedFlatMatches = [...flatMatches].sort(
|
||||||
|
(a, b) => Number(a.mstUtc) - Number(b.mstUtc),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaggerContainer>
|
<StaggerContainer>
|
||||||
<Grid
|
<Grid
|
||||||
@@ -127,7 +141,7 @@ export default function MatchList({
|
|||||||
}}
|
}}
|
||||||
gap={4}
|
gap={4}
|
||||||
>
|
>
|
||||||
{flatMatches.map((match) => (
|
{sortedFlatMatches.map((match) => (
|
||||||
<StaggerItem key={match.id}>
|
<StaggerItem key={match.id}>
|
||||||
<MatchCard match={match} />
|
<MatchCard match={match} />
|
||||||
</StaggerItem>
|
</StaggerItem>
|
||||||
@@ -148,9 +162,23 @@ export default function MatchList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort leagues by their earliest match, and sort matches within each league
|
||||||
|
const sortedLeagues = [...leagues]
|
||||||
|
.map((league) => ({
|
||||||
|
...league,
|
||||||
|
matches: [...league.matches].sort(
|
||||||
|
(a, b) => Number(a.mstUtc) - Number(b.mstUtc),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const earliestA = Math.min(...a.matches.map((m) => Number(m.mstUtc)));
|
||||||
|
const earliestB = Math.min(...b.matches.map((m) => Number(m.mstUtc)));
|
||||||
|
return earliestA - earliestB;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaggerContainer>
|
<StaggerContainer>
|
||||||
{leagues.map((league) => (
|
{sortedLeagues.map((league) => (
|
||||||
<StaggerItem key={league.id}>
|
<StaggerItem key={league.id}>
|
||||||
<Box mb={6}>
|
<Box mb={6}>
|
||||||
{/* League Header */}
|
{/* League Header */}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Flex, Heading } from "@chakra-ui/react";
|
import { Box, Flex, Heading, Group, Button } from "@chakra-ui/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { SlideUp } from "@/components/motion";
|
import { SlideUp } from "@/components/motion";
|
||||||
import { SportFilter, LeagueSidebar, MatchList } from "@/components/matches";
|
import {
|
||||||
|
SportFilter,
|
||||||
|
LeagueSidebar,
|
||||||
|
LeagueFilterBar,
|
||||||
|
MatchList,
|
||||||
|
} from "@/components/matches";
|
||||||
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
|
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
|
||||||
import { useMatchStore } from "@/lib/stores/match-store";
|
import { useMatchStore } from "@/lib/stores/match-store";
|
||||||
|
|
||||||
|
type QuickFilter = "all" | "today" | "live" | "next_1_hour";
|
||||||
|
|
||||||
export default function MatchesContent() {
|
export default function MatchesContent() {
|
||||||
const t = useTranslations("matches");
|
const t = useTranslations("matches");
|
||||||
|
|
||||||
@@ -16,6 +23,9 @@ export default function MatchesContent() {
|
|||||||
const setSport = useMatchStore((s) => s.setSport);
|
const setSport = useMatchStore((s) => s.setSport);
|
||||||
const setLeague = useMatchStore((s) => s.setLeague);
|
const setLeague = useMatchStore((s) => s.setLeague);
|
||||||
|
|
||||||
|
const [quickFilter, setQuickFilter] = useState<QuickFilter>("all");
|
||||||
|
const [dateFilter, setDateFilter] = useState<string>("");
|
||||||
|
|
||||||
// Fetch active leagues for sidebar
|
// Fetch active leagues for sidebar
|
||||||
const { data: leaguesData, isLoading: leaguesLoading } =
|
const { data: leaguesData, isLoading: leaguesLoading } =
|
||||||
useActiveLeagues(sport);
|
useActiveLeagues(sport);
|
||||||
@@ -26,42 +36,63 @@ export default function MatchesContent() {
|
|||||||
|
|
||||||
// Trigger query on sport/league change
|
// Trigger query on sport/league change
|
||||||
const { data: matchesData, isPending: matchesLoading } = (() => {
|
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 {
|
return {
|
||||||
data: queryMatches.data,
|
data: queryMatches.data,
|
||||||
isPending: queryMatches.isPending,
|
isPending: queryMatches.isPending,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const triggerQuery = (
|
||||||
|
currentSport: typeof sport,
|
||||||
|
currentLeague: string | null,
|
||||||
|
currentFilter: QuickFilter,
|
||||||
|
currentDate?: string,
|
||||||
|
) => {
|
||||||
|
const payload: any = {
|
||||||
|
sport: currentSport,
|
||||||
|
leagueId: currentLeague || undefined,
|
||||||
|
limit: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentDate) {
|
||||||
|
payload.date = currentDate;
|
||||||
|
} else if (currentFilter === "today") {
|
||||||
|
// YYYY-MM-DD for today
|
||||||
|
payload.date = new Date().toISOString().split("T")[0];
|
||||||
|
} else if (currentFilter === "live") {
|
||||||
|
payload.status = "LIVE";
|
||||||
|
} else if (currentFilter === "next_1_hour") {
|
||||||
|
payload.dateRange = {
|
||||||
|
from: new Date().toISOString(),
|
||||||
|
to: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
queryMatches.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-trigger query when sport or league changes
|
// Auto-trigger query when sport or league changes
|
||||||
const handleSportChange = (newSport: typeof sport) => {
|
const handleSportChange = (newSport: typeof sport) => {
|
||||||
setSport(newSport);
|
setSport(newSport);
|
||||||
queryMatches.mutate({
|
setLeague(null);
|
||||||
sport: newSport,
|
triggerQuery(newSport, null, quickFilter, dateFilter);
|
||||||
leagueId: undefined,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLeagueChange = (leagueId: string | null) => {
|
const handleLeagueChange = (leagueId: string | null) => {
|
||||||
setLeague(leagueId);
|
setLeague(leagueId);
|
||||||
queryMatches.mutate({
|
triggerQuery(sport, leagueId, quickFilter, dateFilter);
|
||||||
sport,
|
};
|
||||||
leagueId: leagueId || undefined,
|
|
||||||
limit: 100,
|
const handleQuickFilterChange = (filter: QuickFilter) => {
|
||||||
});
|
setDateFilter(""); // Clear specific date
|
||||||
|
setQuickFilter(filter);
|
||||||
|
triggerQuery(sport, leagueFilter, filter, undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queryMatches.data && !queryMatches.isPending) {
|
if (!queryMatches.data && !queryMatches.isPending) {
|
||||||
queryMatches.mutate({
|
triggerQuery(sport, leagueFilter, quickFilter, dateFilter);
|
||||||
sport,
|
|
||||||
leagueId: leagueFilter || undefined,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -75,7 +106,7 @@ export default function MatchesContent() {
|
|||||||
<Flex
|
<Flex
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
mb={6}
|
mb={4}
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
gap={3}
|
gap={3}
|
||||||
>
|
>
|
||||||
@@ -85,6 +116,85 @@ export default function MatchesContent() {
|
|||||||
<SportFilter value={sport} onChange={handleSportChange} />
|
<SportFilter value={sport} onChange={handleSportChange} />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{/* Quick Filters */}
|
||||||
|
<Flex
|
||||||
|
mb={6}
|
||||||
|
overflowX="auto"
|
||||||
|
pb={2}
|
||||||
|
css={{ "&::-webkit-scrollbar": { display: "none" } }}
|
||||||
|
gap={4}
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<Group attached>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickFilterChange("all")}
|
||||||
|
colorPalette={quickFilter === "all" ? "primary" : "gray"}
|
||||||
|
variant={quickFilter === "all" ? "solid" : "outline"}
|
||||||
|
>
|
||||||
|
{t("all-matches")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickFilterChange("today")}
|
||||||
|
colorPalette={quickFilter === "today" ? "primary" : "gray"}
|
||||||
|
variant={quickFilter === "today" ? "solid" : "outline"}
|
||||||
|
>
|
||||||
|
{t("today-matches")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickFilterChange("live")}
|
||||||
|
colorPalette={quickFilter === "live" ? "primary" : "gray"}
|
||||||
|
variant={quickFilter === "live" ? "solid" : "outline"}
|
||||||
|
>
|
||||||
|
{t("live")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickFilterChange("next_1_hour")}
|
||||||
|
colorPalette={quickFilter === "next_1_hour" ? "primary" : "gray"}
|
||||||
|
variant={quickFilter === "next_1_hour" ? "solid" : "outline"}
|
||||||
|
>
|
||||||
|
{t("next-1-hour")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
const dateVal = e.target.value;
|
||||||
|
setDateFilter(dateVal);
|
||||||
|
if (dateVal) {
|
||||||
|
setQuickFilter("all"); // Reset quick filter highlight
|
||||||
|
triggerQuery(sport, leagueFilter, "all", dateVal);
|
||||||
|
} else {
|
||||||
|
handleQuickFilterChange("all");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid var(--chakra-colors-gray-200)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
background: "transparent",
|
||||||
|
color: "inherit",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Mobile League Filter Bar (visible on small screens only) */}
|
||||||
|
<Box display={{ base: "block", lg: "none" }} mb={4}>
|
||||||
|
<LeagueFilterBar
|
||||||
|
leagues={leagues}
|
||||||
|
selectedLeagueId={leagueFilter}
|
||||||
|
onSelect={handleLeagueChange}
|
||||||
|
isLoading={leaguesLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Flex
|
<Flex
|
||||||
gap={6}
|
gap={6}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,698 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import {
|
||||||
|
LuBadgeCheck,
|
||||||
|
LuCircleHelp,
|
||||||
|
LuRectangleVertical,
|
||||||
|
LuShieldAlert,
|
||||||
|
LuTarget,
|
||||||
|
LuTrendingUp,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||||
|
import type {
|
||||||
|
HtftComboKey,
|
||||||
|
OddsBandCardsDto,
|
||||||
|
OddsBandHtftComboDto,
|
||||||
|
TripleValueEntryDto,
|
||||||
|
V27EngineDto,
|
||||||
|
} from "@/lib/api/predictions/types";
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function pct(v: number, d = 0): string {
|
||||||
|
if (!v && v !== 0) return "-";
|
||||||
|
return `${(v * 100).toFixed(d)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeStr(edge: number): string {
|
||||||
|
const sign = edge > 0 ? "+" : "";
|
||||||
|
return `${sign}${(edge * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIPLE_VALUE_LABELS: Record<string, string> = {
|
||||||
|
home: "MS Ev",
|
||||||
|
away: "MS Dep",
|
||||||
|
ou25_over: "ÜST 2.5",
|
||||||
|
btts_yes: "KG Var",
|
||||||
|
ou15_over: "ÜST 1.5",
|
||||||
|
ou35_over: "ÜST 3.5",
|
||||||
|
dc_1x: "ÇŞ 1X",
|
||||||
|
dc_x2: "ÇŞ X2",
|
||||||
|
dc_12: "ÇŞ 12",
|
||||||
|
ht_home: "İY Ev",
|
||||||
|
ht_away: "İY Dep",
|
||||||
|
ht_ou05_over: "İY ÜST 0.5",
|
||||||
|
ht_ou15_over: "İY ÜST 1.5",
|
||||||
|
oe_odd: "Tek",
|
||||||
|
cards_over: "Kart ÜST",
|
||||||
|
htft_11: "İY/MS 1/1",
|
||||||
|
htft_1x: "İY/MS 1/X",
|
||||||
|
htft_12: "İY/MS 1/2",
|
||||||
|
htft_x1: "İY/MS X/1",
|
||||||
|
htft_xx: "İY/MS X/X",
|
||||||
|
htft_x2: "İY/MS X/2",
|
||||||
|
htft_21: "İY/MS 2/1",
|
||||||
|
htft_2x: "İY/MS 2/X",
|
||||||
|
htft_22: "İY/MS 2/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTFT_DISPLAY: Record<HtftComboKey, string> = {
|
||||||
|
"11": "1/1",
|
||||||
|
"1x": "1/X",
|
||||||
|
"12": "1/2",
|
||||||
|
x1: "X/1",
|
||||||
|
xx: "X/X",
|
||||||
|
x2: "X/2",
|
||||||
|
"21": "2/1",
|
||||||
|
"2x": "2/X",
|
||||||
|
"22": "2/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTFT_ROWS: HtftComboKey[][] = [
|
||||||
|
["11", "1x", "12"],
|
||||||
|
["x1", "xx", "x2"],
|
||||||
|
["21", "2x", "22"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function TooltipIcon({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={content}
|
||||||
|
showArrow
|
||||||
|
positioning={{ placement: "top" }}
|
||||||
|
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Bilgi"
|
||||||
|
variant="ghost"
|
||||||
|
size="2xs"
|
||||||
|
colorPalette="gray"
|
||||||
|
>
|
||||||
|
<LuCircleHelp />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
info,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
info?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={icon} boxSize={4.5} color="fg.muted" />
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{info ? <TooltipIcon content={info} /> : null}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Triple Value Card
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function TripleValueCard({
|
||||||
|
label,
|
||||||
|
entry,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
entry: TripleValueEntryDto;
|
||||||
|
}) {
|
||||||
|
const isValue = entry.is_value;
|
||||||
|
const hasSample = entry.band_sample >= 5;
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue(
|
||||||
|
isValue ? "green.50" : "gray.50",
|
||||||
|
isValue ? "green.950" : "whiteAlpha.50",
|
||||||
|
);
|
||||||
|
const borderCol = useColorModeValue(
|
||||||
|
isValue ? "green.300" : "gray.200",
|
||||||
|
isValue ? "green.700" : "gray.700",
|
||||||
|
);
|
||||||
|
const edgeColor =
|
||||||
|
entry.edge > 0.03
|
||||||
|
? "green.500"
|
||||||
|
: entry.edge < -0.03
|
||||||
|
? "red.400"
|
||||||
|
: "fg.muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderCol}
|
||||||
|
borderRadius="xl"
|
||||||
|
position="relative"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{isValue && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
h="3px"
|
||||||
|
bgGradient="to-r"
|
||||||
|
gradientFrom="green.400"
|
||||||
|
gradientTo="teal.400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<VStack align="stretch" gap={1.5}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Text fontSize="xs" fontWeight="semibold" color="fg.muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{isValue ? (
|
||||||
|
<Badge
|
||||||
|
colorPalette="green"
|
||||||
|
variant="solid"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
DEĞER
|
||||||
|
</Badge>
|
||||||
|
) : hasSample ? (
|
||||||
|
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||||||
|
PAS
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
colorPalette="gray"
|
||||||
|
variant="subtle"
|
||||||
|
fontSize="2xs"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
YETERSİZ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="xl" fontWeight="bold" color={edgeColor}>
|
||||||
|
{edgeStr(entry.edge)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<HStack gap={2} flexWrap="wrap">
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Band: {pct(entry.band_rate, 1)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Oran: {pct(entry.implied_prob, 1)}
|
||||||
|
</Text>
|
||||||
|
{entry.confirmations !== undefined && (
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Onay: {entry.confirmations}/2
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{entry.band_sample} maç
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Cards Section
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
value,
|
||||||
|
max,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const trackBg = useColorModeValue("gray.100", "gray.700");
|
||||||
|
const w = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Box h="10px" w="full" bg={trackBg} borderRadius="full" overflow="hidden">
|
||||||
|
<Box
|
||||||
|
h="full"
|
||||||
|
w={`${w}%`}
|
||||||
|
bg={color}
|
||||||
|
borderRadius="full"
|
||||||
|
transition="width 0.4s ease"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardsSection({ cards }: { cards: OddsBandCardsDto }) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
const hasData = cards.sample >= 3;
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<HStack gap={2} mb={2}>
|
||||||
|
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
Kart Analizi
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
Yetersiz veri — henüz yeterli maç örneği bulunamadı.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overPct = cards.combined_over_rate * 100;
|
||||||
|
const overColor =
|
||||||
|
overPct >= 65 ? "red.400" : overPct >= 50 ? "orange.400" : "green.400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
Kart Analizi
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||||||
|
{cards.sample} maç
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="stretch" gap={3}>
|
||||||
|
{/* Referee profile */}
|
||||||
|
<Box>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Hakem Profili
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
Ort: {cards.referee_avg.toFixed(1)} kart
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<ProgressBar
|
||||||
|
value={cards.referee_over_rate * 100}
|
||||||
|
max={100}
|
||||||
|
color="purple.400"
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" mt={0.5}>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Üst oranı: {pct(cards.referee_over_rate, 0)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{cards.referee_sample} maç
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Team profile */}
|
||||||
|
<Box>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Takım Profili
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
Ort: {cards.team_avg.toFixed(1)} kart
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<ProgressBar
|
||||||
|
value={cards.team_over_rate * 100}
|
||||||
|
max={100}
|
||||||
|
color="blue.400"
|
||||||
|
/>
|
||||||
|
<HStack justify="space-between" mt={0.5}>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
Üst oranı: {pct(cards.team_over_rate, 0)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
{cards.team_sample} maç
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Combined */}
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<VStack align="start" gap={0}>
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
Kombine ÜST Oranı
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xs" color="fg.muted">
|
||||||
|
%60 Hakem + %40 Takım ağırlıklı
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color={overColor}>
|
||||||
|
{overPct.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// HTFT 3x3 Grid
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
function HtftGrid({
|
||||||
|
htft,
|
||||||
|
}: {
|
||||||
|
htft: Record<HtftComboKey, OddsBandHtftComboDto>;
|
||||||
|
}) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
|
||||||
|
// Find the max rate for highlighting
|
||||||
|
let maxRate = 0;
|
||||||
|
let maxKey: HtftComboKey = "11";
|
||||||
|
let totalSample = 0;
|
||||||
|
for (const [key, val] of Object.entries(htft) as [
|
||||||
|
HtftComboKey,
|
||||||
|
OddsBandHtftComboDto,
|
||||||
|
][]) {
|
||||||
|
if (val.rate > maxRate) {
|
||||||
|
maxRate = val.rate;
|
||||||
|
maxKey = key;
|
||||||
|
}
|
||||||
|
totalSample = Math.max(totalSample, val.sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellColor = (rate: number, isMax: boolean) => {
|
||||||
|
if (isMax) return { bg: "green.500", text: "white" };
|
||||||
|
if (rate >= 0.2) return { bg: "green.100", text: "green.800" };
|
||||||
|
if (rate >= 0.12) return { bg: "yellow.100", text: "yellow.800" };
|
||||||
|
if (rate >= 0.06) return { bg: "orange.50", text: "orange.700" };
|
||||||
|
return { bg: "gray.50", text: "gray.500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellColorDark = (rate: number, isMax: boolean) => {
|
||||||
|
if (isMax) return { bg: "green.600", text: "white" };
|
||||||
|
if (rate >= 0.2) return { bg: "green.900", text: "green.200" };
|
||||||
|
if (rate >= 0.12) return { bg: "yellow.900", text: "yellow.200" };
|
||||||
|
if (rate >= 0.06) return { bg: "orange.900", text: "orange.200" };
|
||||||
|
return { bg: "whiteAlpha.50", text: "gray.500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightMode = useColorModeValue(true, false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" mb={4}>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuTarget} boxSize={4} color="teal.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
İY/MS Kombinasyonları
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<TooltipIcon content="İlk yarı sonucu ve maç sonucu kombinasyonlarının tarihsel oran bandındaki gerçekleşme oranları." />
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}>
|
||||||
|
<Box />
|
||||||
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
textAlign="center"
|
||||||
|
color="fg.muted"
|
||||||
|
>
|
||||||
|
MS 1
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
textAlign="center"
|
||||||
|
color="fg.muted"
|
||||||
|
>
|
||||||
|
MS X
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
textAlign="center"
|
||||||
|
color="fg.muted"
|
||||||
|
>
|
||||||
|
MS 2
|
||||||
|
</Text>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Grid rows */}
|
||||||
|
{HTFT_ROWS.map((row, rowIdx) => {
|
||||||
|
const rowLabels = ["İY 1", "İY X", "İY 2"];
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
key={rowIdx}
|
||||||
|
templateColumns="50px repeat(3, 1fr)"
|
||||||
|
gap={1.5}
|
||||||
|
mb={1.5}
|
||||||
|
>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Text fontSize="2xs" fontWeight="bold" color="fg.muted">
|
||||||
|
{rowLabels[rowIdx]}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{row.map((comboKey) => {
|
||||||
|
const data = htft[comboKey] || { rate: 0, sample: 0 };
|
||||||
|
const isMax = comboKey === maxKey && maxRate > 0.05;
|
||||||
|
const colors = lightMode
|
||||||
|
? getCellColor(data.rate, isMax)
|
||||||
|
: getCellColorDark(data.rate, isMax);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={comboKey}
|
||||||
|
py={2.5}
|
||||||
|
px={2}
|
||||||
|
bg={colors.bg}
|
||||||
|
borderRadius="lg"
|
||||||
|
textAlign="center"
|
||||||
|
position="relative"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ transform: "scale(1.04)" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={colors.text}
|
||||||
|
mb={0.5}
|
||||||
|
>
|
||||||
|
{HTFT_DISPLAY[comboKey]}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="extrabold"
|
||||||
|
color={colors.text}
|
||||||
|
>
|
||||||
|
{pct(data.rate, 0)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
color={isMax ? "whiteAlpha.800" : "fg.muted"}
|
||||||
|
>
|
||||||
|
{data.sample} maç
|
||||||
|
</Text>
|
||||||
|
{isMax && (
|
||||||
|
<Icon
|
||||||
|
as={LuBadgeCheck}
|
||||||
|
position="absolute"
|
||||||
|
top={1}
|
||||||
|
right={1}
|
||||||
|
boxSize={3.5}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Best combo callout */}
|
||||||
|
{maxRate > 0.05 && (
|
||||||
|
<Box
|
||||||
|
mt={2}
|
||||||
|
p={2.5}
|
||||||
|
bg={useColorModeValue("green.50", "green.950")}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Icon as={LuTrendingUp} boxSize={4} color="green.500" />
|
||||||
|
<Text fontSize="xs" fontWeight="semibold">
|
||||||
|
En güçlü:{" "}
|
||||||
|
<Text as="span" color="green.500">
|
||||||
|
{HTFT_DISPLAY[maxKey]} ({pct(maxRate, 0)})
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Main Panel Export
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface V28OddsBandPanelProps {
|
||||||
|
engine: V27EngineDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
|
|
||||||
|
const tripleValue = engine.triple_value;
|
||||||
|
const cards = engine.odds_band?.cards as OddsBandCardsDto | undefined;
|
||||||
|
const htft = engine.odds_band?.htft as
|
||||||
|
| Record<HtftComboKey, OddsBandHtftComboDto>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Filter out HTFT triple-value entries from the main grid (shown in HTFT section)
|
||||||
|
const mainValueEntries = Object.entries(tripleValue || {}).filter(
|
||||||
|
([key]) => !key.startsWith("htft_"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate value hits from non-hits for priority ordering
|
||||||
|
const valueHits = mainValueEntries.filter(([, e]) => e.is_value);
|
||||||
|
const valueNon = mainValueEntries.filter(([, e]) => !e.is_value);
|
||||||
|
const orderedEntries = [...valueHits, ...valueNon];
|
||||||
|
|
||||||
|
const hasTriple = orderedEntries.length > 0;
|
||||||
|
const hasCards = cards && cards.sample >= 1;
|
||||||
|
const hasHtft = htft && Object.values(htft).some((v) => v.sample > 0);
|
||||||
|
|
||||||
|
if (!hasTriple && !hasCards && !hasHtft) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||||
|
<Card.Body gap={5}>
|
||||||
|
<SectionTitle
|
||||||
|
icon={LuShieldAlert}
|
||||||
|
title="V28 Oran Bandı Analizi"
|
||||||
|
info="Geçmiş maçlarda benzer oranlarda gerçekleşen sonuçların istatistiksel analizi. Triple Value, Kart Profili ve İY/MS kombinasyonlarını içerir."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Engine version badge */}
|
||||||
|
<HStack>
|
||||||
|
<Badge
|
||||||
|
colorPalette="purple"
|
||||||
|
variant="subtle"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="2xs"
|
||||||
|
>
|
||||||
|
{engine.version}
|
||||||
|
</Badge>
|
||||||
|
{engine.consensus && (
|
||||||
|
<Badge
|
||||||
|
colorPalette={engine.consensus === "AGREE" ? "green" : "orange"}
|
||||||
|
variant="solid"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="2xs"
|
||||||
|
>
|
||||||
|
{engine.consensus === "AGREE"
|
||||||
|
? "Motorlar Uyumlu"
|
||||||
|
: "Motorlar Farklı"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{valueHits.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
colorPalette="green"
|
||||||
|
variant="outline"
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="2xs"
|
||||||
|
>
|
||||||
|
{valueHits.length} Değer Sinyali
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Triple Value Grid */}
|
||||||
|
{hasTriple && (
|
||||||
|
<Box>
|
||||||
|
<HStack mb={3} gap={2}>
|
||||||
|
<Icon as={LuTarget} boxSize={4} color="blue.500" />
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
Değer Tespiti (Triple Value)
|
||||||
|
</Text>
|
||||||
|
<TooltipIcon content="Model olasılığı, oran bandı istatistiği ve piyasa oranı karşılaştırması. Edge pozitifse model avantaj görüyor demektir." />
|
||||||
|
</HStack>
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 3, xl: 4 }} gap={2.5}>
|
||||||
|
{orderedEntries.map(([key, entry]) => (
|
||||||
|
<TripleValueCard
|
||||||
|
key={key}
|
||||||
|
label={TRIPLE_VALUE_LABELS[key] || key}
|
||||||
|
entry={entry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cards + HTFT side by side on large screens */}
|
||||||
|
{(hasCards || hasHtft) && (
|
||||||
|
<Grid
|
||||||
|
templateColumns={{
|
||||||
|
base: "1fr",
|
||||||
|
xl: hasCards && hasHtft ? "1fr 1fr" : "1fr",
|
||||||
|
}}
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
{hasCards && <CardsSection cards={cards} />}
|
||||||
|
{hasHtft && <HtftGrid htft={htft} />}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
useInView,
|
useInView,
|
||||||
type HTMLMotionProps,
|
type HTMLMotionProps,
|
||||||
} from "framer-motion";
|
} from "framer-motion";
|
||||||
import { forwardRef, type ReactNode, useEffect, useRef } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
Key,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Shared animation variants
|
// Shared animation variants
|
||||||
@@ -381,34 +388,92 @@ interface SparkleProps {
|
|||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sparkles({ count = 6, color = "rgba(56, 178, 172, 0.6)" }: SparkleProps) {
|
interface SparkleConfig {
|
||||||
|
id: number;
|
||||||
|
size: number;
|
||||||
|
left: number;
|
||||||
|
bottom: number;
|
||||||
|
y: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sparkles({
|
||||||
|
count = 6,
|
||||||
|
color = "rgba(56, 178, 172, 0.6)",
|
||||||
|
}: SparkleProps) {
|
||||||
|
const [sparkles, setSparkles] = useState<SparkleConfig[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newSparkles = Array.from({ length: count }).map((_, i) => ({
|
||||||
|
id: i,
|
||||||
|
size: 4 + Math.random() * 4,
|
||||||
|
left: 10 + Math.random() * 80,
|
||||||
|
bottom: Math.random() * 30,
|
||||||
|
y: -(60 + Math.random() * 80),
|
||||||
|
duration: 2.5 + Math.random() * 2,
|
||||||
|
delay: Math.random() * 3,
|
||||||
|
}));
|
||||||
|
setSparkles(newSparkles);
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
if (sparkles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
|
<div
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: 4 + Math.random() * 4,
|
inset: 0,
|
||||||
height: 4 + Math.random() * 4,
|
overflow: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sparkles.map(
|
||||||
|
(sparkle: {
|
||||||
|
id: Key | null | undefined;
|
||||||
|
size: any;
|
||||||
|
left: any;
|
||||||
|
bottom: any;
|
||||||
|
y: string | number | null;
|
||||||
|
duration: any;
|
||||||
|
delay: any;
|
||||||
|
}) => (
|
||||||
|
<motion.div
|
||||||
|
key={sparkle.id}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: sparkle.size,
|
||||||
|
height: sparkle.size,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: color,
|
background: color,
|
||||||
left: `${10 + Math.random() * 80}%`,
|
left: `${sparkle.left}%`,
|
||||||
bottom: `${Math.random() * 30}%`,
|
bottom: `${sparkle.bottom}%`,
|
||||||
}}
|
}}
|
||||||
animate={{
|
animate={{
|
||||||
y: [0, -(60 + Math.random() * 80)],
|
y: [0, sparkle.y],
|
||||||
opacity: [0, 1, 1, 0],
|
opacity: [0, 1, 1, 0],
|
||||||
scale: [0.5, 1, 0.8, 0],
|
scale: [0.5, 1, 0.8, 0],
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 2.5 + Math.random() * 2,
|
duration: sparkle.duration,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
delay: Math.random() * 3,
|
delay: sparkle.delay,
|
||||||
ease: "easeOut",
|
ease: "easeOut",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,8 +154,9 @@ export default function PredictionsContent() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
colorPalette={
|
colorPalette={
|
||||||
riskColors[pred.risk?.level?.toUpperCase()] ||
|
riskColors[
|
||||||
"gray"
|
pred.risk?.level?.toUpperCase()
|
||||||
|
] || "gray"
|
||||||
}
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
fontSize="2xs"
|
fontSize="2xs"
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
SimpleGrid,
|
||||||
|
Spinner,
|
||||||
|
Card,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSubscriptionPlans } from "@/lib/api/subscriptions/use-hooks";
|
||||||
|
import { usePaddleCheckout } from "@/lib/paddle";
|
||||||
|
import { PricingCard } from "@/components/subscription";
|
||||||
|
import { LoginModal } from "@/components/auth/login-modal";
|
||||||
|
import { SlideUp } from "@/components/motion";
|
||||||
|
import { SegmentedControl } from "@/components/ui/forms/segmented-control";
|
||||||
|
import type {
|
||||||
|
PlanInfo,
|
||||||
|
PlanType,
|
||||||
|
BillingInterval,
|
||||||
|
} from "@/lib/api/subscriptions/types";
|
||||||
|
import { LuMessageCircleQuestion, LuChevronDown } from "react-icons/lu";
|
||||||
|
|
||||||
|
/** Static fallback plans used when the backend is not reachable */
|
||||||
|
const FALLBACK_PLANS: readonly PlanInfo[] = [
|
||||||
|
{
|
||||||
|
id: "free" as PlanType,
|
||||||
|
name: "Free",
|
||||||
|
description: "",
|
||||||
|
monthlyPrice: 0,
|
||||||
|
yearlyPrice: 0,
|
||||||
|
currency: "TRY",
|
||||||
|
features: [],
|
||||||
|
limits: { maxAnalyses: 3, maxCoupons: 1 },
|
||||||
|
highlighted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plus" as PlanType,
|
||||||
|
name: "Plus",
|
||||||
|
description: "",
|
||||||
|
monthlyPrice: 149,
|
||||||
|
yearlyPrice: 1490,
|
||||||
|
currency: "TRY",
|
||||||
|
features: [],
|
||||||
|
limits: { maxAnalyses: 15, maxCoupons: 5 },
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "premium" as PlanType,
|
||||||
|
name: "Premium",
|
||||||
|
description: "",
|
||||||
|
monthlyPrice: 349,
|
||||||
|
yearlyPrice: 3490,
|
||||||
|
currency: "TRY",
|
||||||
|
features: [],
|
||||||
|
limits: { maxAnalyses: 999, maxCoupons: 999 },
|
||||||
|
highlighted: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main pricing page content.
|
||||||
|
* Fetches plans from API, shows monthly/yearly toggle,
|
||||||
|
* renders pricing cards and FAQ section.
|
||||||
|
*/
|
||||||
|
export default function PricingContent() {
|
||||||
|
const t = useTranslations("pricing");
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const { data: plansData, isLoading } = useSubscriptionPlans();
|
||||||
|
const { startCheckout, isLoading: checkoutLoading } = usePaddleCheckout();
|
||||||
|
|
||||||
|
const [isYearly, setIsYearly] = useState(false);
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
const [selectedCheckoutPlan, setSelectedCheckoutPlan] =
|
||||||
|
useState<PlanType | null>(null);
|
||||||
|
|
||||||
|
const subtitleColor = useColorModeValue("gray.600", "gray.400");
|
||||||
|
|
||||||
|
const currentPlan = session?.user?.subscriptionPlan ?? "free";
|
||||||
|
const plans = plansData?.data ?? FALLBACK_PLANS;
|
||||||
|
|
||||||
|
// Enrich plans with i18n translated names/descriptions/features
|
||||||
|
const enrichedPlans: readonly PlanInfo[] = plans.map((plan) => {
|
||||||
|
const planKey = String(plan.id).toLowerCase() as
|
||||||
|
| "free"
|
||||||
|
| "plus"
|
||||||
|
| "premium";
|
||||||
|
const featureKeys = getFeatureKeysForPlan(planKey, plan.limits);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
name: t(`plan.${planKey}.name`),
|
||||||
|
description: t(`plan.${planKey}.description`),
|
||||||
|
features: featureKeys.map((key) => {
|
||||||
|
const match = key.match(/^(\d+)\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const count = match[1];
|
||||||
|
const transKey = match[2];
|
||||||
|
return `${count} ${t(`feature.${transKey}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith("unlimited-")) {
|
||||||
|
const transKey = key.replace("unlimited-", "");
|
||||||
|
return `${t("feature.unlimited")} ${t(`feature.${transKey}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(`feature.${key}`);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePlanSelect = (plan: PlanInfo) => {
|
||||||
|
if (plan.id === "free") return;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCheckoutPlan(plan.id);
|
||||||
|
const interval: BillingInterval = isYearly
|
||||||
|
? ("yearly" as BillingInterval)
|
||||||
|
: ("monthly" as BillingInterval);
|
||||||
|
startCheckout(plan.id, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqItems = [
|
||||||
|
{ q: t("faq.q1"), a: t("faq.a1") },
|
||||||
|
{ q: t("faq.q2"), a: t("faq.a2") },
|
||||||
|
{ q: t("faq.q3"), a: t("faq.a3") },
|
||||||
|
{ q: t("faq.q4"), a: t("faq.a4") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlideUp>
|
||||||
|
<Box py={{ base: 8, md: 16 }}>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<VStack gap={4} textAlign="center" mb={{ base: 10, md: 14 }}>
|
||||||
|
<Heading
|
||||||
|
as="h1"
|
||||||
|
fontSize={{ base: "3xl", md: "4xl", lg: "5xl" }}
|
||||||
|
fontWeight="900"
|
||||||
|
letterSpacing="-0.03em"
|
||||||
|
lineHeight="1.1"
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</Heading>
|
||||||
|
<Text
|
||||||
|
fontSize={{ base: "md", md: "lg" }}
|
||||||
|
color={subtitleColor}
|
||||||
|
maxW="xl"
|
||||||
|
>
|
||||||
|
{t("subtitle")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Monthly / Yearly Toggle */}
|
||||||
|
<HStack
|
||||||
|
gap={3}
|
||||||
|
bg={useColorModeValue("gray.50", "gray.900")}
|
||||||
|
p={1.5}
|
||||||
|
borderRadius="xl"
|
||||||
|
mt={2}
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={isYearly ? "yearly" : "monthly"}
|
||||||
|
onValueChange={(details) =>
|
||||||
|
setIsYearly(details.value === "yearly")
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{ label: t("monthly"), value: "monthly" },
|
||||||
|
{
|
||||||
|
label: `${t("yearly")} 🎁`,
|
||||||
|
value: "yearly",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isYearly && (
|
||||||
|
<Text fontSize="sm" color="primary.500" fontWeight="semibold">
|
||||||
|
✨ {t("yearly-save")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Pricing Cards */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Flex justify="center" py={20}>
|
||||||
|
<Spinner size="lg" color="primary.500" />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid
|
||||||
|
columns={{ base: 1, md: 3 }}
|
||||||
|
gap={{ base: 6, md: 8 }}
|
||||||
|
maxW="5xl"
|
||||||
|
mx="auto"
|
||||||
|
>
|
||||||
|
{enrichedPlans.map((plan) => (
|
||||||
|
<PricingCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
isCurrentPlan={currentPlan === plan.id}
|
||||||
|
isYearly={isYearly}
|
||||||
|
onSelect={handlePlanSelect}
|
||||||
|
isLoading={
|
||||||
|
checkoutLoading && selectedCheckoutPlan === plan.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAQ Section */}
|
||||||
|
<Box maxW="3xl" mx="auto" mt={{ base: 16, md: 24 }}>
|
||||||
|
<VStack gap={2} textAlign="center" mb={8}>
|
||||||
|
<HStack gap={2} color="primary.500">
|
||||||
|
<LuMessageCircleQuestion size={24} />
|
||||||
|
<Heading as="h2" size="lg" fontWeight="bold">
|
||||||
|
{t("faq-title")}
|
||||||
|
</Heading>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
{faqItems.map((item, idx) => (
|
||||||
|
<FaqItem key={idx} question={item.q} answer={item.a} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SlideUp>
|
||||||
|
|
||||||
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onOpenChange={setLoginModalOpen}
|
||||||
|
initialMode="login"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Internal helpers
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getFeatureKeysForPlan(
|
||||||
|
planKey: "free" | "plus" | "premium",
|
||||||
|
limits: { maxAnalyses: number; maxCoupons: number },
|
||||||
|
): string[] {
|
||||||
|
const analysisLabel =
|
||||||
|
limits.maxAnalyses >= 999
|
||||||
|
? "unlimited-daily-analyses"
|
||||||
|
: `${limits.maxAnalyses} daily-analyses`;
|
||||||
|
const couponLabel =
|
||||||
|
limits.maxCoupons >= 999
|
||||||
|
? "unlimited-daily-coupons"
|
||||||
|
: `${limits.maxCoupons} daily-coupons`;
|
||||||
|
|
||||||
|
const base = [analysisLabel, couponLabel, "basic-analysis"];
|
||||||
|
|
||||||
|
if (planKey === "plus") {
|
||||||
|
return [...base, "detailed-analysis", "h2h-comparison", "coupon-builder"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planKey === "premium") {
|
||||||
|
return [
|
||||||
|
...base,
|
||||||
|
"detailed-analysis",
|
||||||
|
"h2h-comparison",
|
||||||
|
"coupon-builder",
|
||||||
|
"spor-toto",
|
||||||
|
"ad-free",
|
||||||
|
"priority-support",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapsible FAQ item */
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const cardBg = useColorModeValue("white", "gray.900");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root
|
||||||
|
bg={cardBg}
|
||||||
|
borderRadius="xl"
|
||||||
|
cursor="pointer"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ shadow: "md" }}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Card.Body p={5}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Text fontWeight="semibold" fontSize="sm">
|
||||||
|
{question}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
transform={open ? "rotate(180deg)" : "rotate(0deg)"}
|
||||||
|
transition="transform 0.2s"
|
||||||
|
flexShrink={0}
|
||||||
|
ml={3}
|
||||||
|
>
|
||||||
|
<LuChevronDown size={18} />
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
{open && (
|
||||||
|
<Text mt={3} fontSize="sm" color="fg.muted" lineHeight="1.7">
|
||||||
|
{answer}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import * as yup from "yup";
|
|||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SubscriptionCard } from "@/components/subscription";
|
||||||
|
|
||||||
interface InfoRowProps {
|
interface InfoRowProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -174,6 +175,9 @@ export default function ProfileContent() {
|
|||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Subscription Info */}
|
||||||
|
<SubscriptionCard />
|
||||||
|
|
||||||
{/* Account Info */}
|
{/* Account Info */}
|
||||||
<Card.Root
|
<Card.Root
|
||||||
bg={cardBg}
|
bg={cardBg}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
|
|||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { LuSearch, LuX } from "react-icons/lu";
|
import { LuSearch, LuX } from "react-icons/lu";
|
||||||
import type { TeamDto } from "@/lib/api/leagues/types";
|
import type { TeamDto } from "@/lib/api/leagues/types";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
export default function GlobalSearch() {
|
export default function GlobalSearch() {
|
||||||
|
const { data: session } = useSession();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
@@ -35,6 +37,7 @@ export default function GlobalSearch() {
|
|||||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
const hoverBg = useColorModeValue("gray.50", "gray.800");
|
const hoverBg = useColorModeValue("gray.50", "gray.800");
|
||||||
const inputBg = useColorModeValue("gray.50", "gray.800");
|
const inputBg = useColorModeValue("gray.50", "gray.800");
|
||||||
|
const shortcutBg = useColorModeValue("gray.100", "gray.700");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||||
@@ -85,6 +88,12 @@ export default function GlobalSearch() {
|
|||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If user is not logged in, don't show the team search,
|
||||||
|
// as it requires auth to view team detail pages.
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -142,7 +151,7 @@ export default function GlobalSearch() {
|
|||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
color="fg.muted"
|
color="fg.muted"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
bg={useColorModeValue("gray.100", "gray.700")}
|
bg={shortcutBg}
|
||||||
px={1.5}
|
px={1.5}
|
||||||
py={0.5}
|
py={0.5}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ function HomeCard() {
|
|||||||
const imagesCollection = createListCollection({ items: images });
|
const imagesCollection = createListCollection({ items: images });
|
||||||
|
|
||||||
const currentImage = imagesCollection.items.find(
|
const currentImage = imagesCollection.items.find(
|
||||||
(img) => img.value === selectedImage
|
(img) => img.value === selectedImage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeCollection = createTreeCollection<Node>({
|
const nodeCollection = createTreeCollection<Node>({
|
||||||
@@ -410,7 +410,7 @@ function HomeCard() {
|
|||||||
|
|
||||||
const [tabs, setTabs] = React.useState<Item[]>(itemsTabs);
|
const [tabs, setTabs] = React.useState<Item[]>(itemsTabs);
|
||||||
const [selectedTab, setSelectedTab] = React.useState<string | null>(
|
const [selectedTab, setSelectedTab] = React.useState<string | null>(
|
||||||
itemsTabs[0].id
|
itemsTabs[0].id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const uuid = () => {
|
const uuid = () => {
|
||||||
@@ -2682,7 +2682,7 @@ function HomeCard() {
|
|||||||
}
|
}
|
||||||
onCheckedChange={(changes) => {
|
onCheckedChange={(changes) => {
|
||||||
setSelection(
|
setSelection(
|
||||||
changes.checked ? items.map((item) => item.name) : []
|
changes.checked ? items.map((item) => item.name) : [],
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -2710,7 +2710,7 @@ function HomeCard() {
|
|||||||
setSelection((prev) =>
|
setSelection((prev) =>
|
||||||
changes.checked
|
changes.checked
|
||||||
? [...prev, item.name]
|
? [...prev, item.name]
|
||||||
: selection.filter((name) => name !== item.name)
|
: selection.filter((name) => name !== item.name),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
useSyncBulletin,
|
useSyncBulletin,
|
||||||
useRolloverHistory,
|
useRolloverHistory,
|
||||||
} from "@/lib/api/spor-toto/use-hooks";
|
} from "@/lib/api/spor-toto/use-hooks";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { UsersQueryKeys } from "@/lib/api/users/use-hooks";
|
||||||
import type {
|
import type {
|
||||||
SporTotoBulletinDto,
|
SporTotoBulletinDto,
|
||||||
SporTotoPredictionResultDto,
|
SporTotoPredictionResultDto,
|
||||||
@@ -59,15 +61,11 @@ export default function SporTotoContent() {
|
|||||||
const rolloverHistory = useRolloverHistory(10);
|
const rolloverHistory = useRolloverHistory(10);
|
||||||
const syncBulletin = useSyncBulletin();
|
const syncBulletin = useSyncBulletin();
|
||||||
const generatePrediction = useGeneratePrediction();
|
const generatePrediction = useGeneratePrediction();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const toast = (opts: { title: string; status: string }) =>
|
const toast = (opts: { title: string; status: string }) =>
|
||||||
toaster.create({
|
toaster.create({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
type: opts.status as
|
type: opts.status as "success" | "warning" | "error" | "info" | "loading",
|
||||||
| "success"
|
|
||||||
| "warning"
|
|
||||||
| "error"
|
|
||||||
| "info"
|
|
||||||
| "loading",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
@@ -91,6 +89,7 @@ export default function SporTotoContent() {
|
|||||||
bulletinId: selectedBulletin,
|
bulletinId: selectedBulletin,
|
||||||
strategy,
|
strategy,
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: UsersQueryKeys.me() });
|
||||||
toast({
|
toast({
|
||||||
title: t("prediction-generated"),
|
title: t("prediction-generated"),
|
||||||
status: "success",
|
status: "success",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { PlanBadge } from "./plan-badge";
|
||||||
|
export { PricingCard } from "./pricing-card";
|
||||||
|
export { SubscriptionCard } from "./subscription-card";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@chakra-ui/react";
|
||||||
|
import type { PlanType } from "@/lib/api/subscriptions/types";
|
||||||
|
|
||||||
|
interface PlanBadgeProps {
|
||||||
|
plan: PlanType | "free" | "plus" | "premium";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const planColorMap: Record<string, string> = {
|
||||||
|
free: "gray",
|
||||||
|
plus: "blue",
|
||||||
|
premium: "yellow",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A colored badge showing the user's subscription plan.
|
||||||
|
* Uses Chakra v3 Badge component with colorPalette.
|
||||||
|
*/
|
||||||
|
export function PlanBadge({ plan, size = "sm" }: PlanBadgeProps) {
|
||||||
|
const planKey = String(plan).toLowerCase();
|
||||||
|
const colorPalette = planColorMap[planKey] ?? "gray";
|
||||||
|
const label = planKey.charAt(0).toUpperCase() + planKey.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
colorPalette={colorPalette}
|
||||||
|
variant="solid"
|
||||||
|
size={size}
|
||||||
|
borderRadius="full"
|
||||||
|
px={2}
|
||||||
|
fontWeight="bold"
|
||||||
|
letterSpacing="0.02em"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import type { PlanInfo } from "@/lib/api/subscriptions/types";
|
||||||
|
import { LuCheck, LuZap, LuCrown, LuRocket } from "react-icons/lu";
|
||||||
|
|
||||||
|
interface PricingCardProps {
|
||||||
|
plan: PlanInfo;
|
||||||
|
isCurrentPlan: boolean;
|
||||||
|
isYearly: boolean;
|
||||||
|
onSelect: (plan: PlanInfo) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planIconMap: Record<string, React.ReactNode> = {
|
||||||
|
free: <LuZap size={20} />,
|
||||||
|
plus: <LuRocket size={20} />,
|
||||||
|
premium: <LuCrown size={20} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual pricing card for a subscription plan.
|
||||||
|
* Shows plan details, features, price, and CTA button.
|
||||||
|
*/
|
||||||
|
export function PricingCard({
|
||||||
|
plan,
|
||||||
|
isCurrentPlan,
|
||||||
|
isYearly,
|
||||||
|
onSelect,
|
||||||
|
isLoading,
|
||||||
|
}: PricingCardProps) {
|
||||||
|
const t = useTranslations("pricing");
|
||||||
|
|
||||||
|
const highlightBorder = useColorModeValue("primary.500", "primary.400");
|
||||||
|
const cardBg = useColorModeValue("white", "gray.900");
|
||||||
|
const mutedText = useColorModeValue("gray.500", "gray.400");
|
||||||
|
|
||||||
|
const price = isYearly ? plan.yearlyPrice : plan.monthlyPrice;
|
||||||
|
const displayPrice = isYearly
|
||||||
|
? Math.round(plan.yearlyPrice / 12)
|
||||||
|
: plan.monthlyPrice;
|
||||||
|
const isFree = plan.id === "free";
|
||||||
|
|
||||||
|
const buttonLabel = isCurrentPlan
|
||||||
|
? t("current-plan")
|
||||||
|
: isFree
|
||||||
|
? t("get-started")
|
||||||
|
: t("upgrade");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root
|
||||||
|
bg={cardBg}
|
||||||
|
borderWidth={plan.highlighted ? "2px" : "1px"}
|
||||||
|
borderColor={plan.highlighted ? highlightBorder : "border.muted"}
|
||||||
|
borderRadius="2xl"
|
||||||
|
position="relative"
|
||||||
|
overflow="visible"
|
||||||
|
transition="all 0.3s ease"
|
||||||
|
_hover={{
|
||||||
|
transform: "translateY(-4px)",
|
||||||
|
shadow: plan.highlighted ? "2xl" : "lg",
|
||||||
|
}}
|
||||||
|
height="full"
|
||||||
|
>
|
||||||
|
{/* Most Popular Badge */}
|
||||||
|
{plan.highlighted && (
|
||||||
|
<Badge
|
||||||
|
position="absolute"
|
||||||
|
top="-3"
|
||||||
|
left="50%"
|
||||||
|
transform="translateX(-50%)"
|
||||||
|
colorPalette="primary"
|
||||||
|
variant="solid"
|
||||||
|
borderRadius="full"
|
||||||
|
px={4}
|
||||||
|
py={1}
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{t("most-popular")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card.Body p={6}>
|
||||||
|
<VStack gap={6} align="stretch" height="full">
|
||||||
|
{/* Plan Header */}
|
||||||
|
<VStack gap={2} align="start">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Box
|
||||||
|
p={2}
|
||||||
|
borderRadius="lg"
|
||||||
|
bg={plan.highlighted ? "primary.50" : "gray.50"}
|
||||||
|
_dark={{
|
||||||
|
bg: plan.highlighted ? "primary.950" : "gray.800",
|
||||||
|
}}
|
||||||
|
color={plan.highlighted ? "primary.500" : "fg.muted"}
|
||||||
|
>
|
||||||
|
{planIconMap[plan.id] ?? <LuZap size={20} />}
|
||||||
|
</Box>
|
||||||
|
<Heading as="h3" size="lg" fontWeight="bold">
|
||||||
|
{plan.name}
|
||||||
|
</Heading>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color={mutedText} minH="10">
|
||||||
|
{plan.description}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<Box>
|
||||||
|
{isFree ? (
|
||||||
|
<VStack gap={0} align="start">
|
||||||
|
<HStack gap={1} align="baseline">
|
||||||
|
<Text fontSize="4xl" fontWeight="900" lineHeight="1">
|
||||||
|
{plan.currency === "TRY" ? "₺" : "$"}0
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={mutedText}>
|
||||||
|
{t("free-forever")}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<VStack gap={0} align="start">
|
||||||
|
<HStack gap={1} align="baseline">
|
||||||
|
<Text fontSize="4xl" fontWeight="900" lineHeight="1">
|
||||||
|
{plan.currency === "TRY" ? "₺" : "$"}
|
||||||
|
{displayPrice}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={mutedText}>
|
||||||
|
{t("per-month")}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{isYearly && (
|
||||||
|
<Text fontSize="xs" color={mutedText}>
|
||||||
|
{plan.currency === "TRY" ? "₺" : "$"}
|
||||||
|
{price}
|
||||||
|
{t("per-year")} · {t("billed-yearly")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Features List */}
|
||||||
|
<VStack gap={3} align="start" flex={1}>
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<HStack key={feature} gap={2} align="start">
|
||||||
|
<Box
|
||||||
|
color={plan.highlighted ? "primary.500" : "green.500"}
|
||||||
|
mt="0.5"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<LuCheck size={16} />
|
||||||
|
</Box>
|
||||||
|
<Text fontSize="sm">{feature}</Text>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<Button
|
||||||
|
w="full"
|
||||||
|
size="lg"
|
||||||
|
borderRadius="xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
variant={
|
||||||
|
isCurrentPlan ? "outline" : plan.highlighted ? "solid" : "outline"
|
||||||
|
}
|
||||||
|
colorPalette={plan.highlighted ? "primary" : "gray"}
|
||||||
|
disabled={isCurrentPlan}
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => onSelect(plan)}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Flex,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
HStack,
|
||||||
|
Textarea,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import {
|
||||||
|
useMySubscription,
|
||||||
|
useCancelSubscription,
|
||||||
|
} from "@/lib/api/subscriptions/use-hooks";
|
||||||
|
import { PlanBadge } from "./plan-badge";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LuCalendar, LuTriangleAlert, LuX, LuCheck } from "react-icons/lu";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription info card for the Profile page.
|
||||||
|
* Shows current plan, billing dates, cancel option.
|
||||||
|
*/
|
||||||
|
export function SubscriptionCard() {
|
||||||
|
const t = useTranslations("subscription");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const { data: subData, isLoading } = useMySubscription(!!session);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||||
|
|
||||||
|
const [showCancelForm, setShowCancelForm] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
const cancelMutation = useCancelSubscription();
|
||||||
|
|
||||||
|
const subscription = subData?.data ?? null;
|
||||||
|
const plan = subscription?.plan ?? session?.user?.subscriptionPlan ?? "free";
|
||||||
|
const isFree = plan === "free";
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
await cancelMutation.mutateAsync({ reason: cancelReason || undefined });
|
||||||
|
setShowCancelForm(false);
|
||||||
|
setCancelReason("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||||
|
<Card.Body>
|
||||||
|
<Flex justify="center" py={8}>
|
||||||
|
<Spinner size="sm" color="primary.500" />
|
||||||
|
</Flex>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||||
|
<Card.Header>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Heading as="h3" size="sm">
|
||||||
|
{t("title")}
|
||||||
|
</Heading>
|
||||||
|
<PlanBadge plan={plan} />
|
||||||
|
</Flex>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
{/* Subscription Details */}
|
||||||
|
{subscription && !isFree && (
|
||||||
|
<>
|
||||||
|
{subscription.currentPeriodEnd && (
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack gap={2} color="fg.muted">
|
||||||
|
<LuCalendar size={14} />
|
||||||
|
<Text fontSize="sm">{t("next-billing")}</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" fontWeight="semibold">
|
||||||
|
{new Date(
|
||||||
|
subscription.currentPeriodEnd,
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subscription.cancelEffectiveDate && (
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg="orange.50"
|
||||||
|
_dark={{ bg: "orange.950" }}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<LuTriangleAlert size={14} color="orange" />
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color="orange.600"
|
||||||
|
_dark={{ color: "orange.300" }}
|
||||||
|
>
|
||||||
|
{t("cancelled-info", {
|
||||||
|
date: new Date(
|
||||||
|
subscription.cancelEffectiveDate,
|
||||||
|
).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Flex gap={2} direction={{ base: "column", sm: "row" }}>
|
||||||
|
{isFree ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/pricing")}
|
||||||
|
colorPalette="primary"
|
||||||
|
variant="solid"
|
||||||
|
size="sm"
|
||||||
|
borderRadius="lg"
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{t("upgrade-cta")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/pricing")}
|
||||||
|
colorPalette="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
borderRadius="lg"
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{t("manage")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!subscription?.cancelEffectiveDate && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={() => setShowCancelForm(true)}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Cancel Confirmation */}
|
||||||
|
{showCancelForm && (
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="red.200"
|
||||||
|
_dark={{ borderColor: "red.800" }}
|
||||||
|
borderRadius="lg"
|
||||||
|
>
|
||||||
|
<VStack gap={3} align="stretch">
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="semibold"
|
||||||
|
color="red.600"
|
||||||
|
_dark={{ color: "red.300" }}
|
||||||
|
>
|
||||||
|
{t("cancel-confirm-title")}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{t("cancel-confirm-message")}
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
size="sm"
|
||||||
|
placeholder={t("cancel-reason-placeholder")}
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<HStack gap={2} justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCancelForm(false);
|
||||||
|
setCancelReason("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuX />
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorPalette="red"
|
||||||
|
variant="solid"
|
||||||
|
size="sm"
|
||||||
|
loading={cancelMutation.isPending}
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<LuCheck />
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,87 +12,167 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Table,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { SlideUp, FadeIn } from "@/components/motion";
|
import { SlideUp, FadeIn } from "@/components/motion";
|
||||||
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
||||||
import { LuArrowLeft, LuCalendar, LuTrophy } from "react-icons/lu";
|
import {
|
||||||
|
LuArrowLeft,
|
||||||
|
LuCalendar,
|
||||||
|
LuTrophy,
|
||||||
|
LuChevronDown,
|
||||||
|
} from "react-icons/lu";
|
||||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { LoginModal } from "@/components/auth/login-modal";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Utility Functions
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function getMatchTimestamp(match: MatchResponseDto): number {
|
function getMatchTimestamp(match: MatchResponseDto): number {
|
||||||
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
|
const raw =
|
||||||
|
typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
|
||||||
return Number.isFinite(raw) ? raw : 0;
|
return Number.isFinite(raw) ? raw : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMatchStatus(match: MatchResponseDto): string {
|
function getMatchStatus(match: MatchResponseDto): string {
|
||||||
return String(match.status || (match as Record<string, unknown>).state || "").toUpperCase();
|
return String(match.status || match.state || "").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMatchFinished(match: MatchResponseDto): boolean {
|
function isMatchFinished(match: MatchResponseDto): boolean {
|
||||||
const status = getMatchStatus(match);
|
const status = getMatchStatus(match);
|
||||||
return status === "FT" || status === "FINISHED" || status === "POSTGAME" || status === "POST_GAME";
|
return (
|
||||||
|
status === "FT" ||
|
||||||
|
status === "FINISHED" ||
|
||||||
|
status === "POSTGAME" ||
|
||||||
|
status === "POST_GAME"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMatchLive(match: MatchResponseDto): boolean {
|
function isMatchLive(match: MatchResponseDto): boolean {
|
||||||
const status = getMatchStatus(match);
|
const status = getMatchStatus(match);
|
||||||
return status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS";
|
return (
|
||||||
|
status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
function getTeamSideName(
|
||||||
|
team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"],
|
||||||
|
fallback?: unknown,
|
||||||
|
): string {
|
||||||
return String(team?.name || fallback || "");
|
return String(team?.name || fallback || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
function getTeamSideLogo(
|
||||||
return String(team?.logo || team?.logoUrl || fallback || "");
|
team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"],
|
||||||
|
fallback?: unknown,
|
||||||
|
): string {
|
||||||
|
return String(team?.logo || fallback || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLeagueLabel(match: MatchResponseDto): string {
|
function getLeagueLabel(match: MatchResponseDto): string {
|
||||||
return String(match.leagueName || match.league?.name || "");
|
return String(match.leagueName || match.league?.name || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSeasonFromTimestamp(timestampMs: number): string {
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1; // 1-indexed
|
||||||
|
|
||||||
|
if (month >= 8) {
|
||||||
|
return `${year}-${year + 1}`;
|
||||||
|
}
|
||||||
|
return `${year - 1}-${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEASONS = (() => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const startYear = currentMonth >= 8 ? currentYear : currentYear - 1;
|
||||||
|
return Array.from(
|
||||||
|
{ length: 5 },
|
||||||
|
(_, i) => `${startYear - i}-${startYear - i + 1}`,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Main Component
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TeamDetailContent() {
|
export default function TeamDetailContent() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
|
||||||
const teamId = params.id as string;
|
const teamId = params.id as string;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [activeSeason, setActiveSeason] = useState<string>(SEASONS[0]);
|
||||||
|
|
||||||
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
||||||
const { data: matchesData, isLoading: matchesLoading } = useTeamMatches(teamId, { limit: 30 });
|
const {
|
||||||
|
data: matchesResponse,
|
||||||
|
isLoading: matchesLoading,
|
||||||
|
isFetching: matchesFetching,
|
||||||
|
} = useTeamMatches(teamId, {
|
||||||
|
page: currentPage,
|
||||||
|
limit: 20,
|
||||||
|
season: activeSeason,
|
||||||
|
});
|
||||||
|
|
||||||
const cardBg = useColorModeValue("white", "gray.800");
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||||
|
const seasonActiveBg = useColorModeValue("primary.500", "primary.400");
|
||||||
|
const seasonInactiveBg = useColorModeValue("gray.100", "gray.700");
|
||||||
|
|
||||||
const team = teamData?.data;
|
// Backend ResponseInterceptor wraps all responses in { success, status, message, data }
|
||||||
const matches: MatchResponseDto[] = matchesData?.data ?? [];
|
const teamWrapper = teamData as Record<string, unknown> | undefined;
|
||||||
|
const team = teamWrapper?.data as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (teamLoading) {
|
// matchesResponse = { success, status, message, data: { data: [...], total, page, limit, totalPages } }
|
||||||
return (
|
const paginationWrapper = matchesResponse as
|
||||||
<Flex justify="center" align="center" py={20}>
|
| Record<string, unknown>
|
||||||
<Spinner size="lg" color="primary.500" />
|
| undefined;
|
||||||
</Flex>
|
const paginationData = paginationWrapper?.data as
|
||||||
);
|
| Record<string, unknown>
|
||||||
}
|
| undefined;
|
||||||
|
const matches: MatchResponseDto[] = (
|
||||||
if (!team) {
|
Array.isArray(paginationData?.data)
|
||||||
return (
|
? paginationData.data
|
||||||
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
: paginationData?.data
|
||||||
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
|
? []
|
||||||
<Button variant="outline" onClick={() => router.back()}>
|
: []
|
||||||
<LuArrowLeft /> Geri
|
) as MatchResponseDto[];
|
||||||
</Button>
|
const totalPages = (paginationData?.totalPages as number) ?? 1;
|
||||||
</Flex>
|
const totalMatches = (paginationData?.total as number) ?? 0;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate past and upcoming matches
|
// Separate past and upcoming matches
|
||||||
const isFinished = (m: MatchResponseDto) => isMatchFinished(m);
|
const pastMatches = useMemo(
|
||||||
|
() => matches.filter((m) => isMatchFinished(m)),
|
||||||
|
[matches],
|
||||||
|
);
|
||||||
|
const upcomingMatches = useMemo(
|
||||||
|
() => matches.filter((m) => !isMatchFinished(m)),
|
||||||
|
[matches],
|
||||||
|
);
|
||||||
|
|
||||||
const pastMatches = matches.filter((m: MatchResponseDto) => isFinished(m));
|
// Pagination handlers
|
||||||
const upcomingMatches = matches.filter((m: MatchResponseDto) => !isFinished(m));
|
const handleNextPage = useCallback(() => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
setCurrentPage((p) => p + 1);
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const handlePrevPage = useCallback(() => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage((p) => p - 1);
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
const getStatusBadge = (match: MatchResponseDto) => {
|
const getStatusBadge = (match: MatchResponseDto) => {
|
||||||
if (isMatchLive(match))
|
if (isMatchLive(match))
|
||||||
@@ -114,23 +194,55 @@ export default function TeamDetailContent() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (teamLoading) {
|
||||||
|
return (
|
||||||
|
<Flex justify="center" align="center" py={20}>
|
||||||
|
<Spinner size="lg" color="primary.500" />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return (
|
||||||
|
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
||||||
|
<Text color="fg.muted" fontSize="lg">
|
||||||
|
Takım bulunamadı
|
||||||
|
</Text>
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<LuArrowLeft /> Geri
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideUp>
|
<SlideUp>
|
||||||
<Box>
|
<Box>
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Button variant="ghost" size="sm" mb={4} onClick={() => router.back()} gap={1.5}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
mb={4}
|
||||||
|
onClick={() => router.back()}
|
||||||
|
gap={1.5}
|
||||||
|
>
|
||||||
<LuArrowLeft />
|
<LuArrowLeft />
|
||||||
Geri
|
Geri
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Team Header */}
|
{/* Team Header */}
|
||||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
<Card.Root
|
||||||
|
bg={cardBg}
|
||||||
|
borderColor={borderColor}
|
||||||
|
borderRadius="xl"
|
||||||
|
mb={6}
|
||||||
|
>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<HStack gap={6} justify="center" align="center">
|
<HStack gap={6} justify="center" align="center">
|
||||||
{team.logo ? (
|
{(team as Record<string, unknown>).logo ? (
|
||||||
<Image
|
<Image
|
||||||
src={team.logo}
|
src={String((team as Record<string, unknown>).logo)}
|
||||||
alt={team.name}
|
alt={String((team as Record<string, unknown>).name)}
|
||||||
boxSize="80px"
|
boxSize="80px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
@@ -143,23 +255,25 @@ export default function TeamDetailContent() {
|
|||||||
justify="center"
|
justify="center"
|
||||||
>
|
>
|
||||||
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
||||||
{team.name?.charAt(0) || "T"}
|
{String(
|
||||||
|
(team as Record<string, unknown>).name || "T",
|
||||||
|
).charAt(0)}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<VStack gap={1} align="start">
|
<VStack gap={1} align="start">
|
||||||
<Heading as="h1" size="xl">
|
<Heading as="h1" size="xl">
|
||||||
{team.name}
|
{String((team as Record<string, unknown>).name)}
|
||||||
</Heading>
|
</Heading>
|
||||||
{team.country && (
|
{Boolean((team as Record<string, unknown>).country) && (
|
||||||
<Text fontSize="md" color="fg.muted">
|
<Text fontSize="md" color="fg.muted">
|
||||||
🌍 {team.country}
|
🌍 {String((team as Record<string, unknown>).country)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<HStack gap={4} mt={1}>
|
<HStack gap={4} mt={1}>
|
||||||
<Badge colorPalette="blue" variant="subtle">
|
<Badge colorPalette="blue" variant="subtle">
|
||||||
<LuTrophy style={{ width: 12, height: 12 }} />
|
<LuTrophy style={{ width: 12, height: 12 }} />
|
||||||
{matches.length} Maç
|
{totalMatches} Maç
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge colorPalette="green" variant="subtle">
|
<Badge colorPalette="green" variant="subtle">
|
||||||
<LuCalendar style={{ width: 12, height: 12 }} />
|
<LuCalendar style={{ width: 12, height: 12 }} />
|
||||||
@@ -186,7 +300,11 @@ export default function TeamDetailContent() {
|
|||||||
cardBg={cardBg}
|
cardBg={cardBg}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
statusBadge={getStatusBadge(match)}
|
statusBadge={getStatusBadge(match)}
|
||||||
onClick={() => router.push(`/tr/matches/${match.id}`)}
|
onClick={() =>
|
||||||
|
session
|
||||||
|
? router.push(`/matches/${match.id}`)
|
||||||
|
: setLoginModalOpen(true)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -194,19 +312,65 @@ export default function TeamDetailContent() {
|
|||||||
</FadeIn>
|
</FadeIn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Past Matches */}
|
{/* Past Matches — Season Grouped */}
|
||||||
<FadeIn>
|
<FadeIn>
|
||||||
<Box>
|
<Box>
|
||||||
<Heading as="h2" size="lg" mb={4}>
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
mb={4}
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Heading as="h2" size="lg">
|
||||||
📊 Geçmiş Maçlar
|
📊 Geçmiş Maçlar
|
||||||
</Heading>
|
</Heading>
|
||||||
{matchesLoading ? (
|
{/* Pagination Info */}
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
Sayfa {currentPage}/{totalPages} • Toplam {totalMatches} maç
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Season Tabs */}
|
||||||
|
{SEASONS.length > 0 && (
|
||||||
|
<HStack gap={2} mb={4} flexWrap="wrap">
|
||||||
|
{SEASONS.map((season) => {
|
||||||
|
const isActive = season === activeSeason;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={season}
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? "solid" : "outline"}
|
||||||
|
bg={isActive ? seasonActiveBg : seasonInactiveBg}
|
||||||
|
color={isActive ? "white" : undefined}
|
||||||
|
borderRadius="full"
|
||||||
|
fontWeight={isActive ? "700" : "500"}
|
||||||
|
fontSize="xs"
|
||||||
|
px={4}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSeason(season);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
_hover={{
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
shadow: "sm",
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
🏆 {season}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchesLoading || matchesFetching ? (
|
||||||
<Flex justify="center" py={8}>
|
<Flex justify="center" py={8}>
|
||||||
<Spinner size="md" color="primary.500" />
|
<Spinner size="md" color="primary.500" />
|
||||||
</Flex>
|
</Flex>
|
||||||
) : pastMatches.length === 0 ? (
|
) : pastMatches.length === 0 ? (
|
||||||
<Text color="fg.muted" textAlign="center" py={8}>
|
<Text color="fg.muted" textAlign="center" py={8}>
|
||||||
Geçmiş maç bulunamadı
|
Bu sezonda geçmiş maç bulunamadı
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={2} align="stretch">
|
<VStack gap={2} align="stretch">
|
||||||
@@ -217,13 +381,82 @@ export default function TeamDetailContent() {
|
|||||||
cardBg={cardBg}
|
cardBg={cardBg}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
statusBadge={getStatusBadge(match)}
|
statusBadge={getStatusBadge(match)}
|
||||||
onClick={() => router.push(`/tr/matches/${match.id}`)}
|
onClick={() =>
|
||||||
|
session
|
||||||
|
? router.push(`/matches/${match.id}`)
|
||||||
|
: setLoginModalOpen(true)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Flex justify="center" gap={3} mt={6} align="center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
← Önceki
|
||||||
|
</Button>
|
||||||
|
<HStack gap={1}>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||||
|
// Show pages around current page
|
||||||
|
let pageNum: number;
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 4) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 3) {
|
||||||
|
pageNum = totalPages - 6 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 3 + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
size="sm"
|
||||||
|
variant={pageNum === currentPage ? "solid" : "ghost"}
|
||||||
|
bg={
|
||||||
|
pageNum === currentPage ? seasonActiveBg : undefined
|
||||||
|
}
|
||||||
|
color={pageNum === currentPage ? "white" : undefined}
|
||||||
|
borderRadius="full"
|
||||||
|
minW="36px"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentPage(pageNum);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
Sonraki →
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
|
|
||||||
|
{/* Login Modal — shown when unauthenticated user clicks a match */}
|
||||||
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onOpenChange={setLoginModalOpen}
|
||||||
|
initialMode="login"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</SlideUp>
|
</SlideUp>
|
||||||
);
|
);
|
||||||
@@ -241,7 +474,13 @@ interface MatchRowProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRowProps) {
|
function MatchRow({
|
||||||
|
match,
|
||||||
|
cardBg,
|
||||||
|
borderColor,
|
||||||
|
statusBadge,
|
||||||
|
onClick,
|
||||||
|
}: MatchRowProps) {
|
||||||
const hoverBg = useColorModeValue("gray.50", "gray.700");
|
const hoverBg = useColorModeValue("gray.50", "gray.700");
|
||||||
const matchTimestamp = getMatchTimestamp(match);
|
const matchTimestamp = getMatchTimestamp(match);
|
||||||
const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName);
|
const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName);
|
||||||
@@ -270,17 +509,34 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
|
|||||||
{homeTeamName}
|
{homeTeamName}
|
||||||
</Text>
|
</Text>
|
||||||
{homeTeamLogo ? (
|
{homeTeamLogo ? (
|
||||||
<Image src={homeTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
|
<Image
|
||||||
|
src={homeTeamLogo}
|
||||||
|
alt=""
|
||||||
|
boxSize="24px"
|
||||||
|
objectFit="contain"
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}>
|
<Flex
|
||||||
<Text fontSize="xs" fontWeight="bold">{homeTeamName?.charAt(0)}</Text>
|
boxSize="24px"
|
||||||
|
bg="primary.subtle"
|
||||||
|
borderRadius="full"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" fontWeight="bold">
|
||||||
|
{homeTeamName?.charAt(0)}
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* Score / VS */}
|
{/* Score / VS */}
|
||||||
<VStack gap={0} flexShrink={0} minW="60px">
|
<VStack gap={0} flexShrink={0} minW="60px">
|
||||||
{hasScore && match.scoreHome !== undefined && match.scoreHome !== null ? (
|
{hasScore &&
|
||||||
|
match.scoreHome !== undefined &&
|
||||||
|
match.scoreHome !== null ? (
|
||||||
<Text fontSize="md" fontWeight="900">
|
<Text fontSize="md" fontWeight="900">
|
||||||
{match.scoreHome} - {match.scoreAway}
|
{match.scoreHome} - {match.scoreAway}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -302,10 +558,25 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
|
|||||||
{/* Away Team */}
|
{/* Away Team */}
|
||||||
<HStack gap={2} flex={1}>
|
<HStack gap={2} flex={1}>
|
||||||
{awayTeamLogo ? (
|
{awayTeamLogo ? (
|
||||||
<Image src={awayTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
|
<Image
|
||||||
|
src={awayTeamLogo}
|
||||||
|
alt=""
|
||||||
|
boxSize="24px"
|
||||||
|
objectFit="contain"
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}>
|
<Flex
|
||||||
<Text fontSize="xs" fontWeight="bold">{awayTeamName?.charAt(0)}</Text>
|
boxSize="24px"
|
||||||
|
bg="primary.subtle"
|
||||||
|
borderRadius="full"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" fontWeight="bold">
|
||||||
|
{awayTeamName?.charAt(0)}
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<Text fontSize="sm" fontWeight="600" truncate>
|
<Text fontSize="sm" fontWeight="600" truncate>
|
||||||
@@ -317,7 +588,11 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
|
|||||||
{/* Status + League */}
|
{/* Status + League */}
|
||||||
<HStack gap={2} flexShrink={0} ml={3}>
|
<HStack gap={2} flexShrink={0} ml={3}>
|
||||||
{leagueLabel && (
|
{leagueLabel && (
|
||||||
<Text fontSize="2xs" color="fg.muted" display={{ base: "none", md: "block" }}>
|
<Text
|
||||||
|
fontSize="2xs"
|
||||||
|
color="fg.muted"
|
||||||
|
display={{ base: "none", md: "block" }}
|
||||||
|
>
|
||||||
{leagueLabel}
|
{leagueLabel}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ export default function TeamsContent() {
|
|||||||
<Spinner size="lg" color="primary.500" />
|
<Spinner size="lg" color="primary.500" />
|
||||||
</Flex>
|
</Flex>
|
||||||
) : query.length < 2 ? (
|
) : query.length < 2 ? (
|
||||||
<Flex justify="center" py={16} direction="column" align="center" gap={3}>
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
py={16}
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
gap={3}
|
||||||
|
>
|
||||||
<Text fontSize="5xl">⚽</Text>
|
<Text fontSize="5xl">⚽</Text>
|
||||||
<Text color="fg.muted" fontSize="lg">
|
<Text color="fg.muted" fontSize="lg">
|
||||||
Aramak istediğiniz takımın adını yazın
|
Aramak istediğiniz takımın adını yazın
|
||||||
@@ -117,7 +123,11 @@ export default function TeamsContent() {
|
|||||||
align="center"
|
align="center"
|
||||||
justify="center"
|
justify="center"
|
||||||
>
|
>
|
||||||
<Text fontSize="xl" fontWeight="bold" color="primary.fg">
|
<Text
|
||||||
|
fontSize="xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
color="primary.fg"
|
||||||
|
>
|
||||||
{team.name?.charAt(0) || "T"}
|
{team.name?.charAt(0) || "T"}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { Icon, IconButton, Presence } from '@chakra-ui/react';
|
import { Icon, IconButton, Presence } from "@chakra-ui/react";
|
||||||
import { FiChevronUp } from 'react-icons/fi';
|
import { FiChevronUp } from "react-icons/fi";
|
||||||
|
|
||||||
const BackToTop = () => {
|
const BackToTop = () => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -12,14 +12,14 @@ const BackToTop = () => {
|
|||||||
setIsVisible(window.pageYOffset > 300);
|
setIsVisible(window.pageYOffset > 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener("scroll", handleScroll);
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth',
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,19 +27,19 @@ const BackToTop = () => {
|
|||||||
<Presence
|
<Presence
|
||||||
unmountOnExit
|
unmountOnExit
|
||||||
present={isVisible}
|
present={isVisible}
|
||||||
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
|
animationName={{ _open: "fade-in", _closed: "fade-out" }}
|
||||||
animationDuration='moderate'
|
animationDuration="moderate"
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant={{ base: 'solid', _dark: 'subtle' }}
|
variant={{ base: "solid", _dark: "subtle" }}
|
||||||
aria-label='Back to top'
|
aria-label="Back to top"
|
||||||
position='fixed'
|
position="fixed"
|
||||||
bottom='8'
|
bottom="8"
|
||||||
right='8'
|
right="8"
|
||||||
borderRadius='full'
|
borderRadius="full"
|
||||||
size='lg'
|
size="lg"
|
||||||
shadow='lg'
|
shadow="lg"
|
||||||
zIndex='999'
|
zIndex="999"
|
||||||
onClick={scrollToTop}
|
onClick={scrollToTop}
|
||||||
>
|
>
|
||||||
<Icon>
|
<Icon>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react";
|
||||||
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
|
import {
|
||||||
import * as React from 'react';
|
AbsoluteCenter,
|
||||||
|
Button as ChakraButton,
|
||||||
|
Span,
|
||||||
|
Spinner,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
interface ButtonLoadingProps {
|
interface ButtonLoadingProps {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -9,20 +14,21 @@ interface ButtonLoadingProps {
|
|||||||
|
|
||||||
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||||
|
|
||||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function Button(props, ref) {
|
||||||
const { loading, disabled, loadingText, children, ...rest } = props;
|
const { loading, disabled, loadingText, children, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||||
{loading && !loadingText ? (
|
{loading && !loadingText ? (
|
||||||
<>
|
<>
|
||||||
<AbsoluteCenter display='inline-flex'>
|
<AbsoluteCenter display="inline-flex">
|
||||||
<Spinner size='inherit' color='inherit' />
|
<Spinner size="inherit" color="inherit" />
|
||||||
</AbsoluteCenter>
|
</AbsoluteCenter>
|
||||||
<Span opacity={0}>{children}</Span>
|
<Span opacity={0}>{children}</Span>
|
||||||
</>
|
</>
|
||||||
) : loading && loadingText ? (
|
) : loading && loadingText ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size='inherit' color='inherit' />
|
<Spinner size="inherit" color="inherit" />
|
||||||
{loadingText}
|
{loadingText}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -30,4 +36,5 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
|
|||||||
)}
|
)}
|
||||||
</ChakraButton>
|
</ChakraButton>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { ButtonProps } from '@chakra-ui/react';
|
import type { ButtonProps } from "@chakra-ui/react";
|
||||||
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
|
import { IconButton as ChakraIconButton } from "@chakra-ui/react";
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { LuX } from 'react-icons/lu';
|
import { LuX } from "react-icons/lu";
|
||||||
|
|
||||||
export type CloseButtonProps = ButtonProps;
|
export type CloseButtonProps = ButtonProps;
|
||||||
|
|
||||||
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
|
export const CloseButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
CloseButtonProps
|
||||||
|
>(function CloseButton(props, ref) {
|
||||||
return (
|
return (
|
||||||
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
|
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
|
||||||
{props.children ?? <LuX />}
|
{props.children ?? <LuX />}
|
||||||
</ChakraIconButton>
|
</ChakraIconButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
|
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react";
|
||||||
import { createRecipeContext } from '@chakra-ui/react';
|
import { createRecipeContext } from "@chakra-ui/react";
|
||||||
|
|
||||||
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
|
export interface LinkButtonProps extends HTMLChakraProps<
|
||||||
|
"a",
|
||||||
|
RecipeProps<"button">
|
||||||
|
> {}
|
||||||
|
|
||||||
const { withContext } = createRecipeContext({ key: 'button' });
|
const { withContext } = createRecipeContext({ key: "button" });
|
||||||
|
|
||||||
// Replace "a" with your framework's link component
|
// Replace "a" with your framework's link component
|
||||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
|
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a");
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import type { ButtonProps } from '@chakra-ui/react';
|
import type { ButtonProps } from "@chakra-ui/react";
|
||||||
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
|
import {
|
||||||
import * as React from 'react';
|
Button,
|
||||||
|
Toggle as ChakraToggle,
|
||||||
|
useToggleContext,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
interface ToggleProps extends ChakraToggle.RootProps {
|
interface ToggleProps extends ChakraToggle.RootProps {
|
||||||
variant?: keyof typeof variantMap;
|
variant?: keyof typeof variantMap;
|
||||||
size?: ButtonProps['size'];
|
size?: ButtonProps["size"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantMap = {
|
const variantMap = {
|
||||||
solid: { on: 'solid', off: 'outline' },
|
solid: { on: "solid", off: "outline" },
|
||||||
surface: { on: 'surface', off: 'outline' },
|
surface: { on: "surface", off: "outline" },
|
||||||
subtle: { on: 'subtle', off: 'ghost' },
|
subtle: { on: "subtle", off: "ghost" },
|
||||||
ghost: { on: 'subtle', off: 'ghost' },
|
ghost: { on: "subtle", off: "ghost" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
|
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
|
||||||
const { variant = 'subtle', size, children, ...rest } = props;
|
function Toggle(props, ref) {
|
||||||
|
const { variant = "subtle", size, children, ...rest } = props;
|
||||||
const variantConfig = variantMap[variant];
|
const variantConfig = variantMap[variant];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,18 +32,26 @@ export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function
|
|||||||
</ToggleBaseButton>
|
</ToggleBaseButton>
|
||||||
</ChakraToggle.Root>
|
</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} />;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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;
|
export const ToggleIndicator = ChakraToggle.Indicator;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
|
import { Combobox as ChakraCombobox, Portal } from "@chakra-ui/react";
|
||||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
import { CloseButton } from "@/components/ui/buttons/close-button";
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
|
|
||||||
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
|
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
|
||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
|
export const ComboboxControl = React.forwardRef<
|
||||||
function ComboboxControl(props, ref) {
|
HTMLDivElement,
|
||||||
|
ComboboxControlProps
|
||||||
|
>(function ComboboxControl(props, ref) {
|
||||||
const { children, clearable, ...rest } = props;
|
const { children, clearable, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<ChakraCombobox.Control {...rest} ref={ref}>
|
<ChakraCombobox.Control {...rest} ref={ref}>
|
||||||
@@ -20,26 +22,34 @@ export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlP
|
|||||||
</ChakraCombobox.IndicatorGroup>
|
</ChakraCombobox.IndicatorGroup>
|
||||||
</ChakraCombobox.Control>
|
</ChakraCombobox.Control>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
|
const ComboboxClearTrigger = React.forwardRef<
|
||||||
function ComboboxClearTrigger(props, ref) {
|
HTMLButtonElement,
|
||||||
|
ChakraCombobox.ClearTriggerProps
|
||||||
|
>(function ComboboxClearTrigger(props, ref) {
|
||||||
return (
|
return (
|
||||||
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
||||||
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
<CloseButton
|
||||||
|
size="xs"
|
||||||
|
variant="plain"
|
||||||
|
focusVisibleRing="inside"
|
||||||
|
focusRingWidth="2px"
|
||||||
|
pointerEvents="auto"
|
||||||
|
/>
|
||||||
</ChakraCombobox.ClearTrigger>
|
</ChakraCombobox.ClearTrigger>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
|
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
|
||||||
portalled?: boolean;
|
portalled?: boolean;
|
||||||
portalRef?: React.RefObject<HTMLElement | null>;
|
portalRef?: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
|
export const ComboboxContent = React.forwardRef<
|
||||||
function ComboboxContent(props, ref) {
|
HTMLDivElement,
|
||||||
|
ComboboxContentProps
|
||||||
|
>(function ComboboxContent(props, ref) {
|
||||||
const { portalled = true, portalRef, ...rest } = props;
|
const { portalled = true, portalRef, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<Portal disabled={!portalled} container={portalRef}>
|
<Portal disabled={!portalled} container={portalRef}>
|
||||||
@@ -48,11 +58,12 @@ export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentP
|
|||||||
</ChakraCombobox.Positioner>
|
</ChakraCombobox.Positioner>
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
|
export const ComboboxItem = React.forwardRef<
|
||||||
function ComboboxItem(props, ref) {
|
HTMLDivElement,
|
||||||
|
ChakraCombobox.ItemProps
|
||||||
|
>(function ComboboxItem(props, ref) {
|
||||||
const { item, children, ...rest } = props;
|
const { item, children, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||||
@@ -60,21 +71,29 @@ export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.Item
|
|||||||
<ChakraCombobox.ItemIndicator />
|
<ChakraCombobox.ItemIndicator />
|
||||||
</ChakraCombobox.Item>
|
</ChakraCombobox.Item>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
|
export const ComboboxRoot = React.forwardRef<
|
||||||
function ComboboxRoot(props, ref) {
|
HTMLDivElement,
|
||||||
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
|
ChakraCombobox.RootProps
|
||||||
},
|
>(function ComboboxRoot(props, ref) {
|
||||||
) as ChakraCombobox.RootComponent;
|
return (
|
||||||
|
<ChakraCombobox.Root
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
positioning={{ sameWidth: true, ...props.positioning }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}) as ChakraCombobox.RootComponent;
|
||||||
|
|
||||||
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
|
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
|
export const ComboboxItemGroup = React.forwardRef<
|
||||||
function ComboboxItemGroup(props, ref) {
|
HTMLDivElement,
|
||||||
|
ComboboxItemGroupProps
|
||||||
|
>(function ComboboxItemGroup(props, ref) {
|
||||||
const { children, label, ...rest } = props;
|
const { children, label, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
||||||
@@ -82,8 +101,7 @@ export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGr
|
|||||||
{children}
|
{children}
|
||||||
</ChakraCombobox.ItemGroup>
|
</ChakraCombobox.ItemGroup>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const ComboboxLabel = ChakraCombobox.Label;
|
export const ComboboxLabel = ChakraCombobox.Label;
|
||||||
export const ComboboxInput = ChakraCombobox.Input;
|
export const ComboboxInput = ChakraCombobox.Input;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user