Compare commits
18 Commits
96b9653a7e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df5145104 | |||
| fc369db123 | |||
| a70848164e | |||
| 71a6ed320c | |||
| e744a62fc2 | |||
| 66877b88ca | |||
| b2ccc98226 | |||
| e6e58b4433 | |||
| c47f128958 | |||
| 5c8619b282 | |||
| 6dadc5f613 | |||
| 4df27e3e6d | |||
| e3cc6702dd | |||
| f72857a3b2 | |||
| 0d194f7409 | |||
| ab5864df2f | |||
| bff5ea7b5f | |||
| 14159911f0 |
@@ -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
|
||||
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
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL='https://api.iddaai.com/api' \
|
||||
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='false' \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
|
||||
--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 .
|
||||
|
||||
- name: Eski Konteyneri Sil
|
||||
@@ -29,8 +47,5 @@ jobs:
|
||||
--network iddaai_iddaai-network \
|
||||
-p 127.0.0.1:1510:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e NEXT_PUBLIC_API_URL='https://api.iddaai.com/api' \
|
||||
-e NEXTAUTH_URL='https://iddaai.com' \
|
||||
-e NEXTAUTH_SECRET='fFw34R134jRof1H2jofh2!32hU3gfjA1' \
|
||||
-e NEXT_PUBLIC_AUTH_REQUIRED='false' \
|
||||
--env-file .env.production \
|
||||
iddaai-fe:latest
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
node_modules
|
||||
|
||||
.next
|
||||
|
||||
.env.local
|
||||
certificates/
|
||||
|
||||
+34
-12
@@ -9,10 +9,26 @@ RUN npm install
|
||||
# Copy source code
|
||||
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
|
||||
# NEXT_PUBLIC_API_URL should be set during build if used in static generation
|
||||
# For production, we usually point to the domain name
|
||||
ENV NEXT_PUBLIC_API_URL=https://api.iddaai.com/api
|
||||
RUN npm run build
|
||||
|
||||
# --- STAGE 2: RUNNER ---
|
||||
@@ -21,16 +37,22 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy only necessary files
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/.next ./.next
|
||||
# Don't run as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone build
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
# Copy messages for internationalization
|
||||
COPY --from=builder /app/messages ./messages
|
||||
|
||||
# Set permissions for standalone build
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start Next.js
|
||||
CMD ["npm", "start"]
|
||||
# Start standalone server
|
||||
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 { fileURLToPath } from 'url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
import nextConfig from 'eslint-config-next';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
|
||||
...nextConfig,
|
||||
prettierConfig,
|
||||
{
|
||||
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@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)
|
||||
|
||||
+308
-31
@@ -36,7 +36,9 @@
|
||||
"logging-in": "Signing in...",
|
||||
"registering": "Creating account...",
|
||||
"login-success": "Login successful!",
|
||||
"register-success": "Registration 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.",
|
||||
"privacy-policy": "Privacy Policy",
|
||||
@@ -45,7 +47,6 @@
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"dashboard": "Dashboard",
|
||||
@@ -66,13 +67,12 @@
|
||||
"coupons": "Coupons",
|
||||
"tools": "Tools"
|
||||
},
|
||||
|
||||
"landing": {
|
||||
"hero-title": "AI-Powered Betting Predictions",
|
||||
"hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.",
|
||||
"get-started": "Get Started",
|
||||
"learn-more": "Learn More",
|
||||
"features-title": "Why Choose Suggest Bet?",
|
||||
"features-title": "Why Choose Iddaai?",
|
||||
"feature-ai": "AI Predictions",
|
||||
"feature-ai-desc": "Powered by V20 ensemble model with 95%+ data quality scoring.",
|
||||
"feature-value": "Value Bets",
|
||||
@@ -86,7 +86,6 @@
|
||||
"stats-users": "Active Users",
|
||||
"stats-matches": "Matches Analyzed"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome back",
|
||||
@@ -99,7 +98,6 @@
|
||||
"no-matches": "No matches available today.",
|
||||
"no-predictions": "No predictions available."
|
||||
},
|
||||
|
||||
"matches": {
|
||||
"title": "Matches",
|
||||
"filter-sport": "Sport",
|
||||
@@ -121,9 +119,44 @@
|
||||
"recent-matches": "Recent Matches",
|
||||
"home-team": "Home",
|
||||
"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": {
|
||||
"title": "Predictions",
|
||||
"upcoming": "Upcoming",
|
||||
@@ -145,6 +178,8 @@
|
||||
"bet-summary": "Bet Summary",
|
||||
"expected-value": "Expected Value",
|
||||
"no-predictions": "No predictions available.",
|
||||
"generate": "Analyze with AI",
|
||||
"pre-match-disclaimer": "This analysis is based on pre-match data only.",
|
||||
"accuracy": "Accuracy",
|
||||
"total-predictions": "Total Predictions",
|
||||
"correct-predictions": "Correct Predictions",
|
||||
@@ -186,11 +221,11 @@
|
||||
"missing_total_odds": "Over/Under odds are missing.",
|
||||
"missing_spread_odds": "Spread (Handicap) odds are missing.",
|
||||
"no_bet_conditions_met": "The algorithm could not find a safe/valuable bet for this match.",
|
||||
"insufficient_play_score": "Play score is below the playability threshold.",
|
||||
"insufficient_play_score": "Model signal is below the threshold.",
|
||||
"no_ev_edge_minimum_stake": "Passed safety gates but no mathematical edge — minimum stake applied.",
|
||||
"upset_risk_detected": "High upset risk detected, proceed with caution."
|
||||
},
|
||||
"ev-edge": "EV Edge",
|
||||
"ev-edge": "Theoretical Edge",
|
||||
"implied-prob": "Market Probability",
|
||||
"model-prob": "Model Probability",
|
||||
"kelly-stake": "Kelly Stake",
|
||||
@@ -219,27 +254,48 @@
|
||||
"HTFT": "Half Time / Full Time",
|
||||
"HT/FT": "Half Time / Full Time",
|
||||
"OE": "Odd / Even",
|
||||
"HT_OU05": "First Half 0.5 Goals"
|
||||
"HT_OU05": "First Half 0.5 Goals",
|
||||
"HT_OU15": "First Half 1.5 Goals",
|
||||
"CARDS": "Cards 4.5",
|
||||
"HCAP": "Handicap Result"
|
||||
},
|
||||
"ui": {
|
||||
"summary-title": "Prediction Summary",
|
||||
"summary-info": "Shows what stands out first and then explains why it stands out.",
|
||||
"main-recommendation": "Main Recommendation",
|
||||
"summary-info": "Shows model signals and uncertainty in a conservative summary.",
|
||||
"model-signal-disclaimer": "This is a model signal; it is not a guaranteed result, guarantee, or hit-rate promise. Signal score can be wrong because of in-match variance, lineups, and data quality.",
|
||||
"main-recommendation": "Highlighted Signal",
|
||||
"best-market-copy": "is the strongest option in this market.",
|
||||
"confidence-label": "Confidence",
|
||||
"confidence-interval": "Confidence Interval",
|
||||
"confidence-interval-warning": "The confidence interval is wide. Even with a signal, it is not recommended as a standalone pick.",
|
||||
"confidence-band": "Band",
|
||||
"odds-label": "Odds",
|
||||
"edge-label": "Expected Advantage (Edge)",
|
||||
"edge-info": "Edge is the gap between the model probability and the market probability. If it is positive, the model sees value in this price.",
|
||||
"edge-label": "Theoretical Edge",
|
||||
"edge-info": "The theoretical gap between model probability and market probability; it is not a guarantee or a certain profit expectation.",
|
||||
"stake-label": "Suggested Bet Size (Stake)",
|
||||
"stake-label-short": "Bet Size",
|
||||
"stake-info": "Stake is the suggested bet size. 2.0u means a 2-unit bet in your own bankroll plan.",
|
||||
"play-score-label": "Playability Score",
|
||||
"playability-label": "Playability",
|
||||
"play-score-label": "Model Signal",
|
||||
"playability-label": "Model signal",
|
||||
"quick-read": "Quick read",
|
||||
"lineup-source": "Lineup Source",
|
||||
"lineup-confirmed-live": "Confirmed starting XI",
|
||||
"lineup-probable-xi": "Probable starting XI",
|
||||
"unknown": "Unknown",
|
||||
"model-label": "Model",
|
||||
"engine-info": "Shows which components influence the prediction the most.",
|
||||
"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-info": "Options outside the main recommendation.",
|
||||
"alternative": "Alternative",
|
||||
@@ -248,10 +304,50 @@
|
||||
"all-markets-info": "Compares every option in a single table.",
|
||||
"market-board-info": "The probability distribution the model sees for each market.",
|
||||
"bet-advice-info": "The model's final action recommendation.",
|
||||
"recommended-stake-inline": "Suggested size"
|
||||
"recommended-stake-inline": "Suggested size",
|
||||
"model-probability-short": "Model",
|
||||
"market-probability-short": "Market",
|
||||
"theoretical-edge-inline": "Theoretical edge",
|
||||
"playable": "Playable",
|
||||
"risky": "Risky",
|
||||
"hit-probability": "Hit Probability",
|
||||
"calibrated-confidence": "Calibrated Confidence",
|
||||
"score-scenario-football": "Score Scenario",
|
||||
"score-scenario-basketball": "Points Scenario",
|
||||
"score-scenario-info-football": "Expected score and the most likely scenarios.",
|
||||
"score-scenario-info-basketball": "Expected points distribution and the most likely match scenarios.",
|
||||
"full-time-football": "Full Time",
|
||||
"full-time-basketball": "Full-Time Points",
|
||||
"half-time-football": "Half Time",
|
||||
"half-time-basketball": "Half-Time Points",
|
||||
"expected-total-football": "Total xG",
|
||||
"expected-total-basketball": "Expected Total Points",
|
||||
"live": "LIVE",
|
||||
"pre-match-prediction": "Pre-match prediction",
|
||||
"prediction-contradictions": "Prediction Contradictions",
|
||||
"data-quality": "Data Quality",
|
||||
"data-quality-info": "How reliable the lineup, odds, and match data are.",
|
||||
"risk-info": "Upset probability and uncertainty level.",
|
||||
"risk-commentary": "Risk Commentary",
|
||||
"risk-default-comment": "The model asks for extra caution on this match.",
|
||||
"surprise-score": "Upset score",
|
||||
"match-commentary-title": "Match Commentary",
|
||||
"match-commentary-info": "The model's human-readable summary of the match.",
|
||||
"reasoning-info": "High-level summary of why the model reads this match this way.",
|
||||
"bet-advice-play": "PLAY",
|
||||
"bet-advice-pass": "PASS",
|
||||
"signal-tier-core": "Core",
|
||||
"signal-tier-value": "Value",
|
||||
"signal-tier-lean": "Lean",
|
||||
"signal-tier-longshot": "Longshot",
|
||||
"signal-tier-pass": "Pass",
|
||||
"confidence-high": "High",
|
||||
"confidence-medium": "Medium",
|
||||
"confidence-low": "Low",
|
||||
"confidence-unknown": "Unknown",
|
||||
"info": "Info"
|
||||
}
|
||||
},
|
||||
|
||||
"coupons": {
|
||||
"title": "Coupon Builder",
|
||||
"builder-title": "Coupon Builder",
|
||||
@@ -309,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-subtitle": "Source: live_matches table • sport: football • status: not started",
|
||||
"match-count-suffix": "matches",
|
||||
"match-count-label": "Coupon Match Count",
|
||||
"match-count-help": "How many matches should the AI coupon include? You can choose between 2 and 15. If you do not select any matches, the full bulletin is scanned.",
|
||||
"match-count-auto": "Full bulletin ({count} matches)",
|
||||
"upcoming-badge": "Upcoming",
|
||||
"upcoming-reference": "Upcoming pool",
|
||||
"finished-badge": "Finished",
|
||||
@@ -375,7 +474,6 @@
|
||||
"engine-mode-label": "Engine Mode",
|
||||
"engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis."
|
||||
},
|
||||
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"account-settings": "Account Settings",
|
||||
@@ -400,15 +498,14 @@
|
||||
"win-rate": "Win Rate",
|
||||
"total-profit": "Total Profit"
|
||||
},
|
||||
|
||||
"leagues": {
|
||||
"title": "Leagues & Teams",
|
||||
"countries": "Countries",
|
||||
"leagues": "Leagues",
|
||||
"countries-leagues": "Countries & Leagues",
|
||||
"search-at-least-2": "Type at least 2 characters to search teams."
|
||||
"search-at-least-2": "Type at least 2 characters to search teams.",
|
||||
"all": "All"
|
||||
},
|
||||
|
||||
"h2h": {
|
||||
"title": "Head to Head",
|
||||
"team-1": "Team 1",
|
||||
@@ -418,7 +515,6 @@
|
||||
"draws": "Draws",
|
||||
"no-matches-found": "No head-to-head matches found between these teams."
|
||||
},
|
||||
|
||||
"analysis": {
|
||||
"title": "Multi-Match Analysis",
|
||||
"select-matches": "Select Matches",
|
||||
@@ -430,7 +526,6 @@
|
||||
"matches-analyzed": "matches analyzed",
|
||||
"no-history": "No analysis history yet."
|
||||
},
|
||||
|
||||
"spor-toto": {
|
||||
"title": "Spor Toto",
|
||||
"sync-bulletins": "Sync Bulletins",
|
||||
@@ -458,7 +553,6 @@
|
||||
"rollover-stats": "Rollover Stats",
|
||||
"prediction-generated": "Prediction generated successfully!"
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
"subtitle": "Manage users, monitor predictions, and system overview.",
|
||||
@@ -466,7 +560,9 @@
|
||||
"analytics": "Analytics Overview",
|
||||
"user-management": "User Management",
|
||||
"users": "Users",
|
||||
"premium-users": "Premium Users",
|
||||
"settings": "Settings",
|
||||
"subscription": "Subscription",
|
||||
"usage-limits": "Usage Limits",
|
||||
"total-users": "Total Users",
|
||||
"active-users": "Active Users",
|
||||
@@ -486,10 +582,31 @@
|
||||
"user-email": "Email",
|
||||
"user-role": "Role",
|
||||
"user-status": "Status",
|
||||
"no-users": "No users found."
|
||||
"no-users": "No users found.",
|
||||
"restricted": "Restricted",
|
||||
"admin-access-required": "Admin access required",
|
||||
"admin-access-description": "This area is only available to superadmin accounts.",
|
||||
"search-users-placeholder": "Search by email or name...",
|
||||
"all-roles": "View All Roles",
|
||||
"standard-user": "Standard User",
|
||||
"superadmin": "System Administrator (Admin)",
|
||||
"all-plans": "View All Plans",
|
||||
"plan-free": "Free",
|
||||
"plan-plus": "Plus Plan",
|
||||
"plan-premium": "Premium Plan",
|
||||
"plan-past-due": "Past Due",
|
||||
"plan-cancelled": "Cancelled",
|
||||
"edit-user-title": "Edit User: {email}",
|
||||
"user-role-field": "User Role",
|
||||
"subscription-plan-field": "Subscription Plan",
|
||||
"subscription-end-date": "Subscription End Date (Optional)",
|
||||
"account-active-question": "Is the account active?"
|
||||
},
|
||||
|
||||
"common": {
|
||||
"limits": {
|
||||
"analysis_left": "Analyses",
|
||||
"out_of_analysis": "Daily analysis limit exceeded."
|
||||
},
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
@@ -516,6 +633,166 @@
|
||||
"of": "of",
|
||||
"items-per-page": "Items per page",
|
||||
"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"
|
||||
}
|
||||
+320
-31
@@ -5,8 +5,8 @@
|
||||
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
|
||||
"artificial-intelligence": "Yapay Zeka",
|
||||
"error": {
|
||||
"not-found": "Aradığınız sayfa bulunamadı.",
|
||||
"404": "404",
|
||||
"not-found": "Aradığınız sayfa bulunamadı.",
|
||||
"back-to-home": "Ana sayfaya dön",
|
||||
"generic": "Beklenmeyen bir hata oluştu.",
|
||||
"network": "Ağ hatası. Lütfen bağlantınızı kontrol edin.",
|
||||
@@ -36,7 +36,9 @@
|
||||
"logging-in": "Giriş yapılıyor...",
|
||||
"registering": "Hesap oluşturuluyor...",
|
||||
"login-success": "Giriş başarılı!",
|
||||
"register-success": "Kayıt 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.",
|
||||
"privacy-policy": "Gizlilik Politikası",
|
||||
@@ -45,7 +47,6 @@
|
||||
"low": "Düşük",
|
||||
"medium": "Orta",
|
||||
"high": "Yüksek",
|
||||
|
||||
"nav": {
|
||||
"home": "Anasayfa",
|
||||
"dashboard": "Kontrol Paneli",
|
||||
@@ -66,13 +67,12 @@
|
||||
"coupons": "Kuponlar",
|
||||
"tools": "Araçlar"
|
||||
},
|
||||
|
||||
"landing": {
|
||||
"hero-title": "Yapay Zeka Destekli Bahis Tahminleri",
|
||||
"hero-subtitle": "Gelişmiş yapay zeka tahmin motorumuz ile daha akıllı bahisler yapın. Maçları analiz edin, değerli bahisleri keşfedin ve kazanan kuponlar oluşturun.",
|
||||
"get-started": "Başla",
|
||||
"learn-more": "Daha Fazla",
|
||||
"features-title": "Neden Suggest Bet?",
|
||||
"features-title": "Neden Iddaai?",
|
||||
"feature-ai": "Yapay Zeka Tahminleri",
|
||||
"feature-ai-desc": "%95+ veri kalitesi puanlama ile V20 ensemble modeli tarafından desteklenmektedir.",
|
||||
"feature-value": "Değerli Bahisler",
|
||||
@@ -86,7 +86,6 @@
|
||||
"stats-users": "Aktif Kullanıcı",
|
||||
"stats-matches": "Analiz Edilen Maç"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"title": "Kontrol Paneli",
|
||||
"welcome": "Tekrar hoş geldiniz",
|
||||
@@ -99,7 +98,6 @@
|
||||
"no-matches": "Bugün maç bulunmuyor.",
|
||||
"no-predictions": "Tahmin bulunmuyor."
|
||||
},
|
||||
|
||||
"matches": {
|
||||
"title": "Maçlar",
|
||||
"filter-sport": "Spor",
|
||||
@@ -121,9 +119,44 @@
|
||||
"recent-matches": "Son Maçlar",
|
||||
"home-team": "Ev Sahibi",
|
||||
"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": {
|
||||
"title": "Tahminler",
|
||||
"upcoming": "Yaklaşan",
|
||||
@@ -145,6 +178,8 @@
|
||||
"bet-summary": "Bahis Özeti",
|
||||
"expected-value": "Beklenen Değer",
|
||||
"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",
|
||||
"total-predictions": "Toplam Tahmin",
|
||||
"correct-predictions": "Doğru Tahmin",
|
||||
@@ -187,10 +222,10 @@
|
||||
"missing_total_odds": "Alt/Üst oranları eksik.",
|
||||
"missing_spread_odds": "Handikap oranları eksik.",
|
||||
"no_bet_conditions_met": "Algoritma bu maç için güvenli/değerli bir bahis önerisi bulamadı.",
|
||||
"insufficient_play_score": "Oynanabilirlik skoru eşiğin altında kaldı.",
|
||||
"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ı."
|
||||
},
|
||||
"ev-edge": "EV Edge",
|
||||
"ev-edge": "Teorik Avantaj",
|
||||
"implied-prob": "Piyasa Olasılığı",
|
||||
"model-prob": "Model Olasılığı",
|
||||
"kelly-stake": "Kelly Bahis",
|
||||
@@ -219,27 +254,48 @@
|
||||
"HTFT": "İlk Yarı / Maç Sonu",
|
||||
"HT/FT": "İlk Yarı / Maç Sonu",
|
||||
"OE": "Tek / Çift",
|
||||
"HT_OU05": "İlk Yarı 0.5 Gol"
|
||||
"HT_OU05": "İlk Yarı 0.5 Gol",
|
||||
"HT_OU15": "İlk Yarı 1.5 Gol",
|
||||
"CARDS": "Kartlar 4.5",
|
||||
"HCAP": "Handikap Sonucu"
|
||||
},
|
||||
"ui": {
|
||||
"summary-title": "Tahmin Özeti",
|
||||
"summary-info": "Önce neyin oynanabileceğini, sonra bunun neden öne çıktığını gösterir.",
|
||||
"main-recommendation": "Ana Öneri",
|
||||
"summary-info": "Model sinyallerini ve belirsizlikleri sade şekilde gösterir.",
|
||||
"model-signal-disclaimer": "Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.",
|
||||
"main-recommendation": "Öne Çıkan Sinyal",
|
||||
"best-market-copy": "marketinde en güçlü seçim.",
|
||||
"confidence-label": "Güven",
|
||||
"confidence-interval": "Güven Aralığı",
|
||||
"confidence-interval-warning": "Güven aralığı geniş. Sinyal olsa bile tek başına oynanması önerilmez.",
|
||||
"confidence-band": "Band",
|
||||
"odds-label": "Oran",
|
||||
"edge-label": "Beklenen Avantaj (Edge)",
|
||||
"edge-info": "Edge, model olasılığı ile piyasa olasılığı arasındaki farktır. Pozitifse model bu oranı avantajlı görüyor demektir.",
|
||||
"edge-label": "Teorik Avantaj",
|
||||
"edge-info": "Model olasılığı ile piyasa olasılığı arasındaki teorik farktır; tutma garantisi veya kesin kazanç beklentisi değildir.",
|
||||
"stake-label": "Önerilen Miktar (Stake)",
|
||||
"stake-label-short": "Bahis Miktarı",
|
||||
"stake-info": "Stake, bu bahis için önerilen bahis birimidir. 2.0u, kendi bankroll planınızdaki 2 birimlik bahis anlamına gelir.",
|
||||
"play-score-label": "Oynanabilirlik Puanı",
|
||||
"playability-label": "Oynanabilirlik",
|
||||
"play-score-label": "Model Sinyali",
|
||||
"playability-label": "Model sinyali",
|
||||
"quick-read": "Hızlı yorum",
|
||||
"lineup-source": "Kadronun Kaynağı",
|
||||
"lineup-confirmed-live": "Onaylı ilk 11",
|
||||
"lineup-probable-xi": "Muhtemel ilk 11",
|
||||
"unknown": "Bilinmiyor",
|
||||
"model-label": "Model",
|
||||
"engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.",
|
||||
"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-info": "Ana tahmin dışındaki seçenekler.",
|
||||
"alternative": "Alternatif",
|
||||
@@ -248,10 +304,50 @@
|
||||
"all-markets-info": "Bütün seçenekleri tek tabloda karşılaştırır.",
|
||||
"market-board-info": "Modelin her markette gördüğü olasılık dağılımı.",
|
||||
"bet-advice-info": "Modelin nihai aksiyon önerisi.",
|
||||
"recommended-stake-inline": "Önerilen miktar"
|
||||
"recommended-stake-inline": "Önerilen miktar",
|
||||
"model-probability-short": "Model",
|
||||
"market-probability-short": "Piyasa",
|
||||
"theoretical-edge-inline": "Teorik avantaj",
|
||||
"playable": "Oynanabilir",
|
||||
"risky": "Riskli",
|
||||
"hit-probability": "Tutma Olasılığı",
|
||||
"calibrated-confidence": "Kalibre Güven",
|
||||
"score-scenario-football": "Skor Senaryosu",
|
||||
"score-scenario-basketball": "Sayı Senaryosu",
|
||||
"score-scenario-info-football": "Beklenen skor ve en olası senaryolar.",
|
||||
"score-scenario-info-basketball": "Beklenen sayı dağılımı ve en olası maç senaryoları.",
|
||||
"full-time-football": "Maç Sonu",
|
||||
"full-time-basketball": "Maç Sonu Sayı",
|
||||
"half-time-football": "İlk Yarı",
|
||||
"half-time-basketball": "İlk Yarı Sayı",
|
||||
"expected-total-football": "Toplam xG",
|
||||
"expected-total-basketball": "Beklenen Toplam Sayı",
|
||||
"live": "CANLI",
|
||||
"pre-match-prediction": "Maç öncesi tahmin",
|
||||
"prediction-contradictions": "Tahmin Çelişkileri",
|
||||
"data-quality": "Veri Kalitesi",
|
||||
"data-quality-info": "Kadro, oran ve maç verisinin ne kadar güvenilir olduğu.",
|
||||
"risk-info": "Sürpriz ihtimali ve belirsizlik seviyesi.",
|
||||
"risk-commentary": "Risk Yorumu",
|
||||
"risk-default-comment": "Model bu maçta ekstra dikkat istiyor.",
|
||||
"surprise-score": "Sürpriz skoru",
|
||||
"match-commentary-title": "Maç Yorumu",
|
||||
"match-commentary-info": "Modelin maç hakkındaki insan okunabilir özeti.",
|
||||
"reasoning-info": "Modelin bu maçı neden bu şekilde okuduğunun üst seviye özeti.",
|
||||
"bet-advice-play": "OYNA",
|
||||
"bet-advice-pass": "OYNAMA",
|
||||
"signal-tier-core": "Çekirdek",
|
||||
"signal-tier-value": "Değer",
|
||||
"signal-tier-lean": "Yorum",
|
||||
"signal-tier-longshot": "Sürpriz",
|
||||
"signal-tier-pass": "Pas",
|
||||
"confidence-high": "Yüksek",
|
||||
"confidence-medium": "Orta",
|
||||
"confidence-low": "Düşük",
|
||||
"confidence-unknown": "Belirsiz",
|
||||
"info": "Bilgi"
|
||||
}
|
||||
},
|
||||
|
||||
"coupons": {
|
||||
"title": "Kupon Oluşturucu",
|
||||
"builder-title": "Kupon Oluşturucu",
|
||||
@@ -297,6 +393,8 @@
|
||||
"coupon": "Kupon",
|
||||
"candidate-match-count": "Aday Maç",
|
||||
"candidate-match-count-help": "Kupon oluşturmak için şu anda uygun olan yaklaşan futbol maçı sayısı.",
|
||||
"finished-match-count": "Biten Maç",
|
||||
"finished-match-count-help": "Biten futbol maçları için isteğe bağlı referans listesi. Bunlar kupon tahmininde asla kullanılmaz.",
|
||||
"selected-match-count": "Seçilen Maç",
|
||||
"selected-match-count-help": "Maçları siz seçerseniz AI kuponu sadece bu havuzdan üretir.",
|
||||
"suggested-bet-count": "Önerilen Bahis",
|
||||
@@ -308,12 +406,24 @@
|
||||
"candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış",
|
||||
"match-count-suffix": "maç",
|
||||
"upcoming-badge": "Yaklaşan",
|
||||
"upcoming-reference": "Yaklaşan havuz",
|
||||
"finished-badge": "Bitti",
|
||||
"prediction-locked": "Tahmine Kapalı",
|
||||
"read-only-short": "Salt okunur",
|
||||
"selected-short": "Seçildi",
|
||||
"select-match": "Seç",
|
||||
"match-state": "Maç Durumu",
|
||||
"selection-mode": "AI Havuzu",
|
||||
"manual-pool": "Manuel havuz",
|
||||
"auto-pool": "Otomatik havuz",
|
||||
"finished-reference-only": "Sadece referans",
|
||||
"no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.",
|
||||
"finished-matches-title": "Biten Maçlar",
|
||||
"finished-matches-help": "Bu maçlar sadece referans için gösterilir. Seçilemezler ve kupon tahmini oluşturulmadan önce backend tarafından filtrelenirler.",
|
||||
"finished-matches-subtitle": "İsteğe bağlı arşiv görünümü. Skorlar ve maç sonu istatistikleri kupon tahmin akışına gönderilmez.",
|
||||
"show-finished-matches": "Biten maçları göster",
|
||||
"hide-finished-matches": "Biten maçları gizle",
|
||||
"no-finished-matches": "Geçerli görünüm için biten futbol maçı bulunamadı.",
|
||||
"manual-selection-active": "AI yalnızca aşağıda seçtiğiniz maçları kullanacak.",
|
||||
"automatic-selection-active": "Henüz manuel seçim yok. AI tüm yaklaşan maç havuzundan seçecek.",
|
||||
"selected-matches-panel-title": "Seçili Maç Havuzu",
|
||||
@@ -388,15 +498,14 @@
|
||||
"win-rate": "Kazanma Oranı",
|
||||
"total-profit": "Toplam Kâr"
|
||||
},
|
||||
|
||||
"leagues": {
|
||||
"title": "Ligler & Takımlar",
|
||||
"countries": "Ülkeler",
|
||||
"leagues": "Ligler",
|
||||
"countries-leagues": "Ülkeler & Ligler",
|
||||
"search-at-least-2": "Takım aramak için en az 2 karakter yazın."
|
||||
"search-at-least-2": "Takım aramak için en az 2 karakter yazın.",
|
||||
"all": "Tümü"
|
||||
},
|
||||
|
||||
"h2h": {
|
||||
"title": "Karşılıklı Karşılaşma",
|
||||
"team-1": "Takım 1",
|
||||
@@ -406,7 +515,6 @@
|
||||
"draws": "Beraberlikler",
|
||||
"no-matches-found": "Bu takımlar arasında karşılıklı maç bulunamadı."
|
||||
},
|
||||
|
||||
"analysis": {
|
||||
"title": "Çoklu Maç Analizi",
|
||||
"select-matches": "Maç Seç",
|
||||
@@ -418,7 +526,6 @@
|
||||
"matches-analyzed": "maç analiz edildi",
|
||||
"no-history": "Henüz analiz geçmişi yok."
|
||||
},
|
||||
|
||||
"spor-toto": {
|
||||
"title": "Spor Toto",
|
||||
"sync-bulletins": "Bültenleri Senkronize Et",
|
||||
@@ -446,7 +553,6 @@
|
||||
"rollover-stats": "Devir İstatistikleri",
|
||||
"prediction-generated": "Tahmin başarıyla oluşturuldu!"
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"title": "Yönetim Paneli",
|
||||
"subtitle": "Kullanıcıları yönetin, tahminleri takip edin ve sistemi izleyin.",
|
||||
@@ -454,7 +560,9 @@
|
||||
"analytics": "Analitik Genel Bakış",
|
||||
"user-management": "Kullanıcı Yönetimi",
|
||||
"users": "Kullanıcılar",
|
||||
"premium-users": "Premium Kullanıcı",
|
||||
"settings": "Ayarlar",
|
||||
"subscription": "Abonelik",
|
||||
"usage-limits": "Kullanım Limitleri",
|
||||
"total-users": "Toplam Kullanıcı",
|
||||
"active-users": "Aktif Kullanıcı",
|
||||
@@ -474,10 +582,31 @@
|
||||
"user-email": "E-Posta",
|
||||
"user-role": "Rol",
|
||||
"user-status": "Durum",
|
||||
"no-users": "Kullanıcı bulunamadı."
|
||||
"no-users": "Kullanıcı bulunamadı.",
|
||||
"restricted": "Kısıtlı",
|
||||
"admin-access-required": "Admin erişimi gerekli",
|
||||
"admin-access-description": "Bu alan yalnızca superadmin hesapları tarafından kullanılabilir.",
|
||||
"search-users-placeholder": "E-posta veya isim ara...",
|
||||
"all-roles": "Tüm Rolleri Gör",
|
||||
"standard-user": "Standart Kullanıcı",
|
||||
"superadmin": "Sistem Yöneticisi (Admin)",
|
||||
"all-plans": "Tüm Paketleri Gör",
|
||||
"plan-free": "Ücretsiz (Free)",
|
||||
"plan-plus": "Plus Paketi",
|
||||
"plan-premium": "Premium Paketi",
|
||||
"plan-past-due": "Ödeme Gecikti (Past Due)",
|
||||
"plan-cancelled": "İptal Edildi (Cancelled)",
|
||||
"edit-user-title": "Kullanıcı Düzenle: {email}",
|
||||
"user-role-field": "Kullanıcı Rolü",
|
||||
"subscription-plan-field": "Abonelik Paketi",
|
||||
"subscription-end-date": "Abonelik Bitiş Tarihi (Opsiyonel)",
|
||||
"account-active-question": "Hesap Aktif mi?"
|
||||
},
|
||||
|
||||
"common": {
|
||||
"limits": {
|
||||
"analysis_left": "Analiz",
|
||||
"out_of_analysis": "Günlük analiz limitiniz doldu."
|
||||
},
|
||||
"loading": "Yükleniyor...",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
@@ -504,6 +633,166 @@
|
||||
"of": "/",
|
||||
"items-per-page": "Sayfa başına öğe",
|
||||
"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ı"
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+29
-2
@@ -2,16 +2,43 @@ import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
optimizePackageImports: ["@chakra-ui/react"],
|
||||
},
|
||||
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() {
|
||||
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 [
|
||||
{
|
||||
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
+188
-180
@@ -1,27 +1,29 @@
|
||||
{
|
||||
"name": "Suggest-Bet-FE-v2",
|
||||
"name": "iddaai-fe",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Suggest-Bet-FE-v2",
|
||||
"name": "iddaai-fe",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.28.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@google/genai": "^1.35.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paddle/paddle-js": "^1.6.4",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"aos": "^2.3.4",
|
||||
"axios": "^1.13.1",
|
||||
"framer-motion": "^12.34.1",
|
||||
"i18next": "^25.6.0",
|
||||
"next": "16.0.0",
|
||||
"next": "^16.2.5",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"postcss": "^8.5.14",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
@@ -1280,51 +1282,30 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz",
|
||||
"integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz",
|
||||
"integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA=="
|
||||
},
|
||||
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz",
|
||||
"integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==",
|
||||
"version": "3.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.7.tgz",
|
||||
"integrity": "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww==",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "3.1.1",
|
||||
"@formatjs/icu-skeleton-parser": "2.1.1",
|
||||
"tslib": "^2.8.1"
|
||||
"@formatjs/icu-skeleton-parser": "2.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz",
|
||||
"integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "3.1.1",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.7.tgz",
|
||||
"integrity": "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA=="
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz",
|
||||
"integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==",
|
||||
"version": "0.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz",
|
||||
"integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.0",
|
||||
"tslib": "^2.8.1"
|
||||
"@formatjs/fast-memoize": "3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
@@ -1936,9 +1917,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz",
|
||||
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA=="
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz",
|
||||
"integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA=="
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.0.0",
|
||||
@@ -1950,9 +1931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz",
|
||||
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz",
|
||||
"integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1965,9 +1946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz",
|
||||
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz",
|
||||
"integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1980,9 +1961,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz",
|
||||
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz",
|
||||
"integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1995,9 +1976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz",
|
||||
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz",
|
||||
"integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2010,9 +1991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz",
|
||||
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz",
|
||||
"integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2025,9 +2006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz",
|
||||
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz",
|
||||
"integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2040,9 +2021,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz",
|
||||
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz",
|
||||
"integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2055,9 +2036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz",
|
||||
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz",
|
||||
"integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2113,6 +2094,12 @@
|
||||
"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": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.1.tgz",
|
||||
@@ -2408,9 +2395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2438,9 +2425,9 @@
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
@@ -2462,9 +2449,9 @@
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
@@ -2477,9 +2464,9 @@
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
@@ -3052,21 +3039,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -4274,9 +4261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -4548,13 +4535,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
@@ -4618,7 +4605,6 @@
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
@@ -4632,9 +4618,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -5025,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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -5961,15 +5942,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6246,19 +6227,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -6562,9 +6543,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/icu-minify": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
||||
"integrity": "sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.0.tgz",
|
||||
"integrity": "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6623,14 +6604,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz",
|
||||
"integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==",
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.4.tgz",
|
||||
"integrity": "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "3.1.1",
|
||||
"@formatjs/fast-memoize": "3.1.0",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.1",
|
||||
"tslib": "^2.8.1"
|
||||
"@formatjs/fast-memoize": "3.1.4",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
@@ -7354,9 +7333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -7447,14 +7426,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz",
|
||||
"integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.0",
|
||||
"@next/env": "16.2.5",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
@@ -7466,15 +7445,15 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.0",
|
||||
"@next/swc-darwin-x64": "16.0.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.0",
|
||||
"@next/swc-linux-arm64-musl": "16.0.0",
|
||||
"@next/swc-linux-x64-gnu": "16.0.0",
|
||||
"@next/swc-linux-x64-musl": "16.0.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.0",
|
||||
"@next/swc-win32-x64-msvc": "16.0.0",
|
||||
"sharp": "^0.34.4"
|
||||
"@next/swc-darwin-arm64": "16.2.5",
|
||||
"@next/swc-darwin-x64": "16.2.5",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.5",
|
||||
"@next/swc-linux-arm64-musl": "16.2.5",
|
||||
"@next/swc-linux-x64-gnu": "16.2.5",
|
||||
"@next/swc-linux-x64-musl": "16.2.5",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.5",
|
||||
"@next/swc-win32-x64-msvc": "16.2.5",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -7531,9 +7510,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.3.tgz",
|
||||
"integrity": "sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.0.tgz",
|
||||
"integrity": "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7544,16 +7523,15 @@
|
||||
"@formatjs/intl-localematcher": "^0.8.1",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
"@swc/core": "^1.15.2",
|
||||
"icu-minify": "^4.8.3",
|
||||
"icu-minify": "^4.11.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",
|
||||
"use-intl": "^4.8.3"
|
||||
"use-intl": "^4.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"typescript": "^5.0.0"
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -7562,9 +7540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl-swc-plugin-extractor": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.3.tgz",
|
||||
"integrity": "sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg=="
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.0.tgz",
|
||||
"integrity": "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A=="
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
@@ -7583,6 +7561,33 @@
|
||||
"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": {
|
||||
"version": "3.9.17",
|
||||
"resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz",
|
||||
@@ -8034,9 +8039,9 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -8060,9 +8065,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8078,9 +8083,9 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -8152,21 +8157,21 @@
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
|
||||
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
@@ -8180,9 +8185,12 @@
|
||||
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-memoize": {
|
||||
"version": "3.0.1",
|
||||
@@ -9093,9 +9101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -9411,9 +9419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz",
|
||||
"integrity": "sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.0.tgz",
|
||||
"integrity": "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9423,7 +9431,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "^3.1.0",
|
||||
"@schummar/icu-type-parser": "1.21.5",
|
||||
"icu-minify": "^4.8.3",
|
||||
"icu-minify": "^4.11.0",
|
||||
"intl-messageformat": "^11.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -9662,9 +9670,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "Suggest-Bet-FE-v2",
|
||||
"name": "iddaai-fe",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
@@ -13,16 +13,18 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@google/genai": "^1.35.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paddle/paddle-js": "^1.6.4",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"aos": "^2.3.4",
|
||||
"axios": "^1.13.1",
|
||||
"framer-motion": "^12.34.1",
|
||||
"i18next": "^25.6.0",
|
||||
"next": "16.0.0",
|
||||
"next": "^16.2.5",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"postcss": "^8.5.14",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.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 { Box, Flex } from '@chakra-ui/react';
|
||||
import Footer from "@/components/layout/footer/footer";
|
||||
import { Box, Flex } from "@chakra-ui/react";
|
||||
|
||||
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Box as='main'>{children}</Box>
|
||||
<Flex minH="100vh" direction="column">
|
||||
<Box as="main">{children}</Box>
|
||||
<Footer />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,231 +1,30 @@
|
||||
"use client";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import SignInForm from "./signin-form";
|
||||
|
||||
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";
|
||||
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 schema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().required(),
|
||||
});
|
||||
const pathSegment = "signin";
|
||||
|
||||
type SignInForm = yup.InferType<typeof schema>;
|
||||
|
||||
const defaultValues = {
|
||||
email: "test@test.com.tr",
|
||||
password: "test1234",
|
||||
};
|
||||
|
||||
function SignInPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignInForm>({
|
||||
resolver: yupResolver(schema),
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: SignInForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
router.replace("/home");
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Giriş yaparken hata oluştu!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return {
|
||||
title: t("signin.title"),
|
||||
description: t("signin.description"),
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||
languages: {
|
||||
en: `${siteUrl}/en/${pathSegment}`,
|
||||
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
export default function SignInPage() {
|
||||
return <SignInForm />;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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";
|
||||
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 schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
const pathSegment = "signup";
|
||||
|
||||
type SignUpForm = yup.InferType<typeof schema>;
|
||||
|
||||
function SignUpPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: "onChange" });
|
||||
|
||||
const onSubmit = async (formData: SignUpForm) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await authService.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
firstName: formData.name,
|
||||
lastName: "",
|
||||
});
|
||||
|
||||
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 {
|
||||
title: t("signup.title"),
|
||||
description: t("signup.description"),
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/${locale}/${pathSegment}`,
|
||||
languages: {
|
||||
en: `${siteUrl}/en/${pathSegment}`,
|
||||
tr: `${siteUrl}/tr/${pathSegment}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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() {
|
||||
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() {
|
||||
return <div>AboutPage</div>;
|
||||
|
||||
@@ -5,12 +5,31 @@ import { isAdminRole } from "@/lib/auth/roles";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "admin";
|
||||
|
||||
return {
|
||||
title: `${t("admin.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Admin panel for managing users, monitoring predictions, and system overview.",
|
||||
title: t("admin.title"),
|
||||
description: t("admin.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 AnalysisContent from "@/components/analysis/analysis-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "analysis";
|
||||
|
||||
return {
|
||||
title: `${t("analysis.title")} | Suggest Bet`,
|
||||
description: "AI-powered multi-match analysis for coupon generation.",
|
||||
title: t("analysis.title"),
|
||||
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 CouponBuilderContent from "@/components/coupons/coupon-builder-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "coupon-builder";
|
||||
|
||||
return {
|
||||
title: `${t("coupons.builder-title")} | Suggest Bet`,
|
||||
description:
|
||||
"Build your coupon with AI-powered suggestions. Choose your strategy and let AI optimize your bets.",
|
||||
title: t("coupon-builder.title"),
|
||||
description: t("coupon-builder.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 CouponHistoryContent from "@/components/coupons/coupon-history-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "coupon-history";
|
||||
|
||||
return {
|
||||
title: `${t("coupons.history-title")} | Suggest Bet`,
|
||||
description:
|
||||
"View your coupon history, track wins and losses, and analyze your betting performance.",
|
||||
title: t("coupon-history.title"),
|
||||
description: t("coupon-history.description"),
|
||||
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 DashboardContent from "@/components/dashboard/dashboard-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "dashboard";
|
||||
|
||||
return {
|
||||
title: `${t("dashboard.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Your personalized betting dashboard with predictions, value bets, and match insights.",
|
||||
title: t("dashboard.title"),
|
||||
description: t("dashboard.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 H2HContent from "@/components/h2h/h2h-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "h2h";
|
||||
|
||||
return {
|
||||
title: `${t("matches.head-to-head")} | Suggest Bet`,
|
||||
description: "Compare two teams and view their head-to-head match history.",
|
||||
title: t("h2h.title"),
|
||||
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 HomeContent from "@/components/home/home-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "home";
|
||||
|
||||
return {
|
||||
title: `${t("home")} | Suggest Bet`,
|
||||
description:
|
||||
"AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons.",
|
||||
title: t("home.title"),
|
||||
description: t("home.description"),
|
||||
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 Header from '@/components/layout/header/header';
|
||||
import Footer from '@/components/layout/footer/footer';
|
||||
import BackToTop from '@/components/ui/back-to-top';
|
||||
import { Container, Flex } from "@chakra-ui/react";
|
||||
import Header from "@/components/layout/header/header";
|
||||
import Footer from "@/components/layout/footer/footer";
|
||||
import BackToTop from "@/components/ui/back-to-top";
|
||||
|
||||
function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Flex minH="100vh" direction="column">
|
||||
<Header />
|
||||
<Container as='main' maxW='8xl' flex='1' py={4}>
|
||||
<Container as="main" maxW="8xl" flex="1" py={4}>
|
||||
{children}
|
||||
</Container>
|
||||
<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 LeaguesContent from "@/components/leagues/leagues-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "leagues";
|
||||
|
||||
return {
|
||||
title: `${t("leagues.title")} | Suggest Bet`,
|
||||
description: "Browse football and basketball leagues, countries, and teams.",
|
||||
title: t("leagues.title"),
|
||||
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 MatchDetailContent from "@/components/matches/match-detail-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "matches/[id]";
|
||||
|
||||
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 MatchesContent from "@/components/matches/matches-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "matches";
|
||||
|
||||
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 PredictionsContent from "@/components/predictions/predictions-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "predictions";
|
||||
|
||||
return {
|
||||
title: `${t("predictions.title")} | Suggest Bet`,
|
||||
description:
|
||||
"AI-powered match predictions with confidence scores, value bets, and prediction history.",
|
||||
title: t("predictions.title"),
|
||||
description: t("predictions.description"),
|
||||
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 ProfileContent from "@/components/profile/profile-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "profile";
|
||||
|
||||
return {
|
||||
title: `${t("profile.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Manage your profile, view account info, and track your betting statistics.",
|
||||
title: t("profile.title"),
|
||||
description: t("profile.description"),
|
||||
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 SporTotoContent from "@/components/spor-toto/spor-toto-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "spor-toto";
|
||||
|
||||
return {
|
||||
title: `${t("spor-toto.title")} | Suggest Bet`,
|
||||
description:
|
||||
"Spor Toto predictions with AI-powered analysis. Generate optimized system coupons with contrarian parimutuel strategy.",
|
||||
title: t("spor-toto.title"),
|
||||
description: t("spor-toto.description"),
|
||||
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 TeamDetailContent from "@/components/teams/team-detail-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "teams/[id]";
|
||||
|
||||
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 TeamsContent from "@/components/teams/teams-content";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
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 = "teams";
|
||||
|
||||
return {
|
||||
title: `${t("nav.teams")} | Suggest Bet`,
|
||||
description: "Search and explore football teams, view match history and stats.",
|
||||
title: t("teams.title"),
|
||||
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
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
|
||||
@@ -4,8 +4,42 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { dir } from "i18next";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
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({
|
||||
variable: "--font-bricolage",
|
||||
subsets: ["latin"],
|
||||
@@ -23,6 +57,27 @@ export default async function RootLayout({
|
||||
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 (
|
||||
<html
|
||||
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='16x16' href='/favicon/favicon-16x16.png' /> */}
|
||||
<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>
|
||||
<body className={bricolage.variable}>
|
||||
<body className={bricolage.variable} suppressHydrationWarning>
|
||||
<NextIntlClientProvider>
|
||||
<Provider>{children}</Provider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Flex, Text, Button, VStack, Heading } from "@chakra-ui/react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
|
||||
<Flex
|
||||
h="100vh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
px={6}
|
||||
>
|
||||
<VStack spaceY={6}>
|
||||
<Heading
|
||||
as='h1'
|
||||
fontSize={{ base: '5xl', md: '6xl' }}
|
||||
fontWeight='bold'
|
||||
color={{ base: 'primary.600', _dark: 'primary.400' }}
|
||||
as="h1"
|
||||
fontSize={{ base: "5xl", md: "6xl" }}
|
||||
fontWeight="bold"
|
||||
color={{ base: "primary.600", _dark: "primary.400" }}
|
||||
>
|
||||
{t('error.404')}
|
||||
{t("error.404")}
|
||||
</Heading>
|
||||
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
|
||||
{t('error.not-found')}
|
||||
<Text
|
||||
fontSize={{ base: "md", md: "lg" }}
|
||||
color={{ base: "fg.muted", _dark: "white" }}
|
||||
>
|
||||
{t("error.not-found")}
|
||||
</Text>
|
||||
<Link href='/home' passHref>
|
||||
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
|
||||
{t('error.back-to-home')}
|
||||
<Link href="/home" passHref>
|
||||
<Button size={{ base: "md", md: "lg" }} rounded="md">
|
||||
{t("error.back-to-home")}
|
||||
</Button>
|
||||
</Link>
|
||||
</VStack>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
redirect('/home');
|
||||
redirect("/home");
|
||||
}
|
||||
|
||||
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,
|
||||
Button,
|
||||
Separator,
|
||||
Input,
|
||||
} 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 {
|
||||
SlideUp,
|
||||
@@ -25,11 +30,19 @@ import {
|
||||
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
||||
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
||||
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
|
||||
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
LuUsers,
|
||||
LuChartBar,
|
||||
LuActivity,
|
||||
LuShield,
|
||||
LuPencil,
|
||||
} from "react-icons/lu";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EditUserModal } from "./edit-user-modal";
|
||||
import LeagueTiersContent from "./league-tiers-content";
|
||||
|
||||
type AdminTab = "overview" | "users";
|
||||
type AdminTab = "overview" | "users" | "league-tiers";
|
||||
|
||||
// ========================
|
||||
// Admin Stat Card
|
||||
@@ -82,7 +95,26 @@ function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
|
||||
export default function AdminContent() {
|
||||
const t = useTranslations("admin");
|
||||
const tCommon = useTranslations("common");
|
||||
const format = useFormatter();
|
||||
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 cardBg = useColorModeValue("white", "gray.800");
|
||||
@@ -92,16 +124,24 @@ export default function AdminContent() {
|
||||
const { data: analyticsData, isLoading: analyticsLoading } =
|
||||
useAdminAnalytics(canAccessAdmin);
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers(
|
||||
undefined,
|
||||
{
|
||||
search: debouncedSearch,
|
||||
role: searchParams.role,
|
||||
subscriptionStatus: searchParams.subscriptionStatus,
|
||||
page: searchParams.page,
|
||||
limit: searchParams.limit,
|
||||
},
|
||||
canAccessAdmin,
|
||||
);
|
||||
|
||||
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
||||
const users = usersData?.data?.items ?? [];
|
||||
const meta = usersData?.data?.meta;
|
||||
|
||||
const tabs: { key: AdminTab; label: string }[] = [
|
||||
{ key: "overview", label: t("overview") },
|
||||
{ key: "users", label: t("user-management") },
|
||||
{ key: "league-tiers", label: "Lig Tier" },
|
||||
];
|
||||
|
||||
const getUserDisplayName = (user: AdminUserDto) => {
|
||||
@@ -127,13 +167,13 @@ export default function AdminContent() {
|
||||
<VStack gap={3}>
|
||||
<Badge colorPalette="red" variant="subtle" borderRadius="full">
|
||||
<LuShield />
|
||||
Restricted
|
||||
{t("restricted")}
|
||||
</Badge>
|
||||
<Heading as="h2" size="md">
|
||||
Admin access required
|
||||
{t("admin-access-required")}
|
||||
</Heading>
|
||||
<Text color="fg.muted" textAlign="center" maxW="md">
|
||||
This area is only available to superadmin accounts.
|
||||
{t("admin-access-description")}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
@@ -194,7 +234,9 @@ export default function AdminContent() {
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-users")}
|
||||
value={analytics?.totalUsers ?? analytics?.users?.total ?? 0}
|
||||
value={
|
||||
analytics?.totalUsers ?? analytics?.users?.total ?? 0
|
||||
}
|
||||
icon={<LuUsers />}
|
||||
colorPalette="primary"
|
||||
/>
|
||||
@@ -202,15 +244,27 @@ export default function AdminContent() {
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-predictions")}
|
||||
value={analytics?.totalPredictions ?? analytics?.predictions ?? 0}
|
||||
value={
|
||||
analytics?.totalPredictions ?? analytics?.predictions ?? 0
|
||||
}
|
||||
icon={<LuChartBar />}
|
||||
colorPalette="green"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("premium-users")}
|
||||
value={analytics?.users?.premium ?? 0}
|
||||
icon={<LuShield />}
|
||||
colorPalette="purple"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("active-users")}
|
||||
value={analytics?.activeUsers ?? analytics?.users?.active ?? 0}
|
||||
value={
|
||||
analytics?.activeUsers ?? analytics?.users?.active ?? 0
|
||||
}
|
||||
icon={<LuActivity />}
|
||||
colorPalette="orange"
|
||||
/>
|
||||
@@ -228,91 +282,282 @@ export default function AdminContent() {
|
||||
))}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === "users" &&
|
||||
(usersLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : users.length > 0 ? (
|
||||
{activeTab === "users" && (
|
||||
<VStack gap={4} align="stretch">
|
||||
{/* Filters */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<VStack gap={0} align="stretch">
|
||||
{/* Table Header */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
mb={2}
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text flex={2}>{t("user-name")}</Text>
|
||||
<Text flex={2}>{t("user-email")}</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-role")}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-status")}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* User Rows */}
|
||||
{users.map((user: AdminUserDto, idx: number) => (
|
||||
<Box key={user.id ?? idx}>
|
||||
{idx > 0 && <Separator />}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
align="center"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Text
|
||||
flex={2}
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
truncate
|
||||
>
|
||||
{getUserDisplayName(user)}
|
||||
</Text>
|
||||
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={isAdminRole([user.role]) ? "red" : "gray"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{formatRoleLabel(user.role)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={user.isActive ? "green" : "gray"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.isActive
|
||||
? tCommon("active")
|
||||
: tCommon("inactive")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
<Card.Body 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>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-users")}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{usersLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : users.length > 0 ? (
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Card.Body>
|
||||
<VStack gap={0} align="stretch">
|
||||
{/* Table Header */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
mb={2}
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text flex={2}>{t("user-name")}</Text>
|
||||
<Text flex={2}>{t("user-email")}</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-role")}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("subscription")}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-status")}
|
||||
</Text>
|
||||
<Text width="40px" textAlign="center"></Text>
|
||||
</Flex>
|
||||
|
||||
{/* User Rows */}
|
||||
{users.map((user: AdminUserDto, idx: number) => (
|
||||
<Box key={user.id ?? idx}>
|
||||
{idx > 0 && <Separator />}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
align="center"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Text
|
||||
flex={2}
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
truncate
|
||||
>
|
||||
{getUserDisplayName(user)}
|
||||
</Text>
|
||||
<Text
|
||||
flex={2}
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
truncate
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={
|
||||
isAdminRole([user.role]) ? "red" : "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{formatRoleLabel(user.role)}
|
||||
</Badge>
|
||||
</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">
|
||||
<Badge
|
||||
colorPalette={user.isActive ? "green" : "gray"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.isActive
|
||||
? tCommon("active")
|
||||
: tCommon("inactive")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Flex width="40px" justify="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingUser(user)}
|
||||
>
|
||||
<LuPencil />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</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>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-users")}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* League Tiers Tab */}
|
||||
{activeTab === "league-tiers" && <LeagueTiersContent />}
|
||||
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
/>
|
||||
</Box>
|
||||
</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 }) =>
|
||||
toaster.create({
|
||||
title: opts.title,
|
||||
type: opts.status as
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "loading",
|
||||
type: opts.status as "success" | "warning" | "error" | "info" | "loading",
|
||||
});
|
||||
|
||||
const toggleMatch = (id: string) => {
|
||||
|
||||
@@ -50,7 +50,11 @@ interface LoginModalProps {
|
||||
|
||||
/* ────────────────────────── Component ────────────────────────── */
|
||||
|
||||
export function LoginModal({ open, onOpenChange, initialMode = "login" }: LoginModalProps) {
|
||||
export function LoginModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialMode = "login",
|
||||
}: LoginModalProps) {
|
||||
const t = useTranslations();
|
||||
const [mode, setMode] = useState<"login" | "register">(initialMode);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -163,7 +167,7 @@ export function LoginModal({ open, onOpenChange, initialMode = "login" }: LoginM
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Heading size="lg" color="primary.500">
|
||||
<Heading as="span" size="lg" color="primary.500">
|
||||
{mode === "login" ? t("auth.sign-in") : t("auth.sign-up")}
|
||||
</Heading>
|
||||
</DialogTitle>
|
||||
|
||||
@@ -769,30 +769,51 @@ export default function CouponBuilderContent() {
|
||||
{/* 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")} />
|
||||
<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}
|
||||
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}
|
||||
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
|
||||
fontSize="xs"
|
||||
color={engineMode === "ai" ? "teal.500" : "cyan.500"}
|
||||
>
|
||||
{engineMode === "ai"
|
||||
? t("ai-mode-active")
|
||||
: t("freq-mode-active")}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -801,165 +822,173 @@ export default function CouponBuilderContent() {
|
||||
{engineMode === "frequency" ? (
|
||||
<FrequencyPanel />
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
fontWeight="semibold"
|
||||
mb={2}
|
||||
>
|
||||
{t("strategy")}
|
||||
</Text>
|
||||
<VStack align="stretch" gap={2} mb={4}>
|
||||
{strategies.map((entry) => {
|
||||
const active = activeStrategy === entry.key;
|
||||
const palette = strategyPalette(entry.key);
|
||||
return (
|
||||
<Box
|
||||
key={entry.key}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor={active ? `${palette}.400` : borderColor}
|
||||
bg={active ? `${palette}.50` : mutedBg}
|
||||
borderRadius="xl"
|
||||
cursor="pointer"
|
||||
onClick={() => setActiveStrategy(entry.key)}
|
||||
>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Badge
|
||||
colorPalette={palette}
|
||||
variant={active ? "solid" : "subtle"}
|
||||
>
|
||||
{entry.label}
|
||||
</Badge>
|
||||
{active ? <LuCheck color="currentColor" /> : null}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{entry.description}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
|
||||
<Separator mb={4} />
|
||||
|
||||
{/* Match Count Input */}
|
||||
<VStack align="stretch" gap={2} mb={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuListChecks} color="purple.500" />
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{t("match-count-label")}
|
||||
</Text>
|
||||
<InfoIcon
|
||||
content={t("match-count-help")}
|
||||
label={t("match-count-label")}
|
||||
/>
|
||||
</HStack>
|
||||
<Badge colorPalette="purple" variant="subtle">
|
||||
{matchCount}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="2"
|
||||
max="15"
|
||||
value={matchCount}
|
||||
onChange={(e) => setMatchCount(Number(e.target.value))}
|
||||
style={{
|
||||
width: "100%",
|
||||
accentColor: "teal",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<HStack
|
||||
justify="space-between"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text>2</Text>
|
||||
<Text>
|
||||
{t("match-count-auto", { count: allMatches.length })}
|
||||
<>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
fontWeight="semibold"
|
||||
mb={2}
|
||||
>
|
||||
{t("strategy")}
|
||||
</Text>
|
||||
<Text>15</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Separator mb={4} />
|
||||
|
||||
<VStack align="stretch" gap={3} mb={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuLayers3} color="teal.500" />
|
||||
<Text fontWeight="semibold">
|
||||
{t("selected-matches-panel-title")}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge colorPalette="teal" variant="subtle">
|
||||
{selectedMatchIds.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{selectedMatches.length > 0 ? (
|
||||
<VStack align="stretch" gap={2}>
|
||||
{selectedMatches.map((match: MatchResponseDto) => (
|
||||
<Flex
|
||||
key={match.id}
|
||||
p={3}
|
||||
bg={mutedBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={3}
|
||||
>
|
||||
<VStack align="flex-start" gap={0}>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{match.matchName}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{formatDate(match.mstUtc, locale)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
colorPalette="red"
|
||||
onClick={() => toggleMatchSelection(match.id)}
|
||||
<VStack align="stretch" gap={2} mb={4}>
|
||||
{strategies.map((entry) => {
|
||||
const active = activeStrategy === entry.key;
|
||||
const palette = strategyPalette(entry.key);
|
||||
return (
|
||||
<Box
|
||||
key={entry.key}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor={
|
||||
active ? `${palette}.400` : borderColor
|
||||
}
|
||||
bg={active ? `${palette}.50` : mutedBg}
|
||||
borderRadius="xl"
|
||||
cursor="pointer"
|
||||
onClick={() => setActiveStrategy(entry.key)}
|
||||
>
|
||||
{t("remove-match")}
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Badge
|
||||
colorPalette={palette}
|
||||
variant={active ? "solid" : "subtle"}
|
||||
>
|
||||
{entry.label}
|
||||
</Badge>
|
||||
{active ? (
|
||||
<LuCheck color="currentColor" />
|
||||
) : null}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{entry.description}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("selected-matches-empty")}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="teal"
|
||||
size="lg"
|
||||
width="full"
|
||||
borderRadius="xl"
|
||||
loading={suggestCoupon.isPending}
|
||||
onClick={handleSuggest}
|
||||
>
|
||||
<LuSparkles />
|
||||
{t("ai-suggest")}
|
||||
</Button>
|
||||
<Text fontSize="xs" color="fg.muted" mt={3}>
|
||||
{selectedMatchIds.length > 0
|
||||
? t("manual-selection-helper")
|
||||
: t("automatic-selection-helper")}
|
||||
</Text>
|
||||
</>
|
||||
<Separator mb={4} />
|
||||
|
||||
{/* Match Count Input */}
|
||||
<VStack align="stretch" gap={2} mb={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuListChecks} color="purple.500" />
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{t("match-count-label")}
|
||||
</Text>
|
||||
<InfoIcon
|
||||
content={t("match-count-help")}
|
||||
label={t("match-count-label")}
|
||||
/>
|
||||
</HStack>
|
||||
<Badge colorPalette="purple" variant="subtle">
|
||||
{matchCount}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="2"
|
||||
max="15"
|
||||
value={matchCount}
|
||||
onChange={(e) =>
|
||||
setMatchCount(Number(e.target.value))
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
accentColor: "teal",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<HStack
|
||||
justify="space-between"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text>2</Text>
|
||||
<Text>
|
||||
{t("match-count-auto", {
|
||||
count: allMatches.length,
|
||||
})}
|
||||
</Text>
|
||||
<Text>15</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Separator mb={4} />
|
||||
|
||||
<VStack align="stretch" gap={3} mb={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuLayers3} color="teal.500" />
|
||||
<Text fontWeight="semibold">
|
||||
{t("selected-matches-panel-title")}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge colorPalette="teal" variant="subtle">
|
||||
{selectedMatchIds.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{selectedMatches.length > 0 ? (
|
||||
<VStack align="stretch" gap={2}>
|
||||
{selectedMatches.map((match: MatchResponseDto) => (
|
||||
<Flex
|
||||
key={match.id}
|
||||
p={3}
|
||||
bg={mutedBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={3}
|
||||
>
|
||||
<VStack align="flex-start" gap={0}>
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{match.matchName}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{formatDate(match.mstUtc, locale)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
colorPalette="red"
|
||||
onClick={() => toggleMatchSelection(match.id)}
|
||||
>
|
||||
{t("remove-match")}
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Box p={3} bg={mutedBg} borderRadius="xl">
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("selected-matches-empty")}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="teal"
|
||||
size="lg"
|
||||
width="full"
|
||||
borderRadius="xl"
|
||||
loading={suggestCoupon.isPending}
|
||||
onClick={handleSuggest}
|
||||
>
|
||||
<LuSparkles />
|
||||
{t("ai-suggest")}
|
||||
</Button>
|
||||
<Text fontSize="xs" color="fg.muted" mt={3}>
|
||||
{selectedMatchIds.length > 0
|
||||
? t("manual-selection-helper")
|
||||
: t("automatic-selection-helper")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
@@ -170,7 +170,11 @@ export default function FrequencyPanel() {
|
||||
max="95"
|
||||
value={minSignal * 100}
|
||||
onChange={(e) => setMinSignal(Number(e.target.value) / 100)}
|
||||
style={{ width: "100%", accentColor: "#0891b2", cursor: "pointer" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
accentColor: "#0891b2",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
||||
<Text>50%</Text>
|
||||
@@ -197,7 +201,11 @@ export default function FrequencyPanel() {
|
||||
max="5"
|
||||
value={maxMatches}
|
||||
onChange={(e) => setMaxMatches(Number(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "#9333ea", cursor: "pointer" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
accentColor: "#9333ea",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
||||
<Text>2</Text>
|
||||
@@ -325,7 +333,12 @@ export default function FrequencyPanel() {
|
||||
borderRadius="xl"
|
||||
bg={mutedBg}
|
||||
>
|
||||
<Flex justify="space-between" align="flex-start" gap={3} mb={3}>
|
||||
<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">
|
||||
@@ -405,7 +418,9 @@ export default function FrequencyPanel() {
|
||||
<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>
|
||||
<Text fontWeight="semibold">
|
||||
{t("rejected-matches-title")}
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack align="stretch" gap={1}>
|
||||
{result.rejected_matches.map((entry, i) => (
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
||||
import {
|
||||
SlideUp,
|
||||
StaggerContainer,
|
||||
StaggerItem,
|
||||
ScrollSlideUp,
|
||||
} from "@/components/motion";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { MatchCard } from "@/components/matches";
|
||||
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
|
||||
@@ -26,8 +31,14 @@ import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LeagueWithMatchesDto, MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import type { MatchPredictionDto, ValueBetDto } from "@/lib/api/predictions/types";
|
||||
import type {
|
||||
LeagueWithMatchesDto,
|
||||
MatchResponseDto,
|
||||
} from "@/lib/api/matches/types";
|
||||
import type {
|
||||
MatchPredictionDto,
|
||||
ValueBetDto,
|
||||
} from "@/lib/api/predictions/types";
|
||||
|
||||
// ========================
|
||||
// Stats Card
|
||||
@@ -181,8 +192,11 @@ export default function DashboardContent() {
|
||||
queryMatches.mutate({ sport: "football", limit: 20 });
|
||||
}
|
||||
|
||||
const todayMatches: MatchResponseDto[] = queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ?? [];
|
||||
const upcomingPredictions: MatchPredictionDto[] = upcomingData?.data?.matches ?? [];
|
||||
const todayMatches: MatchResponseDto[] =
|
||||
queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ??
|
||||
[];
|
||||
const upcomingPredictions: MatchPredictionDto[] =
|
||||
upcomingData?.data?.matches ?? [];
|
||||
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
|
||||
const userStats = statsData?.data;
|
||||
|
||||
@@ -328,44 +342,46 @@ export default function DashboardContent() {
|
||||
</VStack>
|
||||
) : upcomingPredictions.length > 0 ? (
|
||||
<VStack gap={2} align="stretch">
|
||||
{upcomingPredictions.slice(0, 4).map((pred: MatchPredictionDto, idx: number) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={2.5}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
onClick={() =>
|
||||
router.push(`/matches/${pred.match_info.match_id}`)
|
||||
}
|
||||
>
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{pred.match_info.home_team} vs{" "}
|
||||
{pred.match_info.away_team}
|
||||
</Text>
|
||||
{pred.main_pick && (
|
||||
<Flex justify="space-between" align="center" mt={1}>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{pred.main_pick.pick}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette="primary"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{Math.round(
|
||||
pred.main_pick.calibrated_confidence ??
|
||||
pred.main_pick.confidence,
|
||||
)}
|
||||
%
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{upcomingPredictions
|
||||
.slice(0, 4)
|
||||
.map((pred: MatchPredictionDto, idx: number) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={2.5}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
onClick={() =>
|
||||
router.push(`/matches/${pred.match_info.match_id}`)
|
||||
}
|
||||
>
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{pred.match_info.home_team} vs{" "}
|
||||
{pred.match_info.away_team}
|
||||
</Text>
|
||||
{pred.main_pick && (
|
||||
<Flex justify="space-between" align="center" mt={1}>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{pred.main_pick.pick}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette="primary"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{Math.round(
|
||||
pred.main_pick.calibrated_confidence ??
|
||||
pred.main_pick.confidence,
|
||||
)}
|
||||
%
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text
|
||||
@@ -396,16 +412,18 @@ export default function DashboardContent() {
|
||||
</VStack>
|
||||
) : valueBets.length > 0 ? (
|
||||
<VStack gap={2} align="stretch">
|
||||
{valueBets.slice(0, 5).map((vb: ValueBetDto, idx: number) => (
|
||||
<ValueBetMiniCard
|
||||
key={idx}
|
||||
matchName={vb.matchName}
|
||||
prediction={vb.prediction}
|
||||
odd={vb.odd}
|
||||
expectedValue={vb.expectedValue}
|
||||
confidence={vb.confidence}
|
||||
/>
|
||||
))}
|
||||
{valueBets
|
||||
.slice(0, 5)
|
||||
.map((vb: ValueBetDto, idx: number) => (
|
||||
<ValueBetMiniCard
|
||||
key={idx}
|
||||
matchName={vb.matchName}
|
||||
prediction={vb.prediction}
|
||||
odd={vb.odd}
|
||||
expectedValue={vb.expectedValue}
|
||||
confidence={vb.confidence}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text
|
||||
|
||||
@@ -18,10 +18,10 @@ import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks";
|
||||
import type { TeamDto, HeadToHeadDto } from "@/lib/api/leagues/types";
|
||||
import type { TeamDto } from "@/lib/api/leagues/types";
|
||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import { LuSearch, LuArrowLeftRight } from "react-icons/lu";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
function TeamSearchInput({
|
||||
@@ -134,7 +134,7 @@ export default function H2HContent() {
|
||||
?.data
|
||||
? [
|
||||
{
|
||||
label: team1?.name || t("team1"),
|
||||
label: team1?.name || t("team-1"),
|
||||
value: h2h.data.data.team1Wins,
|
||||
color: "green",
|
||||
},
|
||||
@@ -144,7 +144,7 @@ export default function H2HContent() {
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
label: team2?.name || t("team2"),
|
||||
label: team2?.name || t("team-2"),
|
||||
value: h2h.data.data.team2Wins,
|
||||
color: "blue",
|
||||
},
|
||||
|
||||
@@ -309,7 +309,11 @@ export default function HomeContent() {
|
||||
shadow="lg"
|
||||
>
|
||||
<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={3200} label={t("stats-users")} suffix="+" />
|
||||
<StatBlock value={50000} label={t("stats-matches")} suffix="+" />
|
||||
@@ -320,7 +324,13 @@ export default function HomeContent() {
|
||||
{/* Features Section */}
|
||||
<Box mb={16}>
|
||||
<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")}
|
||||
</Heading>
|
||||
<Text
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function Footer() {
|
||||
focusRing="none"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
Suggest Bet
|
||||
iddaai
|
||||
</ChakraLink>
|
||||
. {t("all-right-reserved")}
|
||||
</Text>
|
||||
@@ -61,6 +61,20 @@ export default function Footer() {
|
||||
>
|
||||
{t("terms-of-service")}
|
||||
</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>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ClientOnly,
|
||||
Text,
|
||||
Separator,
|
||||
Badge,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ColorModeButton } from "@/components/ui/color-mode";
|
||||
@@ -40,20 +41,27 @@ import { signOut, useSession } from "next-auth/react";
|
||||
import { authConfig } from "@/config/auth";
|
||||
import { LoginModal } from "@/components/auth/login-modal";
|
||||
import { isAdminRole } from "@/lib/auth/roles";
|
||||
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
|
||||
import { LuLogIn, LuUser, LuShield, LuZap, LuCrown } from "react-icons/lu";
|
||||
import { PlanBadge } from "@/components/subscription";
|
||||
import GlobalSearch from "@/components/search/global-search";
|
||||
import Image from "next/image";
|
||||
import { useGetMe } from "@/lib/api/users/use-hooks";
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations();
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">("login");
|
||||
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">(
|
||||
"login",
|
||||
);
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const isAuthenticated = !!session;
|
||||
const isLoading = status === "loading";
|
||||
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
|
||||
const { data: meData } = useGetMe(isAuthenticated);
|
||||
const usageLimit = meData?.data?.usageLimit;
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsSticky(window.scrollY >= 10);
|
||||
@@ -79,26 +87,54 @@ export default function Header() {
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||
<MenuTrigger rounded="full" focusRing="none">
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem value="profile" onClick={() => router.push("/profile")}>
|
||||
<LuUser />
|
||||
{t("nav.profile")}
|
||||
</MenuItem>
|
||||
{session?.user && isAdminRole(session.user.roles) && (
|
||||
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
||||
<LuShield />
|
||||
{t("nav.admin")}
|
||||
<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" }}>
|
||||
<MenuTrigger rounded="full" focusRing="none">
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem value="profile" onClick={() => router.push("/profile")}>
|
||||
<LuUser />
|
||||
{t("nav.profile")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleLogout} value="sign-out">
|
||||
{t("auth.sign-out")}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
|
||||
<MenuItem
|
||||
value="pricing"
|
||||
onClick={() => router.push("/pricing")}
|
||||
>
|
||||
<LuCrown />
|
||||
{t("nav.pricing")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session?.user && isAdminRole(session.user.roles) && (
|
||||
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
||||
<LuShield />
|
||||
{t("nav.admin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleLogout} value="sign-out">
|
||||
{t("auth.sign-out")}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,9 +176,24 @@ export default function Header() {
|
||||
variant="solid"
|
||||
size="sm"
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="semibold" truncate>
|
||||
<Text fontSize="sm" fontWeight="semibold" truncate flex={1}>
|
||||
{session?.user?.name || session?.user?.email}
|
||||
</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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -154,6 +205,18 @@ export default function Header() {
|
||||
<LuUser />
|
||||
{t("nav.profile")}
|
||||
</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
|
||||
variant="surface"
|
||||
size="sm"
|
||||
@@ -227,36 +290,22 @@ export default function Header() {
|
||||
flexShrink={0}
|
||||
mr={6}
|
||||
>
|
||||
<Flex
|
||||
boxSize="32px"
|
||||
bg="primary.500"
|
||||
borderRadius="lg"
|
||||
align="center"
|
||||
justify="center"
|
||||
shadow="sm"
|
||||
>
|
||||
<LuZap color="white" size={18} />
|
||||
</Flex>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="iddaai logo"
|
||||
width={36}
|
||||
height={36}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
<Box>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="800"
|
||||
fontSize="xl"
|
||||
fontWeight="900"
|
||||
lineHeight="1"
|
||||
color={{ base: "gray.900", _dark: "white" }}
|
||||
letterSpacing="-0.02em"
|
||||
letterSpacing="-0.04em"
|
||||
>
|
||||
Suggest
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="600"
|
||||
lineHeight="1"
|
||||
mt="1px"
|
||||
color={{ base: "primary.600", _dark: "primary.300" }}
|
||||
letterSpacing="0.08em"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
BET
|
||||
iddaai
|
||||
</Text>
|
||||
</Box>
|
||||
</ChakraLink>
|
||||
@@ -302,6 +351,10 @@ export default function Header() {
|
||||
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
||||
<PopoverBody>
|
||||
<VStack mt="2" align="start" spaceY="2" w="full">
|
||||
{/* Mobile Search */}
|
||||
<Box w="full">
|
||||
<GlobalSearch />
|
||||
</Box>
|
||||
{visibleItems.map((item) => (
|
||||
<MobileHeaderLink key={item.href} item={item} />
|
||||
))}
|
||||
@@ -325,7 +378,11 @@ export default function Header() {
|
||||
</Box>
|
||||
|
||||
{/* Login Modal */}
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} initialMode={loginModalMode} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 SportFilter } from "./sport-filter";
|
||||
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 MatchDetailContent } from "./match-detail-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 activeBg = useColorModeValue("primary.50", "primary.900");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
||||
const countryTextColor = useColorModeValue("gray.500", "gray.400");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -111,26 +112,58 @@ export default function LeagueSidebar({
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex align="center" gap={2} minW={0} flex={1}>
|
||||
{league.countryFlag && (
|
||||
{/* Country Flag or Fallback */}
|
||||
{league.countryFlag ? (
|
||||
<Image
|
||||
src={league.countryFlag}
|
||||
alt={league.countryName || ""}
|
||||
boxSize="16px"
|
||||
boxSize="18px"
|
||||
objectFit="contain"
|
||||
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>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={isActive ? "bold" : "medium"}
|
||||
color={isActive ? "primary.fg" : "fg"}
|
||||
truncate
|
||||
>
|
||||
{league.name}
|
||||
</Text>
|
||||
|
||||
{/* League Name + Country */}
|
||||
<Box minW={0} flex={1}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={isActive ? "bold" : "medium"}
|
||||
color={isActive ? "primary.fg" : "fg"}
|
||||
truncate
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{league.name}
|
||||
</Text>
|
||||
{league.countryName && (
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
color={countryTextColor}
|
||||
truncate
|
||||
lineHeight="1.2"
|
||||
>
|
||||
{league.countryName}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={1.5} flexShrink={0}>
|
||||
{/* Badges */}
|
||||
<Flex gap={1.5} flexShrink={0} ml={2}>
|
||||
{league.liveCount > 0 && (
|
||||
<Badge
|
||||
colorPalette="red"
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { LuUsers, LuUser } from "react-icons/lu";
|
||||
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";
|
||||
|
||||
@@ -21,122 +27,274 @@ interface LineupsCardProps {
|
||||
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");
|
||||
|
||||
const homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
|
||||
const awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
|
||||
let homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
|
||||
let awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
|
||||
|
||||
if (homeLineups.length === 0 && awayLineups.length === 0) {
|
||||
return null;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Determine if it's confirmed or probable
|
||||
const source = prediction?.data_quality?.lineup_source;
|
||||
const isConfirmed = source === "confirmed_live";
|
||||
const title = isConfirmed ? "İlk 11" : "Muhtemel Kadro";
|
||||
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={LuUsers} boxSize={5} color="fg.muted" />
|
||||
<Icon as={meta.icon} boxSize={5} color="fg.muted" />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{title}
|
||||
{meta.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorPalette={isConfirmed ? "green" : "orange"}
|
||||
variant="subtle"
|
||||
>
|
||||
{isConfirmed ? "Onaylı" : "Muhtemel"}
|
||||
<Badge colorPalette={meta.badgeColor} variant="subtle">
|
||||
{meta.badge}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
<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}
|
||||
>
|
||||
<Text fontWeight="bold">{match.homeTeamName}</Text>
|
||||
</Flex>
|
||||
<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>
|
||||
</Box>
|
||||
{/* ── 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>
|
||||
)}
|
||||
|
||||
{/* Away Team Lineup */}
|
||||
<Box>
|
||||
<Flex
|
||||
bg={headerBg}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
align="center"
|
||||
justify="center"
|
||||
mb={3}
|
||||
>
|
||||
<Text fontWeight="bold">{match.awayTeamName}</Text>
|
||||
</Flex>
|
||||
<VStack align="stretch" gap={2}>
|
||||
{awayLineups.map((p, idx) => (
|
||||
<HStack
|
||||
key={p.player?.id || idx}
|
||||
p={2}
|
||||
{/* ── 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}
|
||||
>
|
||||
<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 fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||
Kadro henüz belli değil
|
||||
</Text>
|
||||
{p.position && (
|
||||
<Badge ml="auto" size="sm" variant="surface">
|
||||
{p.position}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
))}
|
||||
<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>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { slideUpVariants } from "@/components/motion";
|
||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { useState } from "react";
|
||||
import { LoginModal } from "@/components/auth/login-modal";
|
||||
|
||||
interface MatchCardProps {
|
||||
match: MatchResponseDto;
|
||||
@@ -24,7 +27,10 @@ const MotionBox = motion.create(Box);
|
||||
|
||||
export default function MatchCard({ match }: MatchCardProps) {
|
||||
const t = useTranslations("matches");
|
||||
const tAuth = useTranslations("auth");
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const cardBorder = useColorModeValue("gray.100", "gray.700");
|
||||
@@ -42,6 +48,10 @@ export default function MatchCard({ match }: MatchCardProps) {
|
||||
: t("not-started");
|
||||
|
||||
const handleClick = () => {
|
||||
if (!session) {
|
||||
setLoginModalOpen(true);
|
||||
return;
|
||||
}
|
||||
router.push(`/matches/${match.id}`);
|
||||
};
|
||||
|
||||
@@ -49,180 +59,205 @@ export default function MatchCard({ match }: MatchCardProps) {
|
||||
const matchDate = new Date(match.mstUtc);
|
||||
|
||||
return (
|
||||
<MotionBox
|
||||
variants={slideUpVariants}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={handleClick}
|
||||
transition={{ duration: 0.25 }}
|
||||
_hover={{
|
||||
bg: hoverBg,
|
||||
borderColor: hoverBorder,
|
||||
transform: "translateY(-3px)",
|
||||
shadow: "xl",
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Badge
|
||||
colorPalette={statusColor}
|
||||
variant="subtle"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{isLive && (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-block"
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
bg="red.500"
|
||||
mr={1.5}
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
)}
|
||||
{statusText}
|
||||
</Badge>
|
||||
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{matchDate.toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Teams */}
|
||||
<HStack gap={3} justify="space-between">
|
||||
{/* Home Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.homeTeamLogo ? (
|
||||
<Image
|
||||
src={match.homeTeamLogo}
|
||||
alt={match.homeTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.homeTeamName?.charAt(0) || "H"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
<>
|
||||
<MotionBox
|
||||
variants={slideUpVariants}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={handleClick}
|
||||
transition={{ duration: 0.25 }}
|
||||
_hover={{
|
||||
bg: hoverBg,
|
||||
borderColor: hoverBorder,
|
||||
transform: "translateY(-3px)",
|
||||
shadow: "xl",
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Badge
|
||||
colorPalette={statusColor}
|
||||
variant="subtle"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{match.homeTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
{isLive && (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-block"
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
bg="red.500"
|
||||
mr={1.5}
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
)}
|
||||
{statusText}
|
||||
</Badge>
|
||||
|
||||
{/* Score or VS */}
|
||||
<VStack gap={0} flexShrink={0}>
|
||||
{(isLive || isFinished) &&
|
||||
match.scoreHome !== undefined &&
|
||||
match.scoreAway !== undefined ? (
|
||||
<HStack gap={2}>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreHome}
|
||||
</Text>
|
||||
<Text fontSize="lg" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreAway}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text fontSize="md" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.awayTeamLogo ? (
|
||||
<Image
|
||||
src={match.awayTeamLogo}
|
||||
alt={match.awayTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.awayTeamName?.charAt(0) || "A"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.awayTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* League Info */}
|
||||
{(match.leagueName || match.countryName) && (
|
||||
<Flex
|
||||
mt={3}
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={1.5}
|
||||
>
|
||||
{/* Flag handling if available in flat response, otherwise skip or pass from parent */}
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{match.countryName && `${match.countryName} • `}
|
||||
{match.leagueName}
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{matchDate.toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</MotionBox>
|
||||
|
||||
{/* Teams */}
|
||||
<HStack gap={3} justify="space-between">
|
||||
{/* Home Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.homeTeamLogo ? (
|
||||
<Image
|
||||
src={match.homeTeamLogo}
|
||||
alt={match.homeTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.homeTeamName?.charAt(0) || "H"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.homeTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Score or VS */}
|
||||
<VStack gap={0} flexShrink={0}>
|
||||
{(isLive || isFinished) &&
|
||||
match.scoreHome !== undefined &&
|
||||
match.scoreAway !== undefined ? (
|
||||
<HStack gap={2}>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreHome}
|
||||
</Text>
|
||||
<Text fontSize="lg" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreAway}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text fontSize="md" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.awayTeamLogo ? (
|
||||
<Image
|
||||
src={match.awayTeamLogo}
|
||||
alt={match.awayTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.awayTeamName?.charAt(0) || "A"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.awayTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* League Info */}
|
||||
{(match.leagueName || match.countryName) && (
|
||||
<Flex
|
||||
mt={3}
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={1.5}
|
||||
>
|
||||
{/* Flag handling if available in flat response, otherwise skip or pass from parent */}
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{match.countryName && `${match.countryName} • `}
|
||||
{match.leagueName}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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 { useTranslations } from "next-intl";
|
||||
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 MatchCard from "./match-card";
|
||||
import type {
|
||||
@@ -53,7 +57,13 @@ function MatchCardSkeleton() {
|
||||
</HStack>
|
||||
|
||||
{/* 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" />
|
||||
</Flex>
|
||||
</Box>
|
||||
@@ -117,6 +127,10 @@ export default function MatchList({
|
||||
);
|
||||
}
|
||||
|
||||
const sortedFlatMatches = [...flatMatches].sort(
|
||||
(a, b) => Number(a.mstUtc) - Number(b.mstUtc),
|
||||
);
|
||||
|
||||
return (
|
||||
<StaggerContainer>
|
||||
<Grid
|
||||
@@ -127,7 +141,7 @@ export default function MatchList({
|
||||
}}
|
||||
gap={4}
|
||||
>
|
||||
{flatMatches.map((match) => (
|
||||
{sortedFlatMatches.map((match) => (
|
||||
<StaggerItem key={match.id}>
|
||||
<MatchCard match={match} />
|
||||
</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 (
|
||||
<StaggerContainer>
|
||||
{leagues.map((league) => (
|
||||
{sortedLeagues.map((league) => (
|
||||
<StaggerItem key={league.id}>
|
||||
<Box mb={6}>
|
||||
{/* League Header */}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Heading } from "@chakra-ui/react";
|
||||
import { useEffect } from "react";
|
||||
import { Box, Flex, Heading, Group, Button } from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 { useMatchStore } from "@/lib/stores/match-store";
|
||||
|
||||
type QuickFilter = "all" | "today" | "live" | "next_1_hour";
|
||||
|
||||
export default function MatchesContent() {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
@@ -16,6 +23,9 @@ export default function MatchesContent() {
|
||||
const setSport = useMatchStore((s) => s.setSport);
|
||||
const setLeague = useMatchStore((s) => s.setLeague);
|
||||
|
||||
const [quickFilter, setQuickFilter] = useState<QuickFilter>("all");
|
||||
const [dateFilter, setDateFilter] = useState<string>("");
|
||||
|
||||
// Fetch active leagues for sidebar
|
||||
const { data: leaguesData, isLoading: leaguesLoading } =
|
||||
useActiveLeagues(sport);
|
||||
@@ -26,42 +36,63 @@ export default function MatchesContent() {
|
||||
|
||||
// Trigger query on sport/league change
|
||||
const { data: matchesData, isPending: matchesLoading } = (() => {
|
||||
// We use the queryMatches mutation for initial data
|
||||
// but for the UI we want a reactive approach.
|
||||
// Let's use the standard list with league filter
|
||||
return {
|
||||
data: queryMatches.data,
|
||||
isPending: queryMatches.isPending,
|
||||
};
|
||||
})();
|
||||
|
||||
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
|
||||
const handleSportChange = (newSport: typeof sport) => {
|
||||
setSport(newSport);
|
||||
queryMatches.mutate({
|
||||
sport: newSport,
|
||||
leagueId: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
setLeague(null);
|
||||
triggerQuery(newSport, null, quickFilter, dateFilter);
|
||||
};
|
||||
|
||||
const handleLeagueChange = (leagueId: string | null) => {
|
||||
setLeague(leagueId);
|
||||
queryMatches.mutate({
|
||||
sport,
|
||||
leagueId: leagueId || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
triggerQuery(sport, leagueId, quickFilter, dateFilter);
|
||||
};
|
||||
|
||||
const handleQuickFilterChange = (filter: QuickFilter) => {
|
||||
setDateFilter(""); // Clear specific date
|
||||
setQuickFilter(filter);
|
||||
triggerQuery(sport, leagueFilter, filter, undefined);
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!queryMatches.data && !queryMatches.isPending) {
|
||||
queryMatches.mutate({
|
||||
sport,
|
||||
leagueId: leagueFilter || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
triggerQuery(sport, leagueFilter, quickFilter, dateFilter);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -75,7 +106,7 @@ export default function MatchesContent() {
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mb={6}
|
||||
mb={4}
|
||||
flexWrap="wrap"
|
||||
gap={3}
|
||||
>
|
||||
@@ -85,6 +116,85 @@ export default function MatchesContent() {
|
||||
<SportFilter value={sport} onChange={handleSportChange} />
|
||||
</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 */}
|
||||
<Flex
|
||||
gap={6}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,7 +153,12 @@ function TripleValueCard({
|
||||
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";
|
||||
const edgeColor =
|
||||
entry.edge > 0.03
|
||||
? "green.500"
|
||||
: entry.edge < -0.03
|
||||
? "red.400"
|
||||
: "fg.muted";
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -249,13 +254,7 @@ function ProgressBar({
|
||||
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="10px" w="full" bg={trackBg} borderRadius="full" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
w={`${w}%`}
|
||||
@@ -460,13 +459,28 @@ function HtftGrid({
|
||||
{/* 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">
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
color="fg.muted"
|
||||
>
|
||||
MS 1
|
||||
</Text>
|
||||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
color="fg.muted"
|
||||
>
|
||||
MS X
|
||||
</Text>
|
||||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
color="fg.muted"
|
||||
>
|
||||
MS 2
|
||||
</Text>
|
||||
</Grid>
|
||||
@@ -611,7 +625,12 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||||
|
||||
{/* Engine version badge */}
|
||||
<HStack>
|
||||
<Badge colorPalette="purple" variant="subtle" borderRadius="full" fontSize="2xs">
|
||||
<Badge
|
||||
colorPalette="purple"
|
||||
variant="subtle"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
>
|
||||
{engine.version}
|
||||
</Badge>
|
||||
{engine.consensus && (
|
||||
@@ -621,11 +640,18 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
>
|
||||
{engine.consensus === "AGREE" ? "Motorlar Uyumlu" : "Motorlar Farklı"}
|
||||
{engine.consensus === "AGREE"
|
||||
? "Motorlar Uyumlu"
|
||||
: "Motorlar Farklı"}
|
||||
</Badge>
|
||||
)}
|
||||
{valueHits.length > 0 && (
|
||||
<Badge colorPalette="green" variant="outline" borderRadius="full" fontSize="2xs">
|
||||
<Badge
|
||||
colorPalette="green"
|
||||
variant="outline"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
>
|
||||
{valueHits.length} Değer Sinyali
|
||||
</Badge>
|
||||
)}
|
||||
@@ -656,7 +682,10 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||||
{/* Cards + HTFT side by side on large screens */}
|
||||
{(hasCards || hasHtft) && (
|
||||
<Grid
|
||||
templateColumns={{ base: "1fr", xl: hasCards && hasHtft ? "1fr 1fr" : "1fr" }}
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
xl: hasCards && hasHtft ? "1fr 1fr" : "1fr",
|
||||
}}
|
||||
gap={4}
|
||||
>
|
||||
{hasCards && <CardsSection cards={cards} />}
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
useInView,
|
||||
type HTMLMotionProps,
|
||||
} from "framer-motion";
|
||||
import { forwardRef, type ReactNode, useEffect, useRef } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
Key,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// ========================
|
||||
// Shared animation variants
|
||||
@@ -381,34 +388,92 @@ interface SparkleProps {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 4 + Math.random() * 4,
|
||||
height: 4 + Math.random() * 4,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
left: `${10 + Math.random() * 80}%`,
|
||||
bottom: `${Math.random() * 30}%`,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -(60 + Math.random() * 80)],
|
||||
opacity: [0, 1, 1, 0],
|
||||
scale: [0.5, 1, 0.8, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.5 + Math.random() * 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
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%",
|
||||
background: color,
|
||||
left: `${sparkle.left}%`,
|
||||
bottom: `${sparkle.bottom}%`,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, sparkle.y],
|
||||
opacity: [0, 1, 1, 0],
|
||||
scale: [0.5, 1, 0.8, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: sparkle.duration,
|
||||
repeat: Infinity,
|
||||
delay: sparkle.delay,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,8 +154,9 @@ export default function PredictionsContent() {
|
||||
</Badge>
|
||||
<Badge
|
||||
colorPalette={
|
||||
riskColors[pred.risk?.level?.toUpperCase()] ||
|
||||
"gray"
|
||||
riskColors[
|
||||
pred.risk?.level?.toUpperCase()
|
||||
] || "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
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 { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubscriptionCard } from "@/components/subscription";
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: React.ReactNode;
|
||||
@@ -174,6 +175,9 @@ export default function ProfileContent() {
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Subscription Info */}
|
||||
<SubscriptionCard />
|
||||
|
||||
{/* Account Info */}
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
|
||||
@@ -16,8 +16,10 @@ import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { LuSearch, LuX } from "react-icons/lu";
|
||||
import type { TeamDto } from "@/lib/api/leagues/types";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const { data: session } = useSession();
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
@@ -35,6 +37,7 @@ export default function GlobalSearch() {
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.800");
|
||||
const inputBg = useColorModeValue("gray.50", "gray.800");
|
||||
const shortcutBg = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
@@ -85,6 +88,12 @@ export default function GlobalSearch() {
|
||||
[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 (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
@@ -142,7 +151,7 @@ export default function GlobalSearch() {
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
flexShrink={0}
|
||||
bg={useColorModeValue("gray.100", "gray.700")}
|
||||
bg={shortcutBg}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
|
||||
@@ -399,7 +399,7 @@ function HomeCard() {
|
||||
const imagesCollection = createListCollection({ items: images });
|
||||
|
||||
const currentImage = imagesCollection.items.find(
|
||||
(img) => img.value === selectedImage
|
||||
(img) => img.value === selectedImage,
|
||||
);
|
||||
|
||||
const nodeCollection = createTreeCollection<Node>({
|
||||
@@ -410,7 +410,7 @@ function HomeCard() {
|
||||
|
||||
const [tabs, setTabs] = React.useState<Item[]>(itemsTabs);
|
||||
const [selectedTab, setSelectedTab] = React.useState<string | null>(
|
||||
itemsTabs[0].id
|
||||
itemsTabs[0].id,
|
||||
);
|
||||
|
||||
const uuid = () => {
|
||||
@@ -2682,7 +2682,7 @@ function HomeCard() {
|
||||
}
|
||||
onCheckedChange={(changes) => {
|
||||
setSelection(
|
||||
changes.checked ? items.map((item) => item.name) : []
|
||||
changes.checked ? items.map((item) => item.name) : [],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -2710,7 +2710,7 @@ function HomeCard() {
|
||||
setSelection((prev) =>
|
||||
changes.checked
|
||||
? [...prev, item.name]
|
||||
: selection.filter((name) => name !== item.name)
|
||||
: selection.filter((name) => name !== item.name),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
useSyncBulletin,
|
||||
useRolloverHistory,
|
||||
} from "@/lib/api/spor-toto/use-hooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { UsersQueryKeys } from "@/lib/api/users/use-hooks";
|
||||
import type {
|
||||
SporTotoBulletinDto,
|
||||
SporTotoPredictionResultDto,
|
||||
@@ -59,15 +61,11 @@ export default function SporTotoContent() {
|
||||
const rolloverHistory = useRolloverHistory(10);
|
||||
const syncBulletin = useSyncBulletin();
|
||||
const generatePrediction = useGeneratePrediction();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = (opts: { title: string; status: string }) =>
|
||||
toaster.create({
|
||||
title: opts.title,
|
||||
type: opts.status as
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "loading",
|
||||
type: opts.status as "success" | "warning" | "error" | "info" | "loading",
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
@@ -91,6 +89,7 @@ export default function SporTotoContent() {
|
||||
bulletinId: selectedBulletin,
|
||||
strategy,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: UsersQueryKeys.me() });
|
||||
toast({
|
||||
title: t("prediction-generated"),
|
||||
status: "success",
|
||||
@@ -102,27 +101,27 @@ export default function SporTotoContent() {
|
||||
label: string;
|
||||
desc: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "CONSERVATIVE",
|
||||
label: t("strategy-conservative"),
|
||||
desc: t("strategy-conservative-desc"),
|
||||
},
|
||||
{
|
||||
value: "BALANCED",
|
||||
label: t("strategy-balanced"),
|
||||
desc: t("strategy-balanced-desc"),
|
||||
},
|
||||
{
|
||||
value: "AGGRESSIVE",
|
||||
label: t("strategy-aggressive"),
|
||||
desc: t("strategy-aggressive-desc"),
|
||||
},
|
||||
{
|
||||
value: "FORMULA_6PCT",
|
||||
label: t("strategy-formula"),
|
||||
desc: t("strategy-formula-desc"),
|
||||
},
|
||||
];
|
||||
{
|
||||
value: "CONSERVATIVE",
|
||||
label: t("strategy-conservative"),
|
||||
desc: t("strategy-conservative-desc"),
|
||||
},
|
||||
{
|
||||
value: "BALANCED",
|
||||
label: t("strategy-balanced"),
|
||||
desc: t("strategy-balanced-desc"),
|
||||
},
|
||||
{
|
||||
value: "AGGRESSIVE",
|
||||
label: t("strategy-aggressive"),
|
||||
desc: t("strategy-aggressive-desc"),
|
||||
},
|
||||
{
|
||||
value: "FORMULA_6PCT",
|
||||
label: t("strategy-formula"),
|
||||
desc: t("strategy-formula-desc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,42 +15,63 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, FadeIn } from "@/components/motion";
|
||||
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
||||
import { LuArrowLeft, LuCalendar, LuTrophy, LuChevronDown } from "react-icons/lu";
|
||||
import {
|
||||
LuArrowLeft,
|
||||
LuCalendar,
|
||||
LuTrophy,
|
||||
LuChevronDown,
|
||||
} from "react-icons/lu";
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 || "");
|
||||
}
|
||||
|
||||
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
||||
return String(team?.logo || (team as Record<string, unknown> | undefined)?.logoUrl || fallback || "");
|
||||
function getTeamSideLogo(
|
||||
team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"],
|
||||
fallback?: unknown,
|
||||
): string {
|
||||
return String(team?.logo || fallback || "");
|
||||
}
|
||||
|
||||
function getLeagueLabel(match: MatchResponseDto): string {
|
||||
@@ -72,7 +93,10 @@ 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}`);
|
||||
return Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => `${startYear - i}-${startYear - i + 1}`,
|
||||
);
|
||||
})();
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
@@ -83,6 +107,8 @@ export default function TeamDetailContent() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
|
||||
const teamId = params.id as string;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -93,27 +119,46 @@ export default function TeamDetailContent() {
|
||||
data: matchesResponse,
|
||||
isLoading: matchesLoading,
|
||||
isFetching: matchesFetching,
|
||||
} = useTeamMatches(teamId, { page: currentPage, limit: 20, season: activeSeason });
|
||||
} = useTeamMatches(teamId, {
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
season: activeSeason,
|
||||
});
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
const seasonActiveBg = useColorModeValue("primary.500", "primary.400");
|
||||
const seasonInactiveBg = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const team = (teamData as Record<string, unknown> | undefined)?.data as Record<string, unknown> | undefined;
|
||||
const paginationData = matchesResponse;
|
||||
const matches: MatchResponseDto[] = paginationData?.data ?? [];
|
||||
const totalPages = paginationData?.totalPages ?? 1;
|
||||
const totalMatches = paginationData?.total ?? 0;
|
||||
// Backend ResponseInterceptor wraps all responses in { success, status, message, data }
|
||||
const teamWrapper = teamData as Record<string, unknown> | undefined;
|
||||
const team = teamWrapper?.data as Record<string, unknown> | undefined;
|
||||
|
||||
// matchesResponse = { success, status, message, data: { data: [...], total, page, limit, totalPages } }
|
||||
const paginationWrapper = matchesResponse as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const paginationData = paginationWrapper?.data as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const matches: MatchResponseDto[] = (
|
||||
Array.isArray(paginationData?.data)
|
||||
? paginationData.data
|
||||
: paginationData?.data
|
||||
? []
|
||||
: []
|
||||
) as MatchResponseDto[];
|
||||
const totalPages = (paginationData?.totalPages as number) ?? 1;
|
||||
const totalMatches = (paginationData?.total as number) ?? 0;
|
||||
|
||||
// Separate past and upcoming matches
|
||||
const pastMatches = useMemo(
|
||||
() => matches.filter((m) => isMatchFinished(m)),
|
||||
[matches]
|
||||
[matches],
|
||||
);
|
||||
const upcomingMatches = useMemo(
|
||||
() => matches.filter((m) => !isMatchFinished(m)),
|
||||
[matches]
|
||||
[matches],
|
||||
);
|
||||
|
||||
// Pagination handlers
|
||||
@@ -160,7 +205,9 @@ export default function TeamDetailContent() {
|
||||
if (!team) {
|
||||
return (
|
||||
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
||||
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
|
||||
<Text color="fg.muted" fontSize="lg">
|
||||
Takım bulunamadı
|
||||
</Text>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<LuArrowLeft /> Geri
|
||||
</Button>
|
||||
@@ -172,13 +219,24 @@ export default function TeamDetailContent() {
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* 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 />
|
||||
Geri
|
||||
</Button>
|
||||
|
||||
{/* Team Header */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Root
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Body>
|
||||
<HStack gap={6} justify="center" align="center">
|
||||
{(team as Record<string, unknown>).logo ? (
|
||||
@@ -197,7 +255,9 @@ export default function TeamDetailContent() {
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
||||
{String((team as Record<string, unknown>).name || "T").charAt(0)}
|
||||
{String(
|
||||
(team as Record<string, unknown>).name || "T",
|
||||
).charAt(0)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
@@ -240,7 +300,11 @@ export default function TeamDetailContent() {
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
statusBadge={getStatusBadge(match)}
|
||||
onClick={() => router.push(`/tr/matches/${match.id}`)}
|
||||
onClick={() =>
|
||||
session
|
||||
? router.push(`/matches/${match.id}`)
|
||||
: setLoginModalOpen(true)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -251,7 +315,13 @@ export default function TeamDetailContent() {
|
||||
{/* Past Matches — Season Grouped */}
|
||||
<FadeIn>
|
||||
<Box>
|
||||
<Flex align="center" justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
mb={4}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<Heading as="h2" size="lg">
|
||||
📊 Geçmiş Maçlar
|
||||
</Heading>
|
||||
@@ -311,7 +381,11 @@ export default function TeamDetailContent() {
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
statusBadge={getStatusBadge(match)}
|
||||
onClick={() => router.push(`/tr/matches/${match.id}`)}
|
||||
onClick={() =>
|
||||
session
|
||||
? router.push(`/matches/${match.id}`)
|
||||
: setLoginModalOpen(true)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -348,7 +422,9 @@ export default function TeamDetailContent() {
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={pageNum === currentPage ? "solid" : "ghost"}
|
||||
bg={pageNum === currentPage ? seasonActiveBg : undefined}
|
||||
bg={
|
||||
pageNum === currentPage ? seasonActiveBg : undefined
|
||||
}
|
||||
color={pageNum === currentPage ? "white" : undefined}
|
||||
borderRadius="full"
|
||||
minW="36px"
|
||||
@@ -374,6 +450,13 @@ export default function TeamDetailContent() {
|
||||
)}
|
||||
</Box>
|
||||
</FadeIn>
|
||||
|
||||
{/* Login Modal — shown when unauthenticated user clicks a match */}
|
||||
<LoginModal
|
||||
open={loginModalOpen}
|
||||
onOpenChange={setLoginModalOpen}
|
||||
initialMode="login"
|
||||
/>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
@@ -391,7 +474,13 @@ interface MatchRowProps {
|
||||
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 matchTimestamp = getMatchTimestamp(match);
|
||||
const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName);
|
||||
@@ -420,17 +509,34 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
|
||||
{homeTeamName}
|
||||
</Text>
|
||||
{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}>
|
||||
<Text fontSize="xs" fontWeight="bold">{homeTeamName?.charAt(0)}</Text>
|
||||
<Flex
|
||||
boxSize="24px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="bold">
|
||||
{homeTeamName?.charAt(0)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Score / VS */}
|
||||
<VStack gap={0} flexShrink={0} minW="60px">
|
||||
{hasScore && match.scoreHome !== undefined && match.scoreHome !== null ? (
|
||||
{hasScore &&
|
||||
match.scoreHome !== undefined &&
|
||||
match.scoreHome !== null ? (
|
||||
<Text fontSize="md" fontWeight="900">
|
||||
{match.scoreHome} - {match.scoreAway}
|
||||
</Text>
|
||||
@@ -452,10 +558,25 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
|
||||
{/* Away Team */}
|
||||
<HStack gap={2} flex={1}>
|
||||
{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}>
|
||||
<Text fontSize="xs" fontWeight="bold">{awayTeamName?.charAt(0)}</Text>
|
||||
<Flex
|
||||
boxSize="24px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="bold">
|
||||
{awayTeamName?.charAt(0)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="600" truncate>
|
||||
@@ -467,7 +588,11 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
|
||||
{/* Status + League */}
|
||||
<HStack gap={2} flexShrink={0} ml={3}>
|
||||
{leagueLabel && (
|
||||
<Text fontSize="2xs" color="fg.muted" display={{ base: "none", md: "block" }}>
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
color="fg.muted"
|
||||
display={{ base: "none", md: "block" }}
|
||||
>
|
||||
{leagueLabel}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -69,7 +69,13 @@ export default function TeamsContent() {
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : 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 color="fg.muted" fontSize="lg">
|
||||
Aramak istediğiniz takımın adını yazın
|
||||
@@ -117,7 +123,11 @@ export default function TeamsContent() {
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="xl" fontWeight="bold" color="primary.fg">
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontWeight="bold"
|
||||
color="primary.fg"
|
||||
>
|
||||
{team.name?.charAt(0) || "T"}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon, IconButton, Presence } from '@chakra-ui/react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, Presence } from "@chakra-ui/react";
|
||||
import { FiChevronUp } from "react-icons/fi";
|
||||
|
||||
const BackToTop = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -12,14 +12,14 @@ const BackToTop = () => {
|
||||
setIsVisible(window.pageYOffset > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,19 +27,19 @@ const BackToTop = () => {
|
||||
<Presence
|
||||
unmountOnExit
|
||||
present={isVisible}
|
||||
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
|
||||
animationDuration='moderate'
|
||||
animationName={{ _open: "fade-in", _closed: "fade-out" }}
|
||||
animationDuration="moderate"
|
||||
>
|
||||
<IconButton
|
||||
variant={{ base: 'solid', _dark: 'subtle' }}
|
||||
aria-label='Back to top'
|
||||
position='fixed'
|
||||
bottom='8'
|
||||
right='8'
|
||||
borderRadius='full'
|
||||
size='lg'
|
||||
shadow='lg'
|
||||
zIndex='999'
|
||||
variant={{ base: "solid", _dark: "subtle" }}
|
||||
aria-label="Back to top"
|
||||
position="fixed"
|
||||
bottom="8"
|
||||
right="8"
|
||||
borderRadius="full"
|
||||
size="lg"
|
||||
shadow="lg"
|
||||
zIndex="999"
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<Icon>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
||||
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react";
|
||||
import {
|
||||
AbsoluteCenter,
|
||||
Button as ChakraButton,
|
||||
Span,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import * as React from "react";
|
||||
|
||||
interface ButtonLoadingProps {
|
||||
loading?: boolean;
|
||||
@@ -9,25 +14,27 @@ interface ButtonLoadingProps {
|
||||
|
||||
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
||||
const { loading, disabled, loadingText, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||
{loading && !loadingText ? (
|
||||
<>
|
||||
<AbsoluteCenter display='inline-flex'>
|
||||
<Spinner size='inherit' color='inherit' />
|
||||
</AbsoluteCenter>
|
||||
<Span opacity={0}>{children}</Span>
|
||||
</>
|
||||
) : loading && loadingText ? (
|
||||
<>
|
||||
<Spinner size='inherit' color='inherit' />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ChakraButton>
|
||||
);
|
||||
});
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(props, ref) {
|
||||
const { loading, disabled, loadingText, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||
{loading && !loadingText ? (
|
||||
<>
|
||||
<AbsoluteCenter display="inline-flex">
|
||||
<Spinner size="inherit" color="inherit" />
|
||||
</AbsoluteCenter>
|
||||
<Span opacity={0}>{children}</Span>
|
||||
</>
|
||||
) : loading && loadingText ? (
|
||||
<>
|
||||
<Spinner size="inherit" color="inherit" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ChakraButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuX } from 'react-icons/lu';
|
||||
import type { ButtonProps } from "@chakra-ui/react";
|
||||
import { IconButton as ChakraIconButton } from "@chakra-ui/react";
|
||||
import * as React from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
|
||||
export type CloseButtonProps = ButtonProps;
|
||||
|
||||
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
|
||||
export const CloseButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
CloseButtonProps
|
||||
>(function CloseButton(props, ref) {
|
||||
return (
|
||||
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
|
||||
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
|
||||
{props.children ?? <LuX />}
|
||||
</ChakraIconButton>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
|
||||
import { createRecipeContext } from '@chakra-ui/react';
|
||||
import type { HTMLChakraProps, RecipeProps } 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
|
||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
|
||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a");
|
||||
|
||||
@@ -1,44 +1,57 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import type { ButtonProps } from "@chakra-ui/react";
|
||||
import {
|
||||
Button,
|
||||
Toggle as ChakraToggle,
|
||||
useToggleContext,
|
||||
} from "@chakra-ui/react";
|
||||
import * as React from "react";
|
||||
|
||||
interface ToggleProps extends ChakraToggle.RootProps {
|
||||
variant?: keyof typeof variantMap;
|
||||
size?: ButtonProps['size'];
|
||||
size?: ButtonProps["size"];
|
||||
}
|
||||
|
||||
const variantMap = {
|
||||
solid: { on: 'solid', off: 'outline' },
|
||||
surface: { on: 'surface', off: 'outline' },
|
||||
subtle: { on: 'subtle', off: 'ghost' },
|
||||
ghost: { on: 'subtle', off: 'ghost' },
|
||||
solid: { on: "solid", off: "outline" },
|
||||
surface: { on: "surface", off: "outline" },
|
||||
subtle: { on: "subtle", off: "ghost" },
|
||||
ghost: { on: "subtle", off: "ghost" },
|
||||
} as const;
|
||||
|
||||
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
|
||||
const { variant = 'subtle', size, children, ...rest } = props;
|
||||
const variantConfig = variantMap[variant];
|
||||
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
function Toggle(props, ref) {
|
||||
const { variant = "subtle", size, children, ...rest } = props;
|
||||
const variantConfig = variantMap[variant];
|
||||
|
||||
return (
|
||||
<ChakraToggle.Root asChild {...rest}>
|
||||
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
|
||||
{children}
|
||||
</ToggleBaseButton>
|
||||
</ChakraToggle.Root>
|
||||
);
|
||||
});
|
||||
|
||||
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
variant: Record<'on' | 'off', ButtonProps['variant']>;
|
||||
}
|
||||
|
||||
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
|
||||
function ToggleBaseButton(props, ref) {
|
||||
const toggle = useToggleContext();
|
||||
const { variant, ...rest } = props;
|
||||
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
|
||||
return (
|
||||
<ChakraToggle.Root asChild {...rest}>
|
||||
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
|
||||
{children}
|
||||
</ToggleBaseButton>
|
||||
</ChakraToggle.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface ToggleBaseButtonProps extends Omit<ButtonProps, "variant"> {
|
||||
variant: Record<"on" | "off", ButtonProps["variant"]>;
|
||||
}
|
||||
|
||||
const ToggleBaseButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ToggleBaseButtonProps
|
||||
>(function ToggleBaseButton(props, ref) {
|
||||
const toggle = useToggleContext();
|
||||
const { variant, ...rest } = props;
|
||||
return (
|
||||
<Button
|
||||
variant={toggle.pressed ? variant.on : variant.off}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToggleIndicator = ChakraToggle.Indicator;
|
||||
|
||||
@@ -1,89 +1,107 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
import { Combobox as ChakraCombobox, Portal } from "@chakra-ui/react";
|
||||
import { CloseButton } from "@/components/ui/buttons/close-button";
|
||||
import * as React from "react";
|
||||
|
||||
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
|
||||
function ComboboxControl(props, ref) {
|
||||
const { children, clearable, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Control {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.IndicatorGroup>
|
||||
{clearable && <ComboboxClearTrigger />}
|
||||
<ChakraCombobox.Trigger />
|
||||
</ChakraCombobox.IndicatorGroup>
|
||||
</ChakraCombobox.Control>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const ComboboxControl = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ComboboxControlProps
|
||||
>(function ComboboxControl(props, ref) {
|
||||
const { children, clearable, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Control {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.IndicatorGroup>
|
||||
{clearable && <ComboboxClearTrigger />}
|
||||
<ChakraCombobox.Trigger />
|
||||
</ChakraCombobox.IndicatorGroup>
|
||||
</ChakraCombobox.Control>
|
||||
);
|
||||
});
|
||||
|
||||
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
|
||||
function ComboboxClearTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
||||
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||
</ChakraCombobox.ClearTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
const ComboboxClearTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraCombobox.ClearTriggerProps
|
||||
>(function ComboboxClearTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
||||
<CloseButton
|
||||
size="xs"
|
||||
variant="plain"
|
||||
focusVisibleRing="inside"
|
||||
focusRingWidth="2px"
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
</ChakraCombobox.ClearTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
|
||||
function ComboboxContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraCombobox.Positioner>
|
||||
<ChakraCombobox.Content {...rest} ref={ref} />
|
||||
</ChakraCombobox.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const ComboboxContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ComboboxContentProps
|
||||
>(function ComboboxContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraCombobox.Positioner>
|
||||
<ChakraCombobox.Content {...rest} ref={ref} />
|
||||
</ChakraCombobox.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
|
||||
function ComboboxItem(props, ref) {
|
||||
const { item, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.ItemIndicator />
|
||||
</ChakraCombobox.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const ComboboxItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraCombobox.ItemProps
|
||||
>(function ComboboxItem(props, ref) {
|
||||
const { item, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.ItemIndicator />
|
||||
</ChakraCombobox.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
|
||||
function ComboboxRoot(props, ref) {
|
||||
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
|
||||
},
|
||||
) as ChakraCombobox.RootComponent;
|
||||
export const ComboboxRoot = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChakraCombobox.RootProps
|
||||
>(function ComboboxRoot(props, ref) {
|
||||
return (
|
||||
<ChakraCombobox.Root
|
||||
{...props}
|
||||
ref={ref}
|
||||
positioning={{ sameWidth: true, ...props.positioning }}
|
||||
/>
|
||||
);
|
||||
}) as ChakraCombobox.RootComponent;
|
||||
|
||||
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
|
||||
function ComboboxItemGroup(props, ref) {
|
||||
const { children, label, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
||||
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
|
||||
{children}
|
||||
</ChakraCombobox.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const ComboboxItemGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ComboboxItemGroupProps
|
||||
>(function ComboboxItemGroup(props, ref) {
|
||||
const { children, label, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
||||
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
|
||||
{children}
|
||||
</ChakraCombobox.ItemGroup>
|
||||
);
|
||||
});
|
||||
|
||||
export const ComboboxLabel = ChakraCombobox.Label;
|
||||
export const ComboboxInput = ChakraCombobox.Input;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user