18 Commits

Author SHA1 Message Date
fahricansecer 5df5145104 gg
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m38s
2026-05-20 21:59:52 +03:00
fahricansecer fc369db123 Update header.tsx
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m21s
2026-05-20 10:57:18 +03:00
fahricansecer a70848164e gg
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m19s
2026-05-18 00:08:59 +03:00
fahricansecer 71a6ed320c feat: AI commentary skeleton loading - separate async endpoint
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m20s
2026-05-17 16:46:53 +03:00
fahricansecer e744a62fc2 gg
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m24s
2026-05-17 02:19:55 +03:00
fahricansecer 66877b88ca main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m23s
2026-05-12 17:41:16 +03:00
fahricansecer b2ccc98226 gg
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m41s
2026-05-12 03:03:49 +03:00
fahricansecer e6e58b4433 gg2
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m25s
2026-05-10 23:38:04 +03:00
fahricansecer c47f128958 gg
Deploy Iddaai Frontend / build-and-deploy (push) Failing after 5s
2026-05-10 23:12:40 +03:00
fahricansecer 5c8619b282 gg
Deploy Iddaai Frontend / build-and-deploy (push) Failing after 34s
2026-05-10 22:59:27 +03:00
fahricansecer 6dadc5f613 gg
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 4m15s
2026-05-06 17:50:02 +03:00
fahricansecer 4df27e3e6d main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 3m36s
2026-05-05 14:06:01 +03:00
fahricansecer e3cc6702dd main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 3m37s
2026-05-05 01:04:50 +03:00
fahricansecer f72857a3b2 main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 3m34s
2026-05-04 19:21:48 +03:00
fahricansecer 0d194f7409 main
Deploy Iddaai Frontend / build-and-deploy (push) Failing after 41s
2026-05-04 18:01:01 +03:00
fahricansecer ab5864df2f Merge branch 'main' into v26-shadow
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 4m15s
2026-04-24 23:47:07 +03:00
fahricansecer bff5ea7b5f Update .gitignore 2026-04-24 23:47:04 +03:00
fahricansecer 14159911f0 v28 2026-04-24 23:46:50 +03:00
193 changed files with 12882 additions and 4687 deletions
Vendored
BIN
View File
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 6195
}
]
}
+24
View File
@@ -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='...'
+21 -6
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
node_modules
.next
.env.local
certificates/
+34 -12
View File
@@ -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"]
-28
View File
@@ -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-----
-26
View File
@@ -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
View File
@@ -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
View File
@@ -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 `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;` react/no-unescaped-entities
715:44 error `"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;` 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)
+307 -30
View File
@@ -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"
}
+319 -30
View File
@@ -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ı"
}
+1 -1
View File
@@ -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
View File
@@ -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*`,
},
];
},
+188 -180
View File
@@ -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
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

+5 -5
View File
@@ -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>
);
+23 -224
View File
@@ -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",
return {
title: t("signin.title"),
description: t("signin.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
};
function SignInPage() {
const t = useTranslations();
const router = useRouter();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignInForm>({
resolver: yupResolver(schema),
mode: "onChange",
defaultValues,
});
const onSubmit = async (formData: SignInForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
toaster.error({
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally {
setLoading(false);
export default function SignInPage() {
return <SignInForm />;
}
};
return (
<Box position="relative">
<Flex
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
w="100%"
maxW="1044px"
mx="auto"
justifyContent="space-between"
mb="30px"
pt={{ sm: "100px", md: "0px" }}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<Flex
direction="column"
w="100%"
background="transparent"
p="10"
mt={{ md: "150px", lg: "80px" }}
>
<Heading
color={{ base: "primary.400", _dark: "primary.200" }}
fontSize="32px"
mb="10px"
fontWeight="bold"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<Switch colorPalette="teal" label={t("auth.remember-me")}>
{t("auth.remember-me")}
</Switch>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.dont-have-account")}
<ChakraLink
as={Link}
href="/signup"
color={{ base: "primary.400", _dark: "primary.200" }}
ms="5px"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-up")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
<Box
display={{ base: "none", md: "block" }}
overflowX="hidden"
h="100%"
w="40vw"
position="absolute"
right="0px"
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
);
}
export default SignInPage;
@@ -0,0 +1,229 @@
"use client";
import {
Box,
Flex,
Heading,
Input,
Link as ChakraLink,
Text,
ClientOnly,
} from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Switch } from "@/components/ui/forms/switch";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import signInImage from "../../../../../public/assets/img/sign-in-image.png";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock } from "react-icons/bi";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useRouter } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().required(),
});
type SignInForm = yup.InferType<typeof schema>;
const defaultValues = {
email: "test@test.com.tr",
password: "test1234",
};
export default function SignInForm() {
const t = useTranslations();
const router = useRouter();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignInForm>({
resolver: yupResolver(schema),
mode: "onChange",
defaultValues,
});
const onSubmit = async (formData: SignInForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
toaster.error({
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally {
setLoading(false);
}
};
return (
<Box position="relative">
<Flex
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
w="100%"
maxW="1044px"
mx="auto"
justifyContent="space-between"
mb="30px"
pt={{ sm: "100px", md: "0px" }}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<Flex
direction="column"
w="100%"
background="transparent"
p="10"
mt={{ md: "150px", lg: "80px" }}
>
<Heading
color={{ base: "primary.400", _dark: "primary.200" }}
fontSize="32px"
mb="10px"
fontWeight="bold"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<Switch colorPalette="teal" label={t("auth.remember-me")}>
{t("auth.remember-me")}
</Switch>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.dont-have-account")}
<ChakraLink
as={Link}
href="/signup"
color={{ base: "primary.400", _dark: "primary.200" }}
ms="5px"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-up")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
<Box
display={{ base: "none", md: "block" }}
overflowX="hidden"
h="100%"
w="40vw"
position="absolute"
right="0px"
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
);
}
+24 -232
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { notFound } from 'next/navigation';
import { notFound } from "next/navigation";
export default function CatchAllPage() {
notFound();
+30 -1
View File
@@ -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>;
+24 -5
View File
@@ -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}`,
},
},
};
}
+24 -4
View File
@@ -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}`,
},
},
};
}
+23 -5
View File
@@ -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}`,
},
},
};
}
+25 -4
View File
@@ -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}`,
},
},
};
}
+24 -5
View File
@@ -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}`,
},
},
};
}
+7 -7
View File
@@ -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} />;
}
+24 -4
View File
@@ -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}`,
},
},
};
}
+23 -3
View File
@@ -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}`,
},
},
};
}
+26 -5
View File
@@ -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}`,
},
},
};
}
+24 -5
View File
@@ -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}`,
},
},
};
}
+29
View File
@@ -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} />;
}
+25 -5
View File
@@ -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} />;
}
+24 -5
View File
@@ -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}`,
},
},
};
}
+25 -3
View File
@@ -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}`,
},
},
};
}
+25 -4
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
/*
Suggest-Bet Global CSS
iddaai Global CSS
Premium animations, gradients, and utility keyframes
*/
+64 -1
View File
@@ -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>
+23 -14
View File
@@ -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>
+2 -2
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+13
View File
@@ -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`,
};
}
+35
View File
@@ -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;
}
+262 -17
View File
@@ -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,13 +282,73 @@ export default function AdminContent() {
))}
{/* Users Tab */}
{activeTab === "users" &&
(usersLoading ? (
{activeTab === "users" && (
<VStack gap={4} align="stretch">
{/* Filters */}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body py={4}>
<SimpleGrid columns={{ base: 1, md: 3 }} gap={4}>
<Input
placeholder={t("search-users-placeholder")}
value={searchParams.search}
onChange={(e) =>
setSearchParams({
...searchParams,
search: e.target.value,
})
}
/>
<NativeSelectRoot>
<NativeSelectField
placeholder={t("all-roles")}
value={searchParams.role}
onChange={(e) =>
setSearchParams({
...searchParams,
role: e.target.value,
page: 1,
})
}
items={[
{ label: t("standard-user"), value: "user" },
{ label: t("superadmin"), value: "superadmin" },
]}
/>
</NativeSelectRoot>
<NativeSelectRoot>
<NativeSelectField
placeholder={t("all-plans")}
value={searchParams.subscriptionStatus}
onChange={(e) =>
setSearchParams({
...searchParams,
subscriptionStatus: e.target.value,
page: 1,
})
}
items={[
{ label: t("plan-free"), value: "free" },
{ label: "Plus", value: "plus" },
{ label: "Premium", value: "premium" },
{ label: t("plan-past-due"), value: "past_due" },
{ label: t("plan-cancelled"), value: "cancelled" },
]}
/>
</NativeSelectRoot>
</SimpleGrid>
</Card.Body>
</Card.Root>
{usersLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : users.length > 0 ? (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body>
<VStack gap={0} align="stretch">
{/* Table Header */}
@@ -253,9 +367,13 @@ export default function AdminContent() {
<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 */}
@@ -277,12 +395,19 @@ export default function AdminContent() {
>
{getUserDisplayName(user)}
</Text>
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
<Text
flex={2}
fontSize="sm"
color="fg.muted"
truncate
>
{user.email}
</Text>
<Flex flex={1} justify="center">
<Badge
colorPalette={isAdminRole([user.role]) ? "red" : "gray"}
colorPalette={
isAdminRole([user.role]) ? "red" : "gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
@@ -290,6 +415,61 @@ export default function AdminContent() {
{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"}
@@ -302,9 +482,63 @@ export default function AdminContent() {
: 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>
@@ -312,7 +546,18 @@ export default function AdminContent() {
<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>
);
+207
View File
@@ -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&apos;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>
);
}
+1 -6
View File
@@ -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) => {
+6 -2
View File
@@ -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>
@@ -819,7 +840,9 @@ export default function CouponBuilderContent() {
key={entry.key}
p={3}
borderWidth="1px"
borderColor={active ? `${palette}.400` : borderColor}
borderColor={
active ? `${palette}.400` : borderColor
}
bg={active ? `${palette}.50` : mutedBg}
borderRadius="xl"
cursor="pointer"
@@ -832,7 +855,9 @@ export default function CouponBuilderContent() {
>
{entry.label}
</Badge>
{active ? <LuCheck color="currentColor" /> : null}
{active ? (
<LuCheck color="currentColor" />
) : null}
</HStack>
<Text fontSize="sm" color="fg.muted">
{entry.description}
@@ -866,7 +891,9 @@ export default function CouponBuilderContent() {
min="2"
max="15"
value={matchCount}
onChange={(e) => setMatchCount(Number(e.target.value))}
onChange={(e) =>
setMatchCount(Number(e.target.value))
}
style={{
width: "100%",
accentColor: "teal",
@@ -880,7 +907,9 @@ export default function CouponBuilderContent() {
>
<Text>2</Text>
<Text>
{t("match-count-auto", { count: allMatches.length })}
{t("match-count-auto", {
count: allMatches.length,
})}
</Text>
<Text>15</Text>
</HStack>
+19 -4
View File
@@ -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) => (
+25 -7
View File
@@ -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,7 +342,9 @@ export default function DashboardContent() {
</VStack>
) : upcomingPredictions.length > 0 ? (
<VStack gap={2} align="stretch">
{upcomingPredictions.slice(0, 4).map((pred: MatchPredictionDto, idx: number) => (
{upcomingPredictions
.slice(0, 4)
.map((pred: MatchPredictionDto, idx: number) => (
<Box
key={idx}
p={2.5}
@@ -396,7 +412,9 @@ export default function DashboardContent() {
</VStack>
) : valueBets.length > 0 ? (
<VStack gap={2} align="stretch">
{valueBets.slice(0, 5).map((vb: ValueBetDto, idx: number) => (
{valueBets
.slice(0, 5)
.map((vb: ValueBetDto, idx: number) => (
<ValueBetMiniCard
key={idx}
matchName={vb.matchName}
+4 -4
View File
@@ -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",
},
+12 -2
View File
@@ -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
+15 -1
View File
@@ -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>
+86 -29
View File
@@ -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,6 +87,24 @@ export default function Header() {
if (isAuthenticated) {
return (
<HStack gap={2}>
{usageLimit && (
<Badge
size="sm"
colorPalette={
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
? "green"
: "red"
}
variant="subtle"
display={{ base: "none", sm: "inline-flex" }}
>
<LuZap style={{ marginRight: "4px" }} />
{usageLimit.maxAnalyses - usageLimit.analysisCount}{" "}
{t("common.limits.analysis_left", { defaultValue: "Analiz" })}
</Badge>
)}
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
<MenuRoot positioning={{ placement: "bottom-start" }}>
<MenuTrigger rounded="full" focusRing="none">
<Avatar name={session?.user?.name || "User"} variant="solid" />
@@ -88,6 +114,15 @@ export default function Header() {
<LuUser />
{t("nav.profile")}
</MenuItem>
{(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 />
@@ -99,6 +134,7 @@ export default function Header() {
</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>
);
}
+661 -172
View File
@@ -11,7 +11,9 @@ import {
Badge,
Spinner,
Input,
Tabs,
Grid,
GridItem,
Icon,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
@@ -22,8 +24,15 @@ import {
useSearchTeams,
} from "@/lib/api/leagues/use-hooks";
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
import { LuSearch, LuGlobe, LuTrophy, LuUsers } from "react-icons/lu";
import { useState } from "react";
import {
LuSearch,
LuGlobe,
LuTrophy,
LuUsers,
LuArrowRight,
LuMapPin,
} from "react-icons/lu";
import { useMemo, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { Link } from "@/i18n/navigation";
import { InputGroup } from "@/components/ui/forms/input-group";
@@ -33,13 +42,26 @@ export default function LeaguesContent() {
const t = useTranslations("leagues");
const tMatches = useTranslations("matches");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const bgGradient = useColorModeValue(
"linear(to-r, primary.500, primary.700)",
"linear(to-r, primary.600, primary.900)",
);
const cardBg = useColorModeValue("white", "gray.900");
const borderColor = useColorModeValue("gray.200", "gray.800");
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.50");
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
const [sportFilter, setSportFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebounce(searchQuery, 300);
const [selectedCountryId, setSelectedCountryId] = useState<string | null>(
null,
);
const [teamSearchQuery, setTeamSearchQuery] = useState("");
const debouncedTeamQuery = useDebounce(teamSearchQuery, 300);
const [countrySearchQuery, setCountrySearchQuery] = useState("");
const debouncedCountryQuery = useDebounce(countrySearchQuery, 300);
const countries = useCountries();
const leagues = useLeagues(
@@ -48,182 +70,519 @@ export default function LeaguesContent() {
: undefined,
);
const searchTeams = useSearchTeams(
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" },
debouncedTeamQuery.length >= 2 ? { q: debouncedTeamQuery } : { q: "" },
);
const filteredCountries = useMemo(() => {
if (!countries.data?.data) return [];
if (!debouncedCountryQuery) return countries.data.data;
return countries.data.data.filter((c) =>
c.name.toLowerCase().includes(debouncedCountryQuery.toLowerCase()),
);
}, [countries.data?.data, debouncedCountryQuery]);
const displayedLeagues = useMemo(() => {
let sourceLeagues: LeagueDto[] = leagues.data?.data || [];
if (selectedCountryId) {
sourceLeagues = sourceLeagues.filter(
(l) => l.countryId === selectedCountryId,
);
}
// Apply sport filter if selected
if (sportFilter) {
return sourceLeagues.filter((l) => l.sport === sportFilter);
}
return sourceLeagues;
}, [selectedCountryId, leagues.data?.data, sportFilter]);
return (
<SlideUp>
<Box maxW="6xl" mx="auto">
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
{t("title")}
</Heading>
<Tabs.Root
value={activeTab}
onValueChange={(e) => setActiveTab(e.value as "leagues" | "teams")}
<Box minH="calc(100vh - 80px)">
{/* Hero Section */}
<Box
bgGradient={bgGradient}
color="white"
pt={16}
pb={20}
px={6}
position="relative"
overflow="hidden"
>
<Tabs.List>
<Tabs.Trigger value="leagues">
<LuGlobe />
{t("countries-leagues")}
</Tabs.Trigger>
<Tabs.Trigger value="teams">
<LuUsers />
{tMatches("search-teams")}
</Tabs.Trigger>
</Tabs.List>
<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="center"
gap={4}
textAlign="center"
maxW="3xl"
mx="auto"
>
<Badge
colorScheme="whiteAlpha"
variant="subtle"
size="lg"
px={4}
py={1}
rounded="full"
>
{t("title")}
</Badge>
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="800"
letterSpacing="tight"
>
{activeTab === "leagues"
? t("countries-leagues")
: tMatches("search-teams")}
</Heading>
<Text fontSize="lg" color="whiteAlpha.800" maxW="xl">
{activeTab === "leagues"
? "Explore top football and basketball leagues around the world. Filter by country and analyze historical matches."
: "Find your favorite teams across all leagues. Get deep insights and head-to-head statistics."}
</Text>
</VStack>
</SlideUp>
</Box>
</Box>
{/* Countries & Leagues Tab */}
<Tabs.Content value="leagues">
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
{/* Countries Sidebar */}
<Box w={{ base: "full", lg: "280px" }} flexShrink={0}>
{/* Main Content Area - Pulled up to overlap hero */}
<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] }}
>
<Card.Root
bg={cardBg}
shadow="xl"
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
overflow="hidden"
>
<Card.Header>
<Heading as="h4" size="sm">
<HStack gap={2}>
{/* Tab Navigation */}
<Flex
borderBottomWidth="1px"
borderColor={borderColor}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
>
<Flex flex={1}>
<Box
flex={1}
py={4}
textAlign="center"
cursor="pointer"
borderBottomWidth="2px"
borderColor={
activeTab === "leagues" ? "primary.500" : "transparent"
}
color={activeTab === "leagues" ? "primary.500" : "fg.muted"}
fontWeight={activeTab === "leagues" ? "bold" : "medium"}
onClick={() => setActiveTab("leagues")}
transition="all 0.2s"
_hover={{ bg: hoverBg }}
>
<HStack justify="center" gap={2}>
<LuGlobe />
<Text>{t("countries")}</Text>
<Text>{t("countries-leagues")}</Text>
</HStack>
</Heading>
</Card.Header>
<Card.Body pt={0} maxH="600px" overflowY="auto">
</Box>
<Box
flex={1}
py={4}
textAlign="center"
cursor="pointer"
borderBottomWidth="2px"
borderColor={
activeTab === "teams" ? "primary.500" : "transparent"
}
color={activeTab === "teams" ? "primary.500" : "fg.muted"}
fontWeight={activeTab === "teams" ? "bold" : "medium"}
onClick={() => setActiveTab("teams")}
transition="all 0.2s"
_hover={{ bg: hoverBg }}
>
<HStack justify="center" gap={2}>
<LuUsers />
<Text>{tMatches("search-teams")}</Text>
</HStack>
</Box>
</Flex>
</Flex>
{/* LEAGUES TAB */}
{activeTab === "leagues" && (
<Flex direction={{ base: "column", lg: "row" }} minH="600px">
{/* Left Sidebar: Countries */}
<Box
w={{ base: "full", lg: "320px" }}
borderRightWidth={{ lg: "1px" }}
borderColor={borderColor}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
>
<VStack align="stretch" h="full" gap={0}>
<Box
p={4}
borderBottomWidth="1px"
borderColor={borderColor}
bg={cardBg}
>
<InputGroup
startElement={<LuSearch color="gray.400" />}
w="full"
>
<Input
placeholder={t("countries") + "..."}
variant="subtle"
borderRadius="full"
value={countrySearchQuery}
onChange={(e) =>
setCountrySearchQuery(e.target.value)
}
/>
</InputGroup>
</Box>
<Box
flex={1}
overflowY="auto"
maxH={{ base: "300px", lg: "600px" }}
p={2}
>
{countries.isLoading ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
<Flex justify="center" py={10}>
<Spinner color="primary.500" />
</Flex>
) : (
<VStack gap={1} align="stretch">
{countries.data?.data?.map((country: CountryDto) => (
<Flex
key={country.id}
px={3}
py={2}
borderRadius="md"
_hover={{
bg: "gray.50",
_dark: { bg: "gray.750" },
}}
<Box
px={4}
py={3}
borderRadius="lg"
cursor="pointer"
justify="space-between"
align="center"
bg={
selectedCountryId === null
? "primary.500"
: "transparent"
}
color={selectedCountryId === null ? "white" : "fg"}
_hover={{
bg:
selectedCountryId === null
? "primary.600"
: hoverBg,
}}
onClick={() => setSelectedCountryId(null)}
transition="all 0.2s"
>
<HStack gap={2}>
<HStack justify="space-between">
<HStack gap={3}>
<LuGlobe />
<Text
fontWeight={
selectedCountryId === null
? "bold"
: "medium"
}
>
{t("all")}
</Text>
</HStack>
<Badge
size="sm"
bg={
selectedCountryId === null
? "whiteAlpha.300"
: "gray.100"
}
color={
selectedCountryId === null ? "white" : "fg"
}
>
{leagues.data?.data?.length || 0}
</Badge>
</HStack>
</Box>
{filteredCountries.map((country: CountryDto) => {
const isSelected = selectedCountryId === country.id;
return (
<Box
key={country.id}
px={4}
py={3}
borderRadius="lg"
cursor="pointer"
bg={isSelected ? "primary.500" : "transparent"}
color={isSelected ? "white" : "fg"}
_hover={{
bg: isSelected ? "primary.600" : hoverBg,
}}
onClick={() => setSelectedCountryId(country.id)}
transition="all 0.2s"
>
<HStack justify="space-between">
<HStack gap={3}>
{country.flag ? (
<img
src={country.flag}
width="16"
height="16"
style={{ borderRadius: "2px" }}
width="20"
height="20"
style={{
borderRadius: "50%",
objectFit: "cover",
}}
alt={country.name}
/>
) : null}
<Text fontSize="sm">{country.name}</Text>
) : (
<LuMapPin />
)}
<Text
fontWeight={
isSelected ? "bold" : "medium"
}
>
{country.name}
</Text>
</HStack>
<Badge size="xs" colorScheme="gray">
{country.leagues?.length || 0}
<Badge
size="sm"
bg={
isSelected ? "whiteAlpha.300" : "gray.100"
}
color={isSelected ? "white" : "fg"}
>
{leagues.data?.data?.filter(
(l) => l.countryId === country.id,
).length || 0}
</Badge>
</Flex>
))}
</HStack>
</Box>
);
})}
</VStack>
)}
</Card.Body>
</Card.Root>
</Box>
</VStack>
</Box>
{/* Leagues List */}
<Box flex={1}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
{/* Right Area: Leagues Grid */}
<Box flex={1} p={{ base: 4, md: 8 }} bg={cardBg}>
{/* Top Filters */}
<Flex
justify="space-between"
align="center"
mb={6}
direction={{ base: "column", sm: "row" }}
gap={4}
>
<Card.Header>
<Flex justify="space-between" align="center">
<Heading as="h4" size="sm">
<HStack gap={2}>
<LuTrophy />
<Text>{t("leagues")}</Text>
</HStack>
<Heading size="md" fontWeight="bold">
{selectedCountryId
? `${countries.data?.data?.find((c) => c.id === selectedCountryId)?.name} ${t("leagues")}`
: t("leagues")}
<Text
as="span"
color="fg.muted"
ml={2}
fontWeight="normal"
fontSize="sm"
>
({displayedLeagues.length})
</Text>
</Heading>
<HStack gap={2}>
<Badge
cursor="pointer"
colorScheme={!sportFilter ? "primary" : "gray"}
onClick={() => setSportFilter("")}
<HStack
gap={2}
bg={useColorModeValue("gray.100", "gray.800")}
p={1}
borderRadius="full"
>
{tMatches("all")}
</Badge>
<Badge
<Box
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
colorScheme={
sportFilter === "football" ? "green" : "gray"
fontSize="sm"
fontWeight="medium"
bg={!sportFilter ? "white" : "transparent"}
color={!sportFilter ? "black" : "fg.muted"}
shadow={!sportFilter ? "sm" : "none"}
onClick={() => setSportFilter("")}
transition="all 0.2s"
_dark={{
bg: !sportFilter ? "gray.600" : "transparent",
color: !sportFilter ? "white" : "gray.400",
}}
>
{t("all")}
</Box>
<Box
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
fontSize="sm"
fontWeight="medium"
bg={
sportFilter === "football"
? "green.500"
: "transparent"
}
color={
sportFilter === "football" ? "white" : "fg.muted"
}
shadow={sportFilter === "football" ? "sm" : "none"}
onClick={() =>
setSportFilter(
sportFilter === "football" ? "" : "football",
)
}
transition="all 0.2s"
>
{tMatches("football")}
</Badge>
<Badge
</Box>
<Box
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
colorScheme={
sportFilter === "basketball" ? "orange" : "gray"
fontSize="sm"
fontWeight="medium"
bg={
sportFilter === "basketball"
? "orange.500"
: "transparent"
}
color={
sportFilter === "basketball" ? "white" : "fg.muted"
}
shadow={sportFilter === "basketball" ? "sm" : "none"}
onClick={() =>
setSportFilter(
sportFilter === "basketball" ? "" : "basketball",
)
}
transition="all 0.2s"
>
{tMatches("basketball")}
</Badge>
</Box>
</HStack>
</Flex>
</Card.Header>
<Card.Body pt={0}>
{/* Leagues Grid */}
{leagues.isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="sm" />
<Flex justify="center" py={20}>
<Spinner
size="xl"
color="primary.500"
borderWidth="3px"
/>
</Flex>
) : displayedLeagues.length === 0 ? (
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Box
bg="gray.100"
_dark={{ bg: "gray.800" }}
p={6}
borderRadius="full"
mb={4}
>
<LuTrophy size={40} color="gray" />
</Box>
<Heading size="md" mb={2}>
Bulunamadı
</Heading>
<Text color="fg.muted">
Seçili kriterlere uygun lig bulunamadı.
</Text>
</Flex>
) : (
<VStack gap={2}>
{leagues.data?.data?.map((league: LeagueDto) => (
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
xl: "repeat(3, 1fr)",
}}
gap={4}
>
{displayedLeagues.map((league: LeagueDto) => (
<GridItem key={league.id}>
<ChakraLink
key={league.id}
as={Link}
href="/matches"
p={3}
borderRadius="md"
href={`/leagues/${league.id}`}
display="block"
h="full"
p={5}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
bg={cardBg}
_hover={{
borderColor: "primary.300",
bg: "primary.50",
_dark: { bg: "gray.750" },
shadow: "md",
transform: "translateY(-2px)",
}}
display="flex"
justifyContent="space-between"
alignItems="center"
transition="all 0.2s"
textDecoration="none"
color="inherit"
data-group
>
<VStack align="start" gap={0}>
<Text fontWeight="semibold">{league.name}</Text>
<Text fontSize="xs" color="fg.muted">
{league.country?.name || ""}
</Text>
</VStack>
<HStack gap={2}>
{league.sport ? (
<Flex
justify="space-between"
align="flex-start"
mb={4}
>
<Box
p={2}
borderRadius="lg"
bg={
league.sport === "football"
? "green.50"
: "orange.50"
}
_dark={{
bg:
league.sport === "football"
? "green.900"
: "orange.900",
}}
>
<LuTrophy
size={20}
color={
league.sport === "football"
? "var(--chakra-colors-green-500)"
: "var(--chakra-colors-orange-500)"
}
/>
</Box>
<Badge
size="xs"
size="sm"
variant="subtle"
colorScheme={
league.sport === "football"
? "green"
@@ -232,104 +591,234 @@ export default function LeaguesContent() {
>
{league.sport}
</Badge>
) : null}
{league.season ? (
<Text fontSize="xs" color="fg.muted">
{league.season}
</Flex>
<Heading
size="sm"
mb={1}
lineClamp={1}
_groupHover={{ color: "primary.500" }}
>
{league.name}
</Heading>
<HStack color="fg.muted" fontSize="sm" gap={1}>
<LuMapPin size={14} />
<Text lineClamp={1}>
{league.country?.name || "Global"}
</Text>
) : null}
</HStack>
</ChakraLink>
))}
</VStack>
{league.season && (
<Flex
mt={4}
pt={4}
borderTopWidth="1px"
borderColor={borderColor}
justify="space-between"
align="center"
>
<Text
fontSize="xs"
color="fg.muted"
fontWeight="medium"
>
SEZON: {league.season}
</Text>
<Icon
as={LuArrowRight}
color="gray.400"
_groupHover={{
color: "primary.500",
transform: "translateX(4px)",
}}
transition="all 0.2s"
/>
</Flex>
)}
</ChakraLink>
</GridItem>
))}
</Grid>
)}
</Card.Body>
</Card.Root>
</Box>
</Flex>
</Tabs.Content>
)}
{/* Teams Search Tab */}
<Tabs.Content value="teams">
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<InputGroup startElement={<LuSearch />} mb={4}>
{/* TEAMS TAB */}
{activeTab === "teams" && (
<Box p={{ base: 4, md: 8 }}>
<Box maxW="2xl" mx="auto" mb={10}>
<InputGroup
startElement={<LuSearch color="gray.400" size={20} />}
w="full"
>
<Input
placeholder={tMatches("search-teams")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={tMatches("search-teams") + "..."}
value={teamSearchQuery}
onChange={(e) => setTeamSearchQuery(e.target.value)}
variant="outline"
borderRadius="xl"
fontSize="lg"
py={6}
boxShadow="sm"
_focus={{
boxShadow: "0 0 0 2px var(--chakra-colors-primary-500)",
}}
/>
</InputGroup>
{debouncedQuery.length < 2 ? (
<Text color="fg.muted" textAlign="center" py={8}>
</Box>
{debouncedTeamQuery.length < 2 ? (
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Box
bg="primary.50"
_dark={{ bg: "primary.900" }}
p={8}
borderRadius="full"
mb={6}
>
<LuUsers
size={64}
color="var(--chakra-colors-primary-500)"
/>
</Box>
<Heading size="lg" mb={3}>
{t("search-at-least-2")}
</Heading>
<Text color="fg.muted" maxW="md">
Find detailed statistics, upcoming matches, and
head-to-head analysis by searching for any team worldwide.
</Text>
</Flex>
) : searchTeams.isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="md" />
<Flex justify="center" py={20}>
<Spinner size="xl" color="primary.500" borderWidth="3px" />
</Flex>
) : searchTeams.data?.data?.length === 0 ? (
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Heading size="md" mb={2}>
Takım Bulunamadı
</Heading>
<Text color="fg.muted">
"{debouncedTeamQuery}" aramasıyla eşleşen bir takım
bulunamadı.
</Text>
</Flex>
) : (
<VStack gap={2}>
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
xl: "repeat(3, 1fr)",
}}
gap={4}
>
{searchTeams.data?.data?.map((team: TeamDto) => (
<GridItem key={team.id}>
<ChakraLink
key={team.id}
as={Link}
href={`/teams/${team.id}`}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
_hover={{
borderColor: "primary.300",
bg: "primary.50",
_dark: { bg: "gray.750" },
}}
display="flex"
alignItems="center"
gap={3}
p={4}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
bg={cardBg}
_hover={{
borderColor: "primary.300",
shadow: "md",
transform: "translateY(-2px)",
}}
transition="all 0.2s"
textDecoration="none"
color="inherit"
data-group
>
{team.logo ? (
<Box
w={12}
h={12}
borderRadius="full"
overflow="hidden"
flexShrink={0}
mr={4}
bg="white"
p={1}
shadow="sm"
>
<img
src={team.logo}
width="32"
height="32"
style={{ borderRadius: "50%" }}
width="100%"
height="100%"
style={{ objectFit: "contain" }}
alt={team.name}
/>
</Box>
) : (
<Box
boxSize="32px"
<Flex
w={12}
h={12}
borderRadius="full"
bg="gray.200"
_dark={{ bg: "gray.600" }}
/>
bg="gray.100"
_dark={{ bg: "gray.700" }}
align="center"
justify="center"
flexShrink={0}
mr={4}
>
<LuUsers size={20} color="gray" />
</Flex>
)}
<VStack align="start" gap={0}>
<Text fontWeight="semibold">{team.name}</Text>
<Text fontSize="xs" color="fg.muted">
{team.country || ""}
<VStack align="start" gap={0} flex={1}>
<Heading
size="sm"
lineClamp={1}
_groupHover={{ color: "primary.500" }}
>
{team.name}
</Heading>
<HStack color="fg.muted" fontSize="xs" gap={1}>
<LuMapPin size={12} />
<Text lineClamp={1}>
{team.country || "Global"}
</Text>
</HStack>
</VStack>
<Badge
ml="auto"
size="xs"
ml={2}
size="sm"
colorScheme={
team.sport === "football" ? "green" : "orange"
}
variant="subtle"
>
{team.sport}
</Badge>
</ChakraLink>
</GridItem>
))}
</VStack>
</Grid>
)}
</Card.Body>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Box>
)}
</Card.Root>
</SlideUp>
</Box>
</Box>
);
}
+52
View File
@@ -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>
);
}
+1
View File
@@ -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>
);
}
+36 -3
View File
@@ -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>
)}
{/* 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"
+174 -16
View File
@@ -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,41 +27,118 @@ 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>
{/* ── Info Banner ───────────────────────────── */}
{source !== "confirmed_live" && (
<Flex
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
p={3}
mb={4}
align="center"
gap={2}
>
<Icon as={LuInfo} color="blue.500" flexShrink={0} />
<Text fontSize="xs" color="fg.muted">
{meta.description}
</Text>
</Flex>
)}
{/* ── Lineups Grid ─────────────────────────── */}
{hasLineups ? (
<SimpleGrid columns={{ base: 1, md: 2 }} gap={6}>
{/* Home Team Lineup */}
<Box>
@@ -66,9 +149,14 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
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
@@ -95,6 +183,25 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
</HStack>
))}
</VStack>
) : (
<Flex
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
justify="center"
align="center"
direction="column"
gap={1}
>
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
Kadro henüz belli değil
</Text>
<Text fontSize="xs" color="fg.subtle">
Maç saatine yakın güncellenecek
</Text>
</Flex>
)}
</Box>
{/* Away Team Lineup */}
@@ -106,9 +213,14 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
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
@@ -135,8 +247,54 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
</HStack>
))}
</VStack>
) : (
<Flex
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
justify="center"
align="center"
direction="column"
gap={1}
>
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
Kadro henüz belli değil
</Text>
<Text fontSize="xs" color="fg.subtle">
Maç saatine yakın güncellenecek
</Text>
</Flex>
)}
</Box>
</SimpleGrid>
) : (
/* ── Empty State: No lineups at all ─────── */
<Flex
direction="column"
align="center"
justify="center"
py={8}
gap={3}
>
<Icon as={LuClock} boxSize={8} color="fg.subtle" />
<VStack gap={1}>
<Text fontWeight="semibold" color="fg.muted">
Kadro Henüz Açıklanmadı
</Text>
<Text
fontSize="sm"
color="fg.subtle"
textAlign="center"
maxW="sm"
>
{match.homeTeamName} ve {match.awayTeamName} kadroları maç
saatine yakın güncellenecektir. AI analizi, takım istatistikleri
ve güç dengesi üzerinden yapılmaktadır.
</Text>
</VStack>
</Flex>
)}
</Card.Body>
</Card.Root>
);
+35
View File
@@ -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,6 +59,7 @@ export default function MatchCard({ match }: MatchCardProps) {
const matchDate = new Date(match.mstUtc);
return (
<>
<MotionBox
variants={slideUpVariants}
bg={cardBg}
@@ -223,6 +234,30 @@ export default function MatchCard({ match }: MatchCardProps) {
</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
+32 -4
View File
@@ -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 */}
+132 -22
View File
@@ -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
+44 -15
View File
@@ -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} />}
+79 -14
View File
@@ -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" }}>
{Array.from({ length: count }).map((_, i) => (
<motion.div
key={i}
<div
style={{
position: "absolute",
width: 4 + Math.random() * 4,
height: 4 + Math.random() * 4,
inset: 0,
overflow: "hidden",
pointerEvents: "none",
}}
/>
);
}
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
pointerEvents: "none",
}}
>
{sparkles.map(
(sparkle: {
id: Key | null | undefined;
size: any;
left: any;
bottom: any;
y: string | number | null;
duration: any;
delay: any;
}) => (
<motion.div
key={sparkle.id}
style={{
position: "absolute",
width: sparkle.size,
height: sparkle.size,
borderRadius: "50%",
background: color,
left: `${10 + Math.random() * 80}%`,
bottom: `${Math.random() * 30}%`,
left: `${sparkle.left}%`,
bottom: `${sparkle.bottom}%`,
}}
animate={{
y: [0, -(60 + Math.random() * 80)],
y: [0, sparkle.y],
opacity: [0, 1, 1, 0],
scale: [0.5, 1, 0.8, 0],
}}
transition={{
duration: 2.5 + Math.random() * 2,
duration: sparkle.duration,
repeat: Infinity,
delay: Math.random() * 3,
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"
+325
View File
@@ -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}
+10 -1
View File
@@ -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"
+4 -4
View File
@@ -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",
+3
View File
@@ -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>
);
}
+159 -34
View File
@@ -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>
)}
+12 -2
View File
@@ -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>
+18 -18
View File
@@ -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>
+15 -8
View File
@@ -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,20 +14,21 @@ interface ButtonLoadingProps {
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props;
return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? (
<>
<AbsoluteCenter display='inline-flex'>
<Spinner size='inherit' color='inherit' />
<AbsoluteCenter display="inline-flex">
<Spinner size="inherit" color="inherit" />
</AbsoluteCenter>
<Span opacity={0}>{children}</Span>
</>
) : loading && loadingText ? (
<>
<Spinner size='inherit' color='inherit' />
<Spinner size="inherit" color="inherit" />
{loadingText}
</>
) : (
@@ -30,4 +36,5 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
)}
</ChakraButton>
);
});
},
);
+9 -6
View File
@@ -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>
);
+9 -6
View File
@@ -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");
+35 -22
View File
@@ -1,23 +1,28 @@
'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;
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
function Toggle(props, ref) {
const { variant = "subtle", size, children, ...rest } = props;
const variantConfig = variantMap[variant];
return (
@@ -27,18 +32,26 @@ export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function
</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} />;
},
);
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;
+48 -30
View File
@@ -1,15 +1,17 @@
'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) {
export const ComboboxControl = React.forwardRef<
HTMLDivElement,
ComboboxControlProps
>(function ComboboxControl(props, ref) {
const { children, clearable, ...rest } = props;
return (
<ChakraCombobox.Control {...rest} ref={ref}>
@@ -20,26 +22,34 @@ export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlP
</ChakraCombobox.IndicatorGroup>
</ChakraCombobox.Control>
);
},
);
});
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
function ComboboxClearTrigger(props, ref) {
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' />
<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) {
export const ComboboxContent = React.forwardRef<
HTMLDivElement,
ComboboxContentProps
>(function ComboboxContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
@@ -48,11 +58,12 @@ export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentP
</ChakraCombobox.Positioner>
</Portal>
);
},
);
});
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
function ComboboxItem(props, ref) {
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}>
@@ -60,21 +71,29 @@ export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.Item
<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) {
export const ComboboxItemGroup = React.forwardRef<
HTMLDivElement,
ComboboxItemGroupProps
>(function ComboboxItemGroup(props, ref) {
const { children, label, ...rest } = props;
return (
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
@@ -82,8 +101,7 @@ export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGr
{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