20 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
fahricansecer 96b9653a7e Merge pull request 'gg' (#3) from v26-shadow into main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 3m39s
Reviewed-on: #3
2026-04-24 00:30:36 +03:00
fahricansecer 30592394ef gg 2026-04-24 00:27:14 +03:00
193 changed files with 13026 additions and 4671 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 - name: Kodu Cek
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Ortam Degiskenlerini Olustur
run: |
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > .env.production
echo "NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}" >> .env.production
echo "NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }}" >> .env.production
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env.production
echo "NEXT_PUBLIC_AUTH_REQUIRED=${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}" >> .env.production
echo "NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=${{ secrets.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN }}" >> .env.production
echo "NEXT_PUBLIC_PADDLE_ENVIRONMENT=${{ secrets.NEXT_PUBLIC_PADDLE_ENVIRONMENT }}" >> .env.production
echo "NEXT_PUBLIC_PADDLE_SELLER_ID=${{ secrets.NEXT_PUBLIC_PADDLE_SELLER_ID }}" >> .env.production
cp .env.production .env.development
- name: Docker Build - name: Docker Build
run: | run: |
docker build \ docker build \
--build-arg NEXT_PUBLIC_API_URL='https://api.iddaai.com/api' \ --build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='false' \ --build-arg NEXT_PUBLIC_APP_URL="${{ secrets.NEXT_PUBLIC_APP_URL }}" \
--build-arg NEXTAUTH_URL="${{ secrets.NEXTAUTH_URL }}" \
--build-arg NEXTAUTH_SECRET="${{ secrets.NEXTAUTH_SECRET }}" \
--build-arg NEXT_PUBLIC_AUTH_REQUIRED="${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}" \
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ secrets.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN }}" \
--build-arg NEXT_PUBLIC_PADDLE_ENVIRONMENT="${{ secrets.NEXT_PUBLIC_PADDLE_ENVIRONMENT }}" \
--build-arg NEXT_PUBLIC_PADDLE_SELLER_ID="${{ secrets.NEXT_PUBLIC_PADDLE_SELLER_ID }}" \
-t iddaai-fe:latest . -t iddaai-fe:latest .
- name: Eski Konteyneri Sil - name: Eski Konteyneri Sil
@@ -29,8 +47,5 @@ jobs:
--network iddaai_iddaai-network \ --network iddaai_iddaai-network \
-p 127.0.0.1:1510:3000 \ -p 127.0.0.1:1510:3000 \
-e NODE_ENV=production \ -e NODE_ENV=production \
-e NEXT_PUBLIC_API_URL='https://api.iddaai.com/api' \ --env-file .env.production \
-e NEXTAUTH_URL='https://iddaai.com' \
-e NEXTAUTH_SECRET='fFw34R134jRof1H2jofh2!32hU3gfjA1' \
-e NEXT_PUBLIC_AUTH_REQUIRED='false' \
iddaai-fe:latest iddaai-fe:latest
+3
View File
@@ -1,3 +1,6 @@
node_modules node_modules
.next .next
.env.local
certificates/
+34 -12
View File
@@ -9,10 +9,26 @@ RUN npm install
# Copy source code # Copy source code
COPY . . COPY . .
# Build-time environment variables
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_URL
ARG NEXTAUTH_URL
ARG NEXTAUTH_SECRET
ARG NEXT_PUBLIC_AUTH_REQUIRED
ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
ARG NEXT_PUBLIC_PADDLE_ENVIRONMENT
ARG NEXT_PUBLIC_PADDLE_SELLER_ID
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXTAUTH_URL=$NEXTAUTH_URL
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ENV NEXT_PUBLIC_AUTH_REQUIRED=$NEXT_PUBLIC_AUTH_REQUIRED
ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
ENV NEXT_PUBLIC_PADDLE_ENVIRONMENT=$NEXT_PUBLIC_PADDLE_ENVIRONMENT
ENV NEXT_PUBLIC_PADDLE_SELLER_ID=$NEXT_PUBLIC_PADDLE_SELLER_ID
# Build Next.js app # Build Next.js app
# NEXT_PUBLIC_API_URL should be set during build if used in static generation
# For production, we usually point to the domain name
ENV NEXT_PUBLIC_API_URL=https://api.iddaai.com/api
RUN npm run build RUN npm run build
# --- STAGE 2: RUNNER --- # --- STAGE 2: RUNNER ---
@@ -21,16 +37,22 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Copy only necessary files # Don't run as root
COPY --from=builder /app/package*.json ./ RUN addgroup --system --gid 1001 nodejs
COPY --from=builder /app/.next ./.next RUN adduser --system --uid 1001 nextjs
# Copy standalone build
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/next.config.ts ./ # Set permissions for standalone build
# Copy messages for internationalization COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder /app/messages ./messages COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start Next.js # Start standalone server
CMD ["npm", "start"] CMD ["node", "server.js"]
-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 nextConfig from 'eslint-config-next';
import { fileURLToPath } from 'url'; import prettierConfig from 'eslint-config-prettier';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [ const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'), ...nextConfig,
prettierConfig,
{ {
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
}, },
{ {
files: ['**/*.ts', '**/*.tsx'],
rules: { rules: {
'@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
+138
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...", "logging-in": "Signing in...",
"registering": "Creating account...", "registering": "Creating account...",
"login-success": "Login successful!", "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.", "all-right-reserved": "All rights reserved.",
"privacy-policy": "Privacy Policy", "privacy-policy": "Privacy Policy",
@@ -45,7 +47,6 @@
"low": "Low", "low": "Low",
"medium": "Medium", "medium": "Medium",
"high": "High", "high": "High",
"nav": { "nav": {
"home": "Home", "home": "Home",
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -66,13 +67,12 @@
"coupons": "Coupons", "coupons": "Coupons",
"tools": "Tools" "tools": "Tools"
}, },
"landing": { "landing": {
"hero-title": "AI-Powered Betting Predictions", "hero-title": "AI-Powered Betting Predictions",
"hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.", "hero-subtitle": "Make smarter bets with our advanced AI prediction engine. Analyze matches, discover value bets, and build winning coupons.",
"get-started": "Get Started", "get-started": "Get Started",
"learn-more": "Learn More", "learn-more": "Learn More",
"features-title": "Why Choose Suggest Bet?", "features-title": "Why Choose Iddaai?",
"feature-ai": "AI Predictions", "feature-ai": "AI Predictions",
"feature-ai-desc": "Powered by V20 ensemble model with 95%+ data quality scoring.", "feature-ai-desc": "Powered by V20 ensemble model with 95%+ data quality scoring.",
"feature-value": "Value Bets", "feature-value": "Value Bets",
@@ -86,7 +86,6 @@
"stats-users": "Active Users", "stats-users": "Active Users",
"stats-matches": "Matches Analyzed" "stats-matches": "Matches Analyzed"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"welcome": "Welcome back", "welcome": "Welcome back",
@@ -99,7 +98,6 @@
"no-matches": "No matches available today.", "no-matches": "No matches available today.",
"no-predictions": "No predictions available." "no-predictions": "No predictions available."
}, },
"matches": { "matches": {
"title": "Matches", "title": "Matches",
"filter-sport": "Sport", "filter-sport": "Sport",
@@ -121,9 +119,44 @@
"recent-matches": "Recent Matches", "recent-matches": "Recent Matches",
"home-team": "Home", "home-team": "Home",
"away-team": "Away", "away-team": "Away",
"vs": "vs" "vs": "vs",
"referee": "Referee",
"sidelined": "Injuries & Absences",
"injury": "Injury",
"suspended": "Suspended",
"other-reason": "Other",
"matches-missed": "Matches Missed",
"position": "Position",
"no-sidelined": "No injury information available",
"match-events": "Match Events",
"goal": "Goal",
"yellow-card": "Yellow Card",
"red-card": "Red Card",
"substitution": "Substitution",
"starters": "Starting XI",
"substitutes": "Substitutes",
"all-matches": "All Matches",
"today-matches": "Today's Matches",
"next-1-hour": "Next 1 Hour",
"possession": "Possession",
"shots-on-target": "Shots on Target",
"shots-off-target": "Shots off Target",
"total-shots": "Total Shots",
"total-passes": "Total Passes",
"corners": "Corners",
"fouls": "Fouls",
"offsides": "Offsides",
"officials": "Officials",
"main-referee": "Referee",
"assistant-referee": "Assistant Referee",
"fourth-official": "Fourth Official",
"var-referee": "VAR Referee",
"avar-referee": "AVAR Referee",
"penalty": "Penalty",
"half-time": "1st Half",
"second-half": "2nd Half",
"assist": "Assist"
}, },
"predictions": { "predictions": {
"title": "Predictions", "title": "Predictions",
"upcoming": "Upcoming", "upcoming": "Upcoming",
@@ -145,6 +178,8 @@
"bet-summary": "Bet Summary", "bet-summary": "Bet Summary",
"expected-value": "Expected Value", "expected-value": "Expected Value",
"no-predictions": "No predictions available.", "no-predictions": "No predictions available.",
"generate": "Analyze with AI",
"pre-match-disclaimer": "This analysis is based on pre-match data only.",
"accuracy": "Accuracy", "accuracy": "Accuracy",
"total-predictions": "Total Predictions", "total-predictions": "Total Predictions",
"correct-predictions": "Correct Predictions", "correct-predictions": "Correct Predictions",
@@ -186,11 +221,11 @@
"missing_total_odds": "Over/Under odds are missing.", "missing_total_odds": "Over/Under odds are missing.",
"missing_spread_odds": "Spread (Handicap) odds are missing.", "missing_spread_odds": "Spread (Handicap) odds are missing.",
"no_bet_conditions_met": "The algorithm could not find a safe/valuable bet for this match.", "no_bet_conditions_met": "The algorithm could not find a safe/valuable bet for this match.",
"insufficient_play_score": "Play score is below the playability threshold.", "insufficient_play_score": "Model signal is below the threshold.",
"no_ev_edge_minimum_stake": "Passed safety gates but no mathematical edge — minimum stake applied.", "no_ev_edge_minimum_stake": "Passed safety gates but no mathematical edge — minimum stake applied.",
"upset_risk_detected": "High upset risk detected, proceed with caution." "upset_risk_detected": "High upset risk detected, proceed with caution."
}, },
"ev-edge": "EV Edge", "ev-edge": "Theoretical Edge",
"implied-prob": "Market Probability", "implied-prob": "Market Probability",
"model-prob": "Model Probability", "model-prob": "Model Probability",
"kelly-stake": "Kelly Stake", "kelly-stake": "Kelly Stake",
@@ -219,27 +254,48 @@
"HTFT": "Half Time / Full Time", "HTFT": "Half Time / Full Time",
"HT/FT": "Half Time / Full Time", "HT/FT": "Half Time / Full Time",
"OE": "Odd / Even", "OE": "Odd / Even",
"HT_OU05": "First Half 0.5 Goals" "HT_OU05": "First Half 0.5 Goals",
"HT_OU15": "First Half 1.5 Goals",
"CARDS": "Cards 4.5",
"HCAP": "Handicap Result"
}, },
"ui": { "ui": {
"summary-title": "Prediction Summary", "summary-title": "Prediction Summary",
"summary-info": "Shows what stands out first and then explains why it stands out.", "summary-info": "Shows model signals and uncertainty in a conservative summary.",
"main-recommendation": "Main Recommendation", "model-signal-disclaimer": "This is a model signal; it is not a guaranteed result, guarantee, or hit-rate promise. Signal score can be wrong because of in-match variance, lineups, and data quality.",
"main-recommendation": "Highlighted Signal",
"best-market-copy": "is the strongest option in this market.", "best-market-copy": "is the strongest option in this market.",
"confidence-label": "Confidence", "confidence-label": "Confidence",
"confidence-interval": "Confidence Interval",
"confidence-interval-warning": "The confidence interval is wide. Even with a signal, it is not recommended as a standalone pick.",
"confidence-band": "Band",
"odds-label": "Odds", "odds-label": "Odds",
"edge-label": "Expected Advantage (Edge)", "edge-label": "Theoretical Edge",
"edge-info": "Edge is the gap between the model probability and the market probability. If it is positive, the model sees value in this price.", "edge-info": "The theoretical gap between model probability and market probability; it is not a guarantee or a certain profit expectation.",
"stake-label": "Suggested Bet Size (Stake)", "stake-label": "Suggested Bet Size (Stake)",
"stake-label-short": "Bet Size", "stake-label-short": "Bet Size",
"stake-info": "Stake is the suggested bet size. 2.0u means a 2-unit bet in your own bankroll plan.", "stake-info": "Stake is the suggested bet size. 2.0u means a 2-unit bet in your own bankroll plan.",
"play-score-label": "Playability Score", "play-score-label": "Model Signal",
"playability-label": "Playability", "playability-label": "Model signal",
"quick-read": "Quick read", "quick-read": "Quick read",
"lineup-source": "Lineup Source", "lineup-source": "Lineup Source",
"lineup-confirmed-live": "Confirmed starting XI",
"lineup-probable-xi": "Probable starting XI",
"unknown": "Unknown",
"model-label": "Model", "model-label": "Model",
"engine-info": "Shows which components influence the prediction the most.", "engine-info": "Shows which components influence the prediction the most.",
"best-single-pick": "Best Single Pick", "engine-team-football": "Team Strength",
"engine-team-basketball": "Team Form",
"engine-player-football": "Player Impact",
"engine-player-basketball": "Lineup Impact",
"engine-odds": "Odds Analysis",
"engine-referee-football": "Referee Impact",
"engine-referee-basketball": "Supporting Signals",
"engine-label-high": "High",
"engine-label-medium": "Medium",
"engine-label-low": "Low",
"engine-label-very-low": "Very Low",
"best-single-pick": "Strongest Signal",
"alternative-markets": "Alternative Markets", "alternative-markets": "Alternative Markets",
"alternative-markets-info": "Options outside the main recommendation.", "alternative-markets-info": "Options outside the main recommendation.",
"alternative": "Alternative", "alternative": "Alternative",
@@ -248,10 +304,50 @@
"all-markets-info": "Compares every option in a single table.", "all-markets-info": "Compares every option in a single table.",
"market-board-info": "The probability distribution the model sees for each market.", "market-board-info": "The probability distribution the model sees for each market.",
"bet-advice-info": "The model's final action recommendation.", "bet-advice-info": "The model's final action recommendation.",
"recommended-stake-inline": "Suggested size" "recommended-stake-inline": "Suggested size",
"model-probability-short": "Model",
"market-probability-short": "Market",
"theoretical-edge-inline": "Theoretical edge",
"playable": "Playable",
"risky": "Risky",
"hit-probability": "Hit Probability",
"calibrated-confidence": "Calibrated Confidence",
"score-scenario-football": "Score Scenario",
"score-scenario-basketball": "Points Scenario",
"score-scenario-info-football": "Expected score and the most likely scenarios.",
"score-scenario-info-basketball": "Expected points distribution and the most likely match scenarios.",
"full-time-football": "Full Time",
"full-time-basketball": "Full-Time Points",
"half-time-football": "Half Time",
"half-time-basketball": "Half-Time Points",
"expected-total-football": "Total xG",
"expected-total-basketball": "Expected Total Points",
"live": "LIVE",
"pre-match-prediction": "Pre-match prediction",
"prediction-contradictions": "Prediction Contradictions",
"data-quality": "Data Quality",
"data-quality-info": "How reliable the lineup, odds, and match data are.",
"risk-info": "Upset probability and uncertainty level.",
"risk-commentary": "Risk Commentary",
"risk-default-comment": "The model asks for extra caution on this match.",
"surprise-score": "Upset score",
"match-commentary-title": "Match Commentary",
"match-commentary-info": "The model's human-readable summary of the match.",
"reasoning-info": "High-level summary of why the model reads this match this way.",
"bet-advice-play": "PLAY",
"bet-advice-pass": "PASS",
"signal-tier-core": "Core",
"signal-tier-value": "Value",
"signal-tier-lean": "Lean",
"signal-tier-longshot": "Longshot",
"signal-tier-pass": "Pass",
"confidence-high": "High",
"confidence-medium": "Medium",
"confidence-low": "Low",
"confidence-unknown": "Unknown",
"info": "Info"
} }
}, },
"coupons": { "coupons": {
"title": "Coupon Builder", "title": "Coupon Builder",
"builder-title": "Coupon Builder", "builder-title": "Coupon Builder",
@@ -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-help": "Only football matches that have not started yet are listed here. Finished and live matches are excluded.",
"candidate-pool-subtitle": "Source: live_matches table • sport: football • status: not started", "candidate-pool-subtitle": "Source: live_matches table • sport: football • status: not started",
"match-count-suffix": "matches", "match-count-suffix": "matches",
"match-count-label": "Coupon Match Count",
"match-count-help": "How many matches should the AI coupon include? You can choose between 2 and 15. If you do not select any matches, the full bulletin is scanned.",
"match-count-auto": "Full bulletin ({count} matches)",
"upcoming-badge": "Upcoming", "upcoming-badge": "Upcoming",
"upcoming-reference": "Upcoming pool", "upcoming-reference": "Upcoming pool",
"finished-badge": "Finished", "finished-badge": "Finished",
@@ -375,7 +474,6 @@
"engine-mode-label": "Engine Mode", "engine-mode-label": "Engine Mode",
"engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis." "engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis."
}, },
"profile": { "profile": {
"title": "Profile", "title": "Profile",
"account-settings": "Account Settings", "account-settings": "Account Settings",
@@ -400,15 +498,14 @@
"win-rate": "Win Rate", "win-rate": "Win Rate",
"total-profit": "Total Profit" "total-profit": "Total Profit"
}, },
"leagues": { "leagues": {
"title": "Leagues & Teams", "title": "Leagues & Teams",
"countries": "Countries", "countries": "Countries",
"leagues": "Leagues", "leagues": "Leagues",
"countries-leagues": "Countries & Leagues", "countries-leagues": "Countries & Leagues",
"search-at-least-2": "Type at least 2 characters to search teams." "search-at-least-2": "Type at least 2 characters to search teams.",
"all": "All"
}, },
"h2h": { "h2h": {
"title": "Head to Head", "title": "Head to Head",
"team-1": "Team 1", "team-1": "Team 1",
@@ -418,7 +515,6 @@
"draws": "Draws", "draws": "Draws",
"no-matches-found": "No head-to-head matches found between these teams." "no-matches-found": "No head-to-head matches found between these teams."
}, },
"analysis": { "analysis": {
"title": "Multi-Match Analysis", "title": "Multi-Match Analysis",
"select-matches": "Select Matches", "select-matches": "Select Matches",
@@ -430,7 +526,6 @@
"matches-analyzed": "matches analyzed", "matches-analyzed": "matches analyzed",
"no-history": "No analysis history yet." "no-history": "No analysis history yet."
}, },
"spor-toto": { "spor-toto": {
"title": "Spor Toto", "title": "Spor Toto",
"sync-bulletins": "Sync Bulletins", "sync-bulletins": "Sync Bulletins",
@@ -458,7 +553,6 @@
"rollover-stats": "Rollover Stats", "rollover-stats": "Rollover Stats",
"prediction-generated": "Prediction generated successfully!" "prediction-generated": "Prediction generated successfully!"
}, },
"admin": { "admin": {
"title": "Admin Panel", "title": "Admin Panel",
"subtitle": "Manage users, monitor predictions, and system overview.", "subtitle": "Manage users, monitor predictions, and system overview.",
@@ -466,7 +560,9 @@
"analytics": "Analytics Overview", "analytics": "Analytics Overview",
"user-management": "User Management", "user-management": "User Management",
"users": "Users", "users": "Users",
"premium-users": "Premium Users",
"settings": "Settings", "settings": "Settings",
"subscription": "Subscription",
"usage-limits": "Usage Limits", "usage-limits": "Usage Limits",
"total-users": "Total Users", "total-users": "Total Users",
"active-users": "Active Users", "active-users": "Active Users",
@@ -486,10 +582,31 @@
"user-email": "Email", "user-email": "Email",
"user-role": "Role", "user-role": "Role",
"user-status": "Status", "user-status": "Status",
"no-users": "No users found." "no-users": "No users found.",
"restricted": "Restricted",
"admin-access-required": "Admin access required",
"admin-access-description": "This area is only available to superadmin accounts.",
"search-users-placeholder": "Search by email or name...",
"all-roles": "View All Roles",
"standard-user": "Standard User",
"superadmin": "System Administrator (Admin)",
"all-plans": "View All Plans",
"plan-free": "Free",
"plan-plus": "Plus Plan",
"plan-premium": "Premium Plan",
"plan-past-due": "Past Due",
"plan-cancelled": "Cancelled",
"edit-user-title": "Edit User: {email}",
"user-role-field": "User Role",
"subscription-plan-field": "Subscription Plan",
"subscription-end-date": "Subscription End Date (Optional)",
"account-active-question": "Is the account active?"
}, },
"common": { "common": {
"limits": {
"analysis_left": "Analyses",
"out_of_analysis": "Daily analysis limit exceeded."
},
"loading": "Loading...", "loading": "Loading...",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@@ -516,6 +633,166 @@
"of": "of", "of": "of",
"items-per-page": "Items per page", "items-per-page": "Items per page",
"showing": "Showing", "showing": "Showing",
"results": "results" "results": "results",
"SUCCESS_USER_STATUS_UPDATED": "User status updated successfully.",
"SUCCESS_USER_ROLE_UPDATED": "User role updated successfully.",
"SUCCESS_USER_DELETED": "User deleted successfully.",
"SUCCESS_USER_LIMITS_RESET": "User limits reset successfully.",
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "User subscription updated successfully."
},
"seo": {
"global": {
"title": "iddaai.com | AI-Powered Betting Predictions",
"description": "iddaai.com offers AI-powered betting predictions, detailed match analysis, and data-driven coupon building.",
"keywords": "betting, betting predictions, AI betting, match analysis, sure bets, football statistics"
},
"home": {
"title": "Home",
"description": "AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons."
},
"h2h": {
"title": "Head to Head Comparison",
"description": "Compare football teams head to head. In-depth statistics, past matches, and predictions."
},
"analysis": {
"title": "Multi-Match Analysis",
"description": "Analyze multiple matches at once. Detailed statistics and AI-driven strategies."
},
"leagues": {
"title": "Leagues & Teams",
"description": "Explore football and basketball leagues, countries, and team statistics worldwide."
},
"admin": {
"title": "Admin Panel",
"description": "Admin panel for managing users and system settings."
},
"matches": {
"title": "Matches & Fixtures",
"description": "View upcoming matches, live scores, and past fixtures with AI predictions."
},
"about": {
"title": "About Us",
"description": "Learn more about iddaai.com, our AI technology, and how we deliver betting insights."
},
"dashboard": {
"title": "Dashboard",
"description": "Your personalized dashboard for betting stats, predictions, and account overview."
},
"profile": {
"title": "My Profile",
"description": "Manage your user profile, subscription, and account settings."
},
"spor-toto": {
"title": "Spor Toto Predictions",
"description": "AI-powered Spor Toto predictions. Build coupons with conservative, balanced, or aggressive strategies."
},
"coupon-builder": {
"title": "AI Coupon Builder",
"description": "Automatically generate optimized betting coupons using advanced AI and statistical models."
},
"teams": {
"title": "Team Statistics",
"description": "Detailed statistics, form analysis, and predictive models for football teams."
},
"coupon-history": {
"title": "Coupon History",
"description": "Review your past betting coupons and track your performance."
},
"predictions": {
"title": "Betting Predictions",
"description": "Daily AI betting predictions, value odds, and high-confidence match tips."
},
"signup": {
"title": "Sign Up",
"description": "Create your iddaai.com account to access AI-powered betting predictions."
},
"signin": {
"title": "Sign In",
"description": "Sign in to your iddaai.com account to access AI predictions and tools."
},
"pricing": {
"title": "Pricing — iddaai",
"description": "Explore iddaai AI-powered betting analysis plans. Free, Plus, and Premium plans available."
} }
},
"pricing": {
"title": "Choose Your Plan",
"subtitle": "Boost your winning odds with AI-powered analyses",
"monthly": "Monthly",
"yearly": "Yearly",
"yearly-save": "Save 2 months",
"most-popular": "Most Popular",
"current-plan": "Current Plan",
"get-started": "Get Started",
"upgrade": "Upgrade",
"downgrade": "Downgrade",
"contact-sales": "Contact Us",
"per-month": "/mo",
"per-year": "/yr",
"free-forever": "Free forever",
"billed-yearly": "Billed yearly",
"compare-plans": "Compare Plans",
"faq-title": "Frequently Asked Questions",
"plan": {
"free": {
"name": "Free",
"description": "Get started with basic AI analyses"
},
"plus": {
"name": "Plus",
"description": "More analyses and exclusive features"
},
"premium": {
"name": "Premium",
"description": "Unlimited access and professional tools"
}
},
"feature": {
"daily-analyses": "Daily AI analyses",
"daily-coupons": "Daily coupons",
"basic-analysis": "Basic match analysis",
"detailed-analysis": "Detailed AI analysis",
"h2h-comparison": "H2H comparison",
"coupon-builder": "Coupon builder",
"spor-toto": "Spor Toto analysis",
"ad-free": "Ad-free experience",
"priority-support": "Priority support",
"unlimited": "Unlimited"
},
"faq": {
"q1": "Can I change my plan anytime?",
"a1": "Yes, you can upgrade or downgrade your plan anytime. Upgrades take effect immediately.",
"q2": "How does cancellation work?",
"a2": "Your access continues until the end of your current billing period. You'll automatically switch to the Free plan.",
"q3": "What payment methods are accepted?",
"a3": "You can pay securely with credit cards and debit cards. All payments are processed through Paddle.",
"q4": "Is there a trial period?",
"a4": "You can try all basic features with the Free plan. When you upgrade, you get instant access to premium features."
}
},
"subscription": {
"title": "Subscription",
"current-plan": "Current Plan",
"plan-badge": {
"free": "Free",
"plus": "Plus",
"premium": "Premium"
},
"upgrade-cta": "Upgrade Plan",
"manage": "Manage Subscription",
"cancel": "Cancel Subscription",
"cancel-confirm-title": "Cancel Subscription",
"cancel-confirm-message": "Your access will continue until the end of your current billing period. Are you sure you want to cancel?",
"cancel-reason-placeholder": "Would you like to share your reason? (Optional)",
"cancelled-info": "Your subscription ends on {date}",
"next-billing": "Next billing date",
"usage": {
"title": "Daily Usage",
"analyses": "AI Analyses",
"coupons": "Coupons",
"of": "/",
"remaining": "remaining"
}
},
"refund-policy": "Refund Policy"
} }
+319 -30
View File
@@ -5,8 +5,8 @@
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri", "intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
"artificial-intelligence": "Yapay Zeka", "artificial-intelligence": "Yapay Zeka",
"error": { "error": {
"not-found": "Aradığınız sayfa bulunamadı.",
"404": "404", "404": "404",
"not-found": "Aradığınız sayfa bulunamadı.",
"back-to-home": "Ana sayfaya dön", "back-to-home": "Ana sayfaya dön",
"generic": "Beklenmeyen bir hata oluştu.", "generic": "Beklenmeyen bir hata oluştu.",
"network": "Ağ hatası. Lütfen bağlantınızı kontrol edin.", "network": "Ağ hatası. Lütfen bağlantınızı kontrol edin.",
@@ -36,7 +36,9 @@
"logging-in": "Giriş yapılıyor...", "logging-in": "Giriş yapılıyor...",
"registering": "Hesap oluşturuluyor...", "registering": "Hesap oluşturuluyor...",
"login-success": "Giriş başarılı!", "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.", "all-right-reserved": "Tüm hakları saklıdır.",
"privacy-policy": "Gizlilik Politikası", "privacy-policy": "Gizlilik Politikası",
@@ -45,7 +47,6 @@
"low": "Düşük", "low": "Düşük",
"medium": "Orta", "medium": "Orta",
"high": "Yüksek", "high": "Yüksek",
"nav": { "nav": {
"home": "Anasayfa", "home": "Anasayfa",
"dashboard": "Kontrol Paneli", "dashboard": "Kontrol Paneli",
@@ -66,13 +67,12 @@
"coupons": "Kuponlar", "coupons": "Kuponlar",
"tools": "Araçlar" "tools": "Araçlar"
}, },
"landing": { "landing": {
"hero-title": "Yapay Zeka Destekli Bahis Tahminleri", "hero-title": "Yapay Zeka Destekli Bahis Tahminleri",
"hero-subtitle": "Gelişmiş yapay zeka tahmin motorumuz ile daha akıllı bahisler yapın. Maçları analiz edin, değerli bahisleri keşfedin ve kazanan kuponlar oluşturun.", "hero-subtitle": "Gelişmiş yapay zeka tahmin motorumuz ile daha akıllı bahisler yapın. Maçları analiz edin, değerli bahisleri keşfedin ve kazanan kuponlar oluşturun.",
"get-started": "Başla", "get-started": "Başla",
"learn-more": "Daha Fazla", "learn-more": "Daha Fazla",
"features-title": "Neden Suggest Bet?", "features-title": "Neden Iddaai?",
"feature-ai": "Yapay Zeka Tahminleri", "feature-ai": "Yapay Zeka Tahminleri",
"feature-ai-desc": "%95+ veri kalitesi puanlama ile V20 ensemble modeli tarafından desteklenmektedir.", "feature-ai-desc": "%95+ veri kalitesi puanlama ile V20 ensemble modeli tarafından desteklenmektedir.",
"feature-value": "Değerli Bahisler", "feature-value": "Değerli Bahisler",
@@ -86,7 +86,6 @@
"stats-users": "Aktif Kullanıcı", "stats-users": "Aktif Kullanıcı",
"stats-matches": "Analiz Edilen Maç" "stats-matches": "Analiz Edilen Maç"
}, },
"dashboard": { "dashboard": {
"title": "Kontrol Paneli", "title": "Kontrol Paneli",
"welcome": "Tekrar hoş geldiniz", "welcome": "Tekrar hoş geldiniz",
@@ -99,7 +98,6 @@
"no-matches": "Bugün maç bulunmuyor.", "no-matches": "Bugün maç bulunmuyor.",
"no-predictions": "Tahmin bulunmuyor." "no-predictions": "Tahmin bulunmuyor."
}, },
"matches": { "matches": {
"title": "Maçlar", "title": "Maçlar",
"filter-sport": "Spor", "filter-sport": "Spor",
@@ -121,9 +119,44 @@
"recent-matches": "Son Maçlar", "recent-matches": "Son Maçlar",
"home-team": "Ev Sahibi", "home-team": "Ev Sahibi",
"away-team": "Deplasman", "away-team": "Deplasman",
"vs": "vs" "vs": "vs",
"referee": "Hakem",
"sidelined": "Sakatlık & Eksikler",
"injury": "Sakatlık",
"suspended": "Cezalı",
"other-reason": "Diğer",
"matches-missed": "Kaçırılan Maç",
"position": "Pozisyon",
"no-sidelined": "Eksik oyuncu bilgisi yok",
"match-events": "Maç Olayları",
"goal": "Gol",
"yellow-card": "Sarı Kart",
"red-card": "Kırmızı Kart",
"substitution": "Oyuncu Değişikliği",
"starters": "İlk 11",
"substitutes": "Yedekler",
"all-matches": "Tüm Maçlar",
"today-matches": "Bugünün Maçları",
"next-1-hour": "1 Saat İçinde",
"possession": "Topa Sahip Olma",
"shots-on-target": "İsabetli Şut",
"shots-off-target": "İsabetsiz Şut",
"total-shots": "Toplam Şut",
"total-passes": "Toplam Pas",
"corners": "Korner",
"fouls": "Faul",
"offsides": "Ofsayt",
"officials": "Hakemler",
"main-referee": "Hakem",
"assistant-referee": "Yardımcı Hakem",
"fourth-official": "Dördüncü Hakem",
"var-referee": "VAR Hakemi",
"avar-referee": "AVAR Hakemi",
"penalty": "Penaltı",
"half-time": "İlk Yarı",
"second-half": "İkinci Yarı",
"assist": "Asist"
}, },
"predictions": { "predictions": {
"title": "Tahminler", "title": "Tahminler",
"upcoming": "Yaklaşan", "upcoming": "Yaklaşan",
@@ -145,6 +178,8 @@
"bet-summary": "Bahis Özeti", "bet-summary": "Bahis Özeti",
"expected-value": "Beklenen Değer", "expected-value": "Beklenen Değer",
"no-predictions": "Tahmin bulunmuyor.", "no-predictions": "Tahmin bulunmuyor.",
"generate": "Yapay Zeka ile Analiz Et",
"pre-match-disclaimer": "Bu analiz maç başlamadan önceki verilere dayanmaktadır.",
"accuracy": "Doğruluk", "accuracy": "Doğruluk",
"total-predictions": "Toplam Tahmin", "total-predictions": "Toplam Tahmin",
"correct-predictions": "Doğru Tahmin", "correct-predictions": "Doğru Tahmin",
@@ -187,10 +222,10 @@
"missing_total_odds": "Alt/Üst oranları eksik.", "missing_total_odds": "Alt/Üst oranları eksik.",
"missing_spread_odds": "Handikap oranları eksik.", "missing_spread_odds": "Handikap oranları eksik.",
"no_bet_conditions_met": "Algoritma bu maç için güvenli/değerli bir bahis önerisi bulamadı.", "no_bet_conditions_met": "Algoritma bu maç için güvenli/değerli bir bahis önerisi bulamadı.",
"insufficient_play_score": "Oynanabilirlik skoru eşiğin altında kaldı.", "insufficient_play_score": "Model sinyali eşiğin altında kaldı.",
"no_ev_edge_minimum_stake": "Güvenlik kontrollerini geçti ancak matematik avantaj yok — minimum bahis uygulandı." "no_ev_edge_minimum_stake": "Güvenlik kontrollerini geçti ancak matematik avantaj yok — minimum bahis uygulandı."
}, },
"ev-edge": "EV Edge", "ev-edge": "Teorik Avantaj",
"implied-prob": "Piyasa Olasılığı", "implied-prob": "Piyasa Olasılığı",
"model-prob": "Model Olasılığı", "model-prob": "Model Olasılığı",
"kelly-stake": "Kelly Bahis", "kelly-stake": "Kelly Bahis",
@@ -219,27 +254,48 @@
"HTFT": "İlk Yarı / Maç Sonu", "HTFT": "İlk Yarı / Maç Sonu",
"HT/FT": "İlk Yarı / Maç Sonu", "HT/FT": "İlk Yarı / Maç Sonu",
"OE": "Tek / Çift", "OE": "Tek / Çift",
"HT_OU05": "İlk Yarı 0.5 Gol" "HT_OU05": "İlk Yarı 0.5 Gol",
"HT_OU15": "İlk Yarı 1.5 Gol",
"CARDS": "Kartlar 4.5",
"HCAP": "Handikap Sonucu"
}, },
"ui": { "ui": {
"summary-title": "Tahmin Özeti", "summary-title": "Tahmin Özeti",
"summary-info": "Önce neyin oynanabileceğini, sonra bunun neden öne çıktığını gösterir.", "summary-info": "Model sinyallerini ve belirsizlikleri sade şekilde gösterir.",
"main-recommendation": "Ana Öneri", "model-signal-disclaimer": "Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.",
"main-recommendation": "Öne Çıkan Sinyal",
"best-market-copy": "marketinde en güçlü seçim.", "best-market-copy": "marketinde en güçlü seçim.",
"confidence-label": "Güven", "confidence-label": "Güven",
"confidence-interval": "Güven Aralığı",
"confidence-interval-warning": "Güven aralığı geniş. Sinyal olsa bile tek başına oynanması önerilmez.",
"confidence-band": "Band",
"odds-label": "Oran", "odds-label": "Oran",
"edge-label": "Beklenen Avantaj (Edge)", "edge-label": "Teorik Avantaj",
"edge-info": "Edge, model olasılığı ile piyasa olasılığı arasındaki farktır. Pozitifse model bu oranı avantajlı görüyor demektir.", "edge-info": "Model olasılığı ile piyasa olasılığı arasındaki teorik farktır; tutma garantisi veya kesin kazanç beklentisi değildir.",
"stake-label": "Önerilen Miktar (Stake)", "stake-label": "Önerilen Miktar (Stake)",
"stake-label-short": "Bahis Miktarı", "stake-label-short": "Bahis Miktarı",
"stake-info": "Stake, bu bahis için önerilen bahis birimidir. 2.0u, kendi bankroll planınızdaki 2 birimlik bahis anlamına gelir.", "stake-info": "Stake, bu bahis için önerilen bahis birimidir. 2.0u, kendi bankroll planınızdaki 2 birimlik bahis anlamına gelir.",
"play-score-label": "Oynanabilirlik Puanı", "play-score-label": "Model Sinyali",
"playability-label": "Oynanabilirlik", "playability-label": "Model sinyali",
"quick-read": "Hızlı yorum", "quick-read": "Hızlı yorum",
"lineup-source": "Kadronun Kaynağı", "lineup-source": "Kadronun Kaynağı",
"lineup-confirmed-live": "Onaylı ilk 11",
"lineup-probable-xi": "Muhtemel ilk 11",
"unknown": "Bilinmiyor",
"model-label": "Model", "model-label": "Model",
"engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.", "engine-info": "Tahmini en çok hangi bileşenlerin etkilediğini gösterir.",
"best-single-pick": "En İyi Tekli Seçim", "engine-team-football": "Takım Gücü",
"engine-team-basketball": "Takım Formu",
"engine-player-football": "Oyuncu Etkisi",
"engine-player-basketball": "Kadro Etkisi",
"engine-odds": "Oran Analizi",
"engine-referee-football": "Hakem Etkisi",
"engine-referee-basketball": "Yardımcı Sinyaller",
"engine-label-high": "Yüksek",
"engine-label-medium": "Orta",
"engine-label-low": "Düşük",
"engine-label-very-low": "Çok Düşük",
"best-single-pick": "En Güçlü Sinyal",
"alternative-markets": "Alternatif Marketler", "alternative-markets": "Alternatif Marketler",
"alternative-markets-info": "Ana tahmin dışındaki seçenekler.", "alternative-markets-info": "Ana tahmin dışındaki seçenekler.",
"alternative": "Alternatif", "alternative": "Alternatif",
@@ -248,10 +304,50 @@
"all-markets-info": "Bütün seçenekleri tek tabloda karşılaştırır.", "all-markets-info": "Bütün seçenekleri tek tabloda karşılaştırır.",
"market-board-info": "Modelin her markette gördüğü olasılık dağılımı.", "market-board-info": "Modelin her markette gördüğü olasılık dağılımı.",
"bet-advice-info": "Modelin nihai aksiyon önerisi.", "bet-advice-info": "Modelin nihai aksiyon önerisi.",
"recommended-stake-inline": "Önerilen miktar" "recommended-stake-inline": "Önerilen miktar",
"model-probability-short": "Model",
"market-probability-short": "Piyasa",
"theoretical-edge-inline": "Teorik avantaj",
"playable": "Oynanabilir",
"risky": "Riskli",
"hit-probability": "Tutma Olasılığı",
"calibrated-confidence": "Kalibre Güven",
"score-scenario-football": "Skor Senaryosu",
"score-scenario-basketball": "Sayı Senaryosu",
"score-scenario-info-football": "Beklenen skor ve en olası senaryolar.",
"score-scenario-info-basketball": "Beklenen sayı dağılımı ve en olası maç senaryoları.",
"full-time-football": "Maç Sonu",
"full-time-basketball": "Maç Sonu Sayı",
"half-time-football": "İlk Yarı",
"half-time-basketball": "İlk Yarı Sayı",
"expected-total-football": "Toplam xG",
"expected-total-basketball": "Beklenen Toplam Sayı",
"live": "CANLI",
"pre-match-prediction": "Maç öncesi tahmin",
"prediction-contradictions": "Tahmin Çelişkileri",
"data-quality": "Veri Kalitesi",
"data-quality-info": "Kadro, oran ve maç verisinin ne kadar güvenilir olduğu.",
"risk-info": "Sürpriz ihtimali ve belirsizlik seviyesi.",
"risk-commentary": "Risk Yorumu",
"risk-default-comment": "Model bu maçta ekstra dikkat istiyor.",
"surprise-score": "Sürpriz skoru",
"match-commentary-title": "Maç Yorumu",
"match-commentary-info": "Modelin maç hakkındaki insan okunabilir özeti.",
"reasoning-info": "Modelin bu maçı neden bu şekilde okuduğunun üst seviye özeti.",
"bet-advice-play": "OYNA",
"bet-advice-pass": "OYNAMA",
"signal-tier-core": "Çekirdek",
"signal-tier-value": "Değer",
"signal-tier-lean": "Yorum",
"signal-tier-longshot": "Sürpriz",
"signal-tier-pass": "Pas",
"confidence-high": "Yüksek",
"confidence-medium": "Orta",
"confidence-low": "Düşük",
"confidence-unknown": "Belirsiz",
"info": "Bilgi"
} }
}, },
"coupons": { "coupons": {
"title": "Kupon Oluşturucu", "title": "Kupon Oluşturucu",
"builder-title": "Kupon Oluşturucu", "builder-title": "Kupon Oluşturucu",
@@ -297,6 +393,8 @@
"coupon": "Kupon", "coupon": "Kupon",
"candidate-match-count": "Aday Maç", "candidate-match-count": "Aday Maç",
"candidate-match-count-help": "Kupon oluşturmak için şu anda uygun olan yaklaşan futbol maçı sayısı.", "candidate-match-count-help": "Kupon oluşturmak için şu anda uygun olan yaklaşan futbol maçı sayısı.",
"finished-match-count": "Biten Maç",
"finished-match-count-help": "Biten futbol maçları için isteğe bağlı referans listesi. Bunlar kupon tahmininde asla kullanılmaz.",
"selected-match-count": "Seçilen Maç", "selected-match-count": "Seçilen Maç",
"selected-match-count-help": "Maçları siz seçerseniz AI kuponu sadece bu havuzdan üretir.", "selected-match-count-help": "Maçları siz seçerseniz AI kuponu sadece bu havuzdan üretir.",
"suggested-bet-count": "Önerilen Bahis", "suggested-bet-count": "Önerilen Bahis",
@@ -308,12 +406,24 @@
"candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış", "candidate-pool-subtitle": "Kaynak: live_matches tablosu - spor: futbol - durum: başlamamış",
"match-count-suffix": "maç", "match-count-suffix": "maç",
"upcoming-badge": "Yaklaşan", "upcoming-badge": "Yaklaşan",
"upcoming-reference": "Yaklaşan havuz",
"finished-badge": "Bitti",
"prediction-locked": "Tahmine Kapalı",
"read-only-short": "Salt okunur",
"selected-short": "Seçildi", "selected-short": "Seçildi",
"select-match": "Seç", "select-match": "Seç",
"match-state": "Maç Durumu",
"selection-mode": "AI Havuzu", "selection-mode": "AI Havuzu",
"manual-pool": "Manuel havuz", "manual-pool": "Manuel havuz",
"auto-pool": "Otomatik havuz", "auto-pool": "Otomatik havuz",
"finished-reference-only": "Sadece referans",
"no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.", "no-upcoming-matches": "Şu anda kupon oluşturmaya uygun yaklaşan futbol maçı bulunmuyor.",
"finished-matches-title": "Biten Maçlar",
"finished-matches-help": "Bu maçlar sadece referans için gösterilir. Seçilemezler ve kupon tahmini oluşturulmadan önce backend tarafından filtrelenirler.",
"finished-matches-subtitle": "İsteğe bağlı arşiv görünümü. Skorlar ve maç sonu istatistikleri kupon tahmin akışına gönderilmez.",
"show-finished-matches": "Biten maçları göster",
"hide-finished-matches": "Biten maçları gizle",
"no-finished-matches": "Geçerli görünüm için biten futbol maçı bulunamadı.",
"manual-selection-active": "AI yalnızca aşağıda seçtiğiniz maçları kullanacak.", "manual-selection-active": "AI yalnızca aşağıda seçtiğiniz maçları kullanacak.",
"automatic-selection-active": "Henüz manuel seçim yok. AI tüm yaklaşan maç havuzundan seçecek.", "automatic-selection-active": "Henüz manuel seçim yok. AI tüm yaklaşan maç havuzundan seçecek.",
"selected-matches-panel-title": "Seçili Maç Havuzu", "selected-matches-panel-title": "Seçili Maç Havuzu",
@@ -388,15 +498,14 @@
"win-rate": "Kazanma Oranı", "win-rate": "Kazanma Oranı",
"total-profit": "Toplam Kâr" "total-profit": "Toplam Kâr"
}, },
"leagues": { "leagues": {
"title": "Ligler & Takımlar", "title": "Ligler & Takımlar",
"countries": "Ülkeler", "countries": "Ülkeler",
"leagues": "Ligler", "leagues": "Ligler",
"countries-leagues": "Ülkeler & Ligler", "countries-leagues": "Ülkeler & Ligler",
"search-at-least-2": "Takım aramak için en az 2 karakter yazın." "search-at-least-2": "Takım aramak için en az 2 karakter yazın.",
"all": "Tümü"
}, },
"h2h": { "h2h": {
"title": "Karşılıklı Karşılaşma", "title": "Karşılıklı Karşılaşma",
"team-1": "Takım 1", "team-1": "Takım 1",
@@ -406,7 +515,6 @@
"draws": "Beraberlikler", "draws": "Beraberlikler",
"no-matches-found": "Bu takımlar arasında karşılıklı maç bulunamadı." "no-matches-found": "Bu takımlar arasında karşılıklı maç bulunamadı."
}, },
"analysis": { "analysis": {
"title": "Çoklu Maç Analizi", "title": "Çoklu Maç Analizi",
"select-matches": "Maç Seç", "select-matches": "Maç Seç",
@@ -418,7 +526,6 @@
"matches-analyzed": "maç analiz edildi", "matches-analyzed": "maç analiz edildi",
"no-history": "Henüz analiz geçmişi yok." "no-history": "Henüz analiz geçmişi yok."
}, },
"spor-toto": { "spor-toto": {
"title": "Spor Toto", "title": "Spor Toto",
"sync-bulletins": "Bültenleri Senkronize Et", "sync-bulletins": "Bültenleri Senkronize Et",
@@ -446,7 +553,6 @@
"rollover-stats": "Devir İstatistikleri", "rollover-stats": "Devir İstatistikleri",
"prediction-generated": "Tahmin başarıyla oluşturuldu!" "prediction-generated": "Tahmin başarıyla oluşturuldu!"
}, },
"admin": { "admin": {
"title": "Yönetim Paneli", "title": "Yönetim Paneli",
"subtitle": "Kullanıcıları yönetin, tahminleri takip edin ve sistemi izleyin.", "subtitle": "Kullanıcıları yönetin, tahminleri takip edin ve sistemi izleyin.",
@@ -454,7 +560,9 @@
"analytics": "Analitik Genel Bakış", "analytics": "Analitik Genel Bakış",
"user-management": "Kullanıcı Yönetimi", "user-management": "Kullanıcı Yönetimi",
"users": "Kullanıcılar", "users": "Kullanıcılar",
"premium-users": "Premium Kullanıcı",
"settings": "Ayarlar", "settings": "Ayarlar",
"subscription": "Abonelik",
"usage-limits": "Kullanım Limitleri", "usage-limits": "Kullanım Limitleri",
"total-users": "Toplam Kullanıcı", "total-users": "Toplam Kullanıcı",
"active-users": "Aktif Kullanıcı", "active-users": "Aktif Kullanıcı",
@@ -474,10 +582,31 @@
"user-email": "E-Posta", "user-email": "E-Posta",
"user-role": "Rol", "user-role": "Rol",
"user-status": "Durum", "user-status": "Durum",
"no-users": "Kullanıcı bulunamadı." "no-users": "Kullanıcı bulunamadı.",
"restricted": "Kısıtlı",
"admin-access-required": "Admin erişimi gerekli",
"admin-access-description": "Bu alan yalnızca superadmin hesapları tarafından kullanılabilir.",
"search-users-placeholder": "E-posta veya isim ara...",
"all-roles": "Tüm Rolleri Gör",
"standard-user": "Standart Kullanıcı",
"superadmin": "Sistem Yöneticisi (Admin)",
"all-plans": "Tüm Paketleri Gör",
"plan-free": "Ücretsiz (Free)",
"plan-plus": "Plus Paketi",
"plan-premium": "Premium Paketi",
"plan-past-due": "Ödeme Gecikti (Past Due)",
"plan-cancelled": "İptal Edildi (Cancelled)",
"edit-user-title": "Kullanıcı Düzenle: {email}",
"user-role-field": "Kullanıcı Rolü",
"subscription-plan-field": "Abonelik Paketi",
"subscription-end-date": "Abonelik Bitiş Tarihi (Opsiyonel)",
"account-active-question": "Hesap Aktif mi?"
}, },
"common": { "common": {
"limits": {
"analysis_left": "Analiz",
"out_of_analysis": "Günlük analiz limitiniz doldu."
},
"loading": "Yükleniyor...", "loading": "Yükleniyor...",
"save": "Kaydet", "save": "Kaydet",
"cancel": "İptal", "cancel": "İptal",
@@ -504,6 +633,166 @@
"of": "/", "of": "/",
"items-per-page": "Sayfa başına öğe", "items-per-page": "Sayfa başına öğe",
"showing": "Gösterilen", "showing": "Gösterilen",
"results": "sonuç" "results": "sonuç",
"SUCCESS_USER_STATUS_UPDATED": "Kullanıcı durumu başarıyla güncellendi.",
"SUCCESS_USER_ROLE_UPDATED": "Kullanıcı rolü başarıyla güncellendi.",
"SUCCESS_USER_DELETED": "Kullanıcı başarıyla silindi.",
"SUCCESS_USER_LIMITS_RESET": "Kullanıcı limitleri başarıyla sıfırlandı.",
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "Kullanıcı aboneliği başarıyla güncellendi."
},
"seo": {
"global": {
"title": "iddaai.com | Yapay Zeka İddaa Tahminleri",
"description": "iddaai.com yapay zeka destekli iddaa tahminleri, detaylı maç analizleri ve veriye dayalı kupon oluşturma hizmeti sunar.",
"keywords": "iddaa, iddaa tahminleri, yapay zeka iddaa, maç analizi, banko kuponlar, futbol istatistikleri"
},
"home": {
"title": "Ana Sayfa",
"description": "Yapay zeka destekli iddaa tahminleri. Maçları analiz edin, değerli bahisleri keşfedin ve kazandıran kuponlar oluşturun."
},
"h2h": {
"title": "Takım Karşılaştırma (H2H)",
"description": "Futbol takımlarını birebir karşılaştırın. Derinlemesine istatistikler, geçmiş maçlar ve tahminler."
},
"analysis": {
"title": "Çoklu Maç Analizi",
"description": "Aynı anda birden fazla maçı analiz edin. Detaylı istatistikler ve yapay zeka ile stratejiler geliştirin."
},
"leagues": {
"title": "Ligler ve Takımlar",
"description": "Dünya çapındaki futbol ve basketbol liglerini, ülkeleri ve takım istatistiklerini inceleyin."
},
"admin": {
"title": "Yönetici Paneli",
"description": "Kullanıcıları ve sistem ayarlarını yönetmek için yönetici paneli."
},
"matches": {
"title": "Maçlar ve Fikstür",
"description": "Yaklaşan maçları, canlı skorları ve yapay zeka tahminleriyle geçmiş fikstürleri görüntüleyin."
},
"about": {
"title": "Hakkımızda",
"description": "iddaai.com, yapay zeka teknolojimiz ve bahis öngörülerini nasıl sağladığımız hakkında daha fazla bilgi edinin."
},
"dashboard": {
"title": "Kullanıcı Paneli",
"description": "Bahis istatistikleri, tahminler ve hesaba genel bakış için kişiselleştirilmiş paneliniz."
},
"profile": {
"title": "Profilim",
"description": "Kullanıcı profilinizi, aboneliğinizi ve hesap ayarlarınızı yönetin."
},
"spor-toto": {
"title": "Spor Toto Tahminleri",
"description": "Yapay zeka destekli Spor Toto tahminleri. Muhafazakar, dengeli veya agresif stratejilerle kuponlar oluşturun."
},
"coupon-builder": {
"title": "Yapay Zeka Kupon Oluşturucu",
"description": "Gelişmiş yapay zeka ve istatistiksel modelleri kullanarak otomatik olarak optimize edilmiş bahis kuponları oluşturun."
},
"teams": {
"title": "Takım İstatistikleri",
"description": "Futbol takımları için detaylı istatistikler, form durumları ve tahmine dayalı modeller."
},
"coupon-history": {
"title": "Kupon Geçmişi",
"description": "Geçmişte oluşturduğunuz bahis kuponlarınızı ve performansınızı inceleyin."
},
"predictions": {
"title": "İddaa Tahminleri",
"description": "Günlük yapay zeka iddaa tahminleri, değerli oranlar ve yüksek güvenilirlikli maç tüyoları."
},
"signup": {
"title": "Kayıt Ol",
"description": "Yapay zeka tahminlerine erişmek için iddaai.com hesabınızı oluşturun."
},
"signin": {
"title": "Giriş Yap",
"description": "Yapay zeka tahminlerine ve araçlarına erişmek için iddaai.com hesabınıza giriş yapın."
},
"pricing": {
"title": "Fiyatlandırma — iddaai",
"description": "iddaai AI destekli iddaa analiz planlarını keşfedin. Ücretsiz, Plus ve Premium planlar."
} }
},
"pricing": {
"title": "Planınızı Seçin",
"subtitle": "AI destekli analizlerle kazanma şansınızı artırın",
"monthly": "Aylık",
"yearly": "Yıllık",
"yearly-save": "2 ay tasarruf",
"most-popular": "En Popüler",
"current-plan": "Mevcut Plan",
"get-started": "Başla",
"upgrade": "Yükselt",
"downgrade": "Düşür",
"contact-sales": "Bize Ulaşın",
"per-month": "/ay",
"per-year": "/yıl",
"free-forever": "Sonsuza kadar ücretsiz",
"billed-yearly": "Yıllık faturalandırılır",
"compare-plans": "Planları Karşılaştır",
"faq-title": "Sıkça Sorulan Sorular",
"plan": {
"free": {
"name": "Ücretsiz",
"description": "Temel AI analizleri ile başlayın"
},
"plus": {
"name": "Plus",
"description": "Daha fazla analiz ve özel özellikler"
},
"premium": {
"name": "Premium",
"description": "Sınırsız erişim ve profesyonel araçlar"
}
},
"feature": {
"daily-analyses": "Günlük AI analiz",
"daily-coupons": "Günlük kupon",
"basic-analysis": "Temel maç analizi",
"detailed-analysis": "Detaylı AI analizi",
"h2h-comparison": "H2H karşılaştırma",
"coupon-builder": "Kupon oluşturucu",
"spor-toto": "Spor Toto analizi",
"ad-free": "Reklamsız deneyim",
"priority-support": "Öncelikli destek",
"unlimited": "Sınırsız"
},
"faq": {
"q1": "Planımı istediğim zaman değiştirebilir miyim?",
"a1": "Evet, planınızı istediğiniz zaman yükseltebilir veya düşürebilirsiniz. Yükseltmeler anında aktif olur.",
"q2": "İptal nasıl çalışır?",
"a2": "Mevcut fatura döneminizin sonuna kadar erişiminiz devam eder. Otomatik olarak Ücretsiz plana geçersiniz.",
"q3": "Ödeme yöntemleri nelerdir?",
"a3": "Kredi kartı ve banka kartı ile güvenli ödeme yapabilirsiniz. Tüm ödemeler Paddle altyapısı ile işlenir.",
"q4": "Deneme süresi var mı?",
"a4": "Ücretsiz plan ile tüm temel özellikleri deneyebilirsiniz. Yükseltme yaptığınızda anında premium özelliklere erişirsiniz."
}
},
"subscription": {
"title": "Abonelik Bilgileri",
"current-plan": "Mevcut Plan",
"plan-badge": {
"free": "Ücretsiz",
"plus": "Plus",
"premium": "Premium"
},
"upgrade-cta": "Planı Yükselt",
"manage": "Aboneliği Yönet",
"cancel": "Aboneliği İptal Et",
"cancel-confirm-title": "Aboneliği İptal Et",
"cancel-confirm-message": "Mevcut fatura döneminizin sonuna kadar erişiminiz devam edecek. İptal etmek istediğinizden emin misiniz?",
"cancel-reason-placeholder": "İptal nedeninizi paylaşır mısınız? (Opsiyonel)",
"cancelled-info": "Aboneliğiniz {date} tarihinde sona erecek",
"next-billing": "Sonraki fatura tarihi",
"usage": {
"title": "Günlük Kullanım",
"analyses": "AI Analiz",
"coupons": "Kupon",
"of": "/",
"remaining": "kalan"
}
},
"refund-policy": "İade Politikası"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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"; import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: "standalone",
experimental: { experimental: {
optimizePackageImports: ["@chakra-ui/react"], optimizePackageImports: ["@chakra-ui/react"],
}, },
reactCompiler: true, reactCompiler: true,
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
},
],
},
];
},
async rewrites() { async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
throw new Error("url is not defined");
}
// Remove the trailing /api to map uploads from the base backend url
const backendUrl = apiUrl.replace(/\/api\/?$/, "");
return [ return [
{ {
source: "/api/backend/:path*", source: "/api/backend/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3005/api'}/:path*`, destination: `${apiUrl}/:path*`,
},
{
source: "/uploads/:path*",
destination: `${backendUrl}/uploads/:path*`,
}, },
]; ];
}, },
+188 -180
View File
@@ -1,27 +1,29 @@
{ {
"name": "Suggest-Bet-FE-v2", "name": "iddaai-fe",
"version": "0.0.1", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "Suggest-Bet-FE-v2", "name": "iddaai-fe",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^3.28.0", "@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@paddle/paddle-js": "^1.6.4",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"aos": "^2.3.4", "aos": "^2.3.4",
"axios": "^1.13.1", "axios": "^1.13.1",
"framer-motion": "^12.34.1", "framer-motion": "^12.34.1",
"i18next": "^25.6.0", "i18next": "^25.6.0",
"next": "16.0.0", "next": "^16.2.5",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-intl": "^4.4.0", "next-intl": "^4.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"postcss": "^8.5.14",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.65.0", "react-hook-form": "^7.65.0",
@@ -1280,51 +1282,30 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
}, },
"node_modules/@formatjs/ecma402-abstract": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz",
"integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==",
"dependencies": {
"@formatjs/fast-memoize": "3.1.0",
"@formatjs/intl-localematcher": "0.8.1",
"decimal.js": "^10.6.0",
"tslib": "^2.8.1"
}
},
"node_modules/@formatjs/fast-memoize": { "node_modules/@formatjs/fast-memoize": {
"version": "3.1.0", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz",
"integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==", "integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA=="
"dependencies": {
"tslib": "^2.8.1"
}
}, },
"node_modules/@formatjs/icu-messageformat-parser": { "node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.1", "version": "3.5.7",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.7.tgz",
"integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==", "integrity": "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww==",
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "3.1.1", "@formatjs/icu-skeleton-parser": "2.1.7"
"@formatjs/icu-skeleton-parser": "2.1.1",
"tslib": "^2.8.1"
} }
}, },
"node_modules/@formatjs/icu-skeleton-parser": { "node_modules/@formatjs/icu-skeleton-parser": {
"version": "2.1.1", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.7.tgz",
"integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==", "integrity": "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA=="
"dependencies": {
"@formatjs/ecma402-abstract": "3.1.1",
"tslib": "^2.8.1"
}
}, },
"node_modules/@formatjs/intl-localematcher": { "node_modules/@formatjs/intl-localematcher": {
"version": "0.8.1", "version": "0.8.6",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz",
"integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==", "integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==",
"dependencies": { "dependencies": {
"@formatjs/fast-memoize": "3.1.0", "@formatjs/fast-memoize": "3.1.4"
"tslib": "^2.8.1"
} }
}, },
"node_modules/@google/genai": { "node_modules/@google/genai": {
@@ -1936,9 +1917,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz",
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==" "integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA=="
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.0.0", "version": "16.0.0",
@@ -1950,9 +1931,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz",
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==", "integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1965,9 +1946,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz",
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==", "integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1980,9 +1961,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz",
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==", "integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1995,9 +1976,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz",
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==", "integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2010,9 +1991,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz",
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==", "integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2025,9 +2006,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz",
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==", "integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2040,9 +2021,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz",
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==", "integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2055,9 +2036,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz",
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==", "integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2113,6 +2094,12 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@paddle/paddle-js": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.6.4.tgz",
"integrity": "sha512-ncfnS6I8mCX6krZ3Sgz2iAYivGmhdI81yt9mT6prtPj4Ipd9J3M12LCJRUFL4FB7BYeeuV04c33RSEnbZUBCaA==",
"license": "Apache-2.0"
},
"node_modules/@pandacss/is-valid-prop": { "node_modules/@pandacss/is-valid-prop": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.1.tgz",
@@ -2408,9 +2395,9 @@
} }
}, },
"node_modules/@parcel/watcher/node_modules/picomatch": { "node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2438,9 +2425,9 @@
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
}, },
"node_modules/@protobufjs/codegen": { "node_modules/@protobufjs/codegen": {
"version": "2.0.4", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
}, },
"node_modules/@protobufjs/eventemitter": { "node_modules/@protobufjs/eventemitter": {
"version": "1.1.0", "version": "1.1.0",
@@ -2462,9 +2449,9 @@
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
}, },
"node_modules/@protobufjs/inquire": { "node_modules/@protobufjs/inquire": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="
}, },
"node_modules/@protobufjs/path": { "node_modules/@protobufjs/path": {
"version": "1.1.2", "version": "1.1.2",
@@ -2477,9 +2464,9 @@
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
}, },
"node_modules/@protobufjs/utf8": { "node_modules/@protobufjs/utf8": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="
}, },
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
@@ -3052,21 +3039,21 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -4274,9 +4261,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -4548,13 +4535,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.5", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.16.0",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/axobject-query": { "node_modules/axobject-query": {
@@ -4618,7 +4605,6 @@
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
@@ -4632,9 +4618,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5961,15 +5942,15 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true "dev": true
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -6246,19 +6227,19 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/glob/node_modules/minimatch": { "node_modules/glob/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -6562,9 +6543,9 @@
} }
}, },
"node_modules/icu-minify": { "node_modules/icu-minify": {
"version": "4.8.3", "version": "4.11.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.0.tgz",
"integrity": "sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==", "integrity": "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -6623,14 +6604,12 @@
} }
}, },
"node_modules/intl-messageformat": { "node_modules/intl-messageformat": {
"version": "11.1.2", "version": "11.2.4",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.4.tgz",
"integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==", "integrity": "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==",
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "3.1.1", "@formatjs/fast-memoize": "3.1.4",
"@formatjs/fast-memoize": "3.1.0", "@formatjs/icu-messageformat-parser": "3.5.7"
"@formatjs/icu-messageformat-parser": "3.5.1",
"tslib": "^2.8.1"
} }
}, },
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
@@ -7354,9 +7333,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -7447,14 +7426,14 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.0.0", "version": "16.2.5",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz",
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==", "integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@next/env": "16.0.0", "@next/env": "16.2.5",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
"styled-jsx": "5.1.6" "styled-jsx": "5.1.6"
@@ -7466,15 +7445,15 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.0", "@next/swc-darwin-arm64": "16.2.5",
"@next/swc-darwin-x64": "16.0.0", "@next/swc-darwin-x64": "16.2.5",
"@next/swc-linux-arm64-gnu": "16.0.0", "@next/swc-linux-arm64-gnu": "16.2.5",
"@next/swc-linux-arm64-musl": "16.0.0", "@next/swc-linux-arm64-musl": "16.2.5",
"@next/swc-linux-x64-gnu": "16.0.0", "@next/swc-linux-x64-gnu": "16.2.5",
"@next/swc-linux-x64-musl": "16.0.0", "@next/swc-linux-x64-musl": "16.2.5",
"@next/swc-win32-arm64-msvc": "16.0.0", "@next/swc-win32-arm64-msvc": "16.2.5",
"@next/swc-win32-x64-msvc": "16.0.0", "@next/swc-win32-x64-msvc": "16.2.5",
"sharp": "^0.34.4" "sharp": "^0.34.5"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
@@ -7531,9 +7510,9 @@
} }
}, },
"node_modules/next-intl": { "node_modules/next-intl": {
"version": "4.8.3", "version": "4.11.0",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.3.tgz", "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.0.tgz",
"integrity": "sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==", "integrity": "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -7544,16 +7523,15 @@
"@formatjs/intl-localematcher": "^0.8.1", "@formatjs/intl-localematcher": "^0.8.1",
"@parcel/watcher": "^2.4.1", "@parcel/watcher": "^2.4.1",
"@swc/core": "^1.15.2", "@swc/core": "^1.15.2",
"icu-minify": "^4.8.3", "icu-minify": "^4.11.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.8.3", "next-intl-swc-plugin-extractor": "^4.11.0",
"po-parser": "^2.1.1", "po-parser": "^2.1.1",
"use-intl": "^4.8.3" "use-intl": "^4.11.0"
}, },
"peerDependencies": { "peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
"typescript": "^5.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -7562,9 +7540,9 @@
} }
}, },
"node_modules/next-intl-swc-plugin-extractor": { "node_modules/next-intl-swc-plugin-extractor": {
"version": "4.8.3", "version": "4.11.0",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.3.tgz", "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.0.tgz",
"integrity": "sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==" "integrity": "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A=="
}, },
"node_modules/next-themes": { "node_modules/next-themes": {
"version": "0.4.6", "version": "0.4.6",
@@ -7583,6 +7561,33 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/nextjs-toploader": { "node_modules/nextjs-toploader": {
"version": "3.9.17", "version": "3.9.17",
"resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz", "resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz",
@@ -8034,9 +8039,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -8060,9 +8065,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.31", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -8078,9 +8083,9 @@
} }
], ],
"dependencies": { "dependencies": {
"nanoid": "^3.3.6", "nanoid": "^3.3.11",
"picocolors": "^1.0.0", "picocolors": "^1.1.1",
"source-map-js": "^1.0.2" "source-map-js": "^1.2.1"
}, },
"engines": { "engines": {
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
@@ -8152,21 +8157,21 @@
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
}, },
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.5.4", "version": "7.5.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@protobufjs/aspromise": "^1.1.2", "@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2", "@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4", "@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0", "@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0", "@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2", "@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0", "@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2", "@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0", "@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0", "@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0", "@types/node": ">=13.7.0",
"long": "^5.0.0" "long": "^5.0.0"
}, },
@@ -8180,9 +8185,12 @@
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==" "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
}, },
"node_modules/proxy-memoize": { "node_modules/proxy-memoize": {
"version": "3.0.1", "version": "3.0.1",
@@ -9093,9 +9101,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"peer": true, "peer": true,
"engines": { "engines": {
@@ -9411,9 +9419,9 @@
} }
}, },
"node_modules/use-intl": { "node_modules/use-intl": {
"version": "4.8.3", "version": "4.11.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.0.tgz",
"integrity": "sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==", "integrity": "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -9423,7 +9431,7 @@
"dependencies": { "dependencies": {
"@formatjs/fast-memoize": "^3.1.0", "@formatjs/fast-memoize": "^3.1.0",
"@schummar/icu-type-parser": "1.21.5", "@schummar/icu-type-parser": "1.21.5",
"icu-minify": "^4.8.3", "icu-minify": "^4.11.0",
"intl-messageformat": "^11.1.0" "intl-messageformat": "^11.1.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -9662,9 +9670,9 @@
"dev": true "dev": true
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "1.10.2", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
} }
+5 -3
View File
@@ -1,9 +1,9 @@
{ {
"name": "Suggest-Bet-FE-v2", "name": "iddaai-fe",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --webpack --experimental-https -p 3001", "dev": "next dev -p 6195",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
@@ -13,16 +13,18 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@paddle/paddle-js": "^1.6.4",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"aos": "^2.3.4", "aos": "^2.3.4",
"axios": "^1.13.1", "axios": "^1.13.1",
"framer-motion": "^12.34.1", "framer-motion": "^12.34.1",
"i18next": "^25.6.0", "i18next": "^25.6.0",
"next": "16.0.0", "next": "^16.2.5",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-intl": "^4.4.0", "next-intl": "^4.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"postcss": "^8.5.14",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.65.0", "react-hook-form": "^7.65.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

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 Footer from "@/components/layout/footer/footer";
import { Box, Flex } from '@chakra-ui/react'; import { Box, Flex } from "@chakra-ui/react";
function AuthLayout({ children }: { children: React.ReactNode }) { function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<Flex minH='100vh' direction='column'> <Flex minH="100vh" direction="column">
<Box as='main'>{children}</Box> <Box as="main">{children}</Box>
<Footer /> <Footer />
</Flex> </Flex>
); );
+24 -225
View File
@@ -1,231 +1,30 @@
"use client"; import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import SignInForm from "./signin-form";
import { export async function generateMetadata(props: {
Box, params: Promise<{ locale: string }>;
Flex, }): Promise<Metadata> {
Heading, const params = await props.params;
Input, const { locale } = params;
Link as ChakraLink, const t = await getTranslations({ locale, namespace: "seo" });
Text, const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
ClientOnly,
} from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Switch } from "@/components/ui/forms/switch";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import signInImage from "../../../../../public/assets/img/sign-in-image.png";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock } from "react-icons/bi";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useRouter } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
const schema = yup.object({ const pathSegment = "signin";
email: yup.string().email().required(),
password: yup.string().required(),
});
type SignInForm = yup.InferType<typeof schema>; return {
title: t("signin.title"),
const defaultValues = { description: t("signin.description"),
email: "test@test.com.tr", alternates: {
password: "test1234", canonical: `${siteUrl}/${locale}/${pathSegment}`,
}; languages: {
en: `${siteUrl}/en/${pathSegment}`,
function SignInPage() { tr: `${siteUrl}/tr/${pathSegment}`,
const t = useTranslations(); },
const router = useRouter(); },
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignInForm>({
resolver: yupResolver(schema),
mode: "onChange",
defaultValues,
});
const onSubmit = async (formData: SignInForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
toaster.error({
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally {
setLoading(false);
}
}; };
return (
<Box position="relative">
<Flex
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
w="100%"
maxW="1044px"
mx="auto"
justifyContent="space-between"
mb="30px"
pt={{ sm: "100px", md: "0px" }}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<Flex
direction="column"
w="100%"
background="transparent"
p="10"
mt={{ md: "150px", lg: "80px" }}
>
<Heading
color={{ base: "primary.400", _dark: "primary.200" }}
fontSize="32px"
mb="10px"
fontWeight="bold"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<Switch colorPalette="teal" label={t("auth.remember-me")}>
{t("auth.remember-me")}
</Switch>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.dont-have-account")}
<ChakraLink
as={Link}
href="/signup"
color={{ base: "primary.400", _dark: "primary.200" }}
ms="5px"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-up")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
<Box
display={{ base: "none", md: "block" }}
overflowX="hidden"
h="100%"
w="40vw"
position="absolute"
right="0px"
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
);
} }
export default SignInPage; export default function SignInPage() {
return <SignInForm />;
}
@@ -0,0 +1,229 @@
"use client";
import {
Box,
Flex,
Heading,
Input,
Link as ChakraLink,
Text,
ClientOnly,
} from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Switch } from "@/components/ui/forms/switch";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import signInImage from "../../../../../public/assets/img/sign-in-image.png";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock } from "react-icons/bi";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useRouter } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().required(),
});
type SignInForm = yup.InferType<typeof schema>;
const defaultValues = {
email: "test@test.com.tr",
password: "test1234",
};
export default function SignInForm() {
const t = useTranslations();
const router = useRouter();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignInForm>({
resolver: yupResolver(schema),
mode: "onChange",
defaultValues,
});
const onSubmit = async (formData: SignInForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
toaster.error({
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally {
setLoading(false);
}
};
return (
<Box position="relative">
<Flex
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
w="100%"
maxW="1044px"
mx="auto"
justifyContent="space-between"
mb="30px"
pt={{ sm: "100px", md: "0px" }}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<Flex
direction="column"
w="100%"
background="transparent"
p="10"
mt={{ md: "150px", lg: "80px" }}
>
<Heading
color={{ base: "primary.400", _dark: "primary.200" }}
fontSize="32px"
mb="10px"
fontWeight="bold"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<Switch colorPalette="teal" label={t("auth.remember-me")}>
{t("auth.remember-me")}
</Switch>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.dont-have-account")}
<ChakraLink
as={Link}
href="/signup"
color={{ base: "primary.400", _dark: "primary.200" }}
ms="5px"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-up")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
<Box
display={{ base: "none", md: "block" }}
overflowX="hidden"
h="100%"
w="40vw"
position="absolute"
right="0px"
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
);
}
+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 { export async function generateMetadata(props: {
Box, params: Promise<{ locale: string }>;
Flex, }): Promise<Metadata> {
Input, const params = await props.params;
Link as ChakraLink, const { locale } = params;
Text, const t = await getTranslations({ locale, namespace: "seo" });
ClientOnly, const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
} from "@chakra-ui/react";
import signUpImage from "../../../../../public/assets/img/sign-up-image.png";
import { Button } from "@/components/ui/buttons/button";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock, BiUser } from "react-icons/bi";
import { Link } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { useRouter } from "next/navigation";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { authService } from "@/lib/api/auth/service";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
const schema = yup.object({ const pathSegment = "signup";
name: yup.string().required(),
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
type SignUpForm = yup.InferType<typeof schema>; return {
title: t("signup.title"),
function SignUpPage() { description: t("signup.description"),
const t = useTranslations(); alternates: {
const router = useRouter(); canonical: `${siteUrl}/${locale}/${pathSegment}`,
const [isSubmitting, setIsSubmitting] = useState(false); languages: {
en: `${siteUrl}/en/${pathSegment}`,
const { tr: `${siteUrl}/tr/${pathSegment}`,
handleSubmit, },
register, },
formState: { errors },
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: "onChange" });
const onSubmit = async (formData: SignUpForm) => {
setIsSubmitting(true);
try {
await authService.register({
email: formData.email,
password: formData.password,
firstName: formData.name,
lastName: "",
});
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
if (error instanceof Error && error.message) {
toaster.error({
title: error.message,
type: "error",
});
}
// other errors are handled by api-service interceptor (toast + 422 display)
} finally {
setIsSubmitting(false);
}
return formData;
}; };
return (
<Box>
<Box
position="absolute"
minH={{ base: "70vh", md: "50vh" }}
w={{ md: "calc(100vw - 50px)" }}
borderRadius={{ md: "15px" }}
left="0"
right="0"
bgRepeat="no-repeat"
overflow="hidden"
zIndex="-1"
top="0"
bgImage={`url(${signUpImage.src})`}
bgSize="cover"
mx={{ md: "auto" }}
mt={{ md: "14px" }}
/>
<Flex
w="full"
h="full"
direction="column"
alignItems="center"
justifyContent="center"
>
<Text
fontSize={{ base: "2xl", md: "3xl", lg: "4xl" }}
color="white"
fontWeight="bold"
mt={{ base: "2rem", md: "4.5rem", "2xl": "6.5rem" }}
mb={{ base: "2rem", md: "3rem", "2xl": "4rem" }}
>
{t("auth.create-an-account-now")}
</Text>
<Flex
direction="column"
w={{ base: "100%", md: "445px" }}
background="transparent"
borderRadius="15px"
p="10"
mx={{ base: "100px" }}
bg="bg.panel"
boxShadow="0 20px 27px 0 rgb(0 0 0 / 5%)"
mb="8"
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
flexDirection="column"
alignItems="center"
justifyContent="start"
w="100%"
>
<Field
mb="24px"
label={t("name")}
errorText={errors.name?.message}
invalid={!!errors.name}
>
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("name")}
size="lg"
{...register("name")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
type="submit"
bg="primary.400"
color="white"
fontWeight="bold"
w="100%"
h="45px"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
loading={isSubmitting}
>
{isSubmitting ? t("auth.registering") : t("auth.sign-up")}
</Button>
</ClientOnly>
</Field>
</Flex>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.already-have-an-account")}
<ChakraLink
as={Link}
color={{ base: "primary.400", _dark: "primary.200" }}
ml="2"
href="/signin"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-in")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
</Box>
);
} }
export default SignUpPage; export default function SignUpPage() {
return <SignUpForm />;
}
@@ -0,0 +1,236 @@
"use client";
import {
Box,
Flex,
Input,
Link as ChakraLink,
Text,
ClientOnly,
} from "@chakra-ui/react";
import signUpImage from "../../../../../public/assets/img/sign-up-image.png";
import { Button } from "@/components/ui/buttons/button";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock, BiUser } from "react-icons/bi";
import { Link } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { useRouter } from "next/navigation";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { authService } from "@/lib/api/auth/service";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
const schema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
type SignUpForm = yup.InferType<typeof schema>;
export default function SignUpForm() {
const t = useTranslations();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: "onChange" });
const onSubmit = async (formData: SignUpForm) => {
setIsSubmitting(true);
try {
await authService.register({
email: formData.email,
password: formData.password,
firstName: formData.name,
lastName: "",
});
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
if (error instanceof Error && error.message) {
toaster.error({
title: error.message,
type: "error",
});
}
// other errors are handled by api-service interceptor (toast + 422 display)
} finally {
setIsSubmitting(false);
}
return formData;
};
return (
<Box>
<Box
position="absolute"
minH={{ base: "70vh", md: "50vh" }}
w={{ md: "calc(100vw - 50px)" }}
borderRadius={{ md: "15px" }}
left="0"
right="0"
bgRepeat="no-repeat"
overflow="hidden"
zIndex="-1"
top="0"
bgImage={`url(${signUpImage.src})`}
bgSize="cover"
mx={{ md: "auto" }}
mt={{ md: "14px" }}
/>
<Flex
w="full"
h="full"
direction="column"
alignItems="center"
justifyContent="center"
>
<Text
fontSize={{ base: "2xl", md: "3xl", lg: "4xl" }}
color="white"
fontWeight="bold"
mt={{ base: "2rem", md: "4.5rem", "2xl": "6.5rem" }}
mb={{ base: "2rem", md: "3rem", "2xl": "4rem" }}
>
{t("auth.create-an-account-now")}
</Text>
<Flex
direction="column"
w={{ base: "100%", md: "445px" }}
background="transparent"
borderRadius="15px"
p="10"
mx={{ base: "100px" }}
bg="bg.panel"
boxShadow="0 20px 27px 0 rgb(0 0 0 / 5%)"
mb="8"
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
flexDirection="column"
alignItems="center"
justifyContent="start"
w="100%"
>
<Field
mb="24px"
label={t("name")}
errorText={errors.name?.message}
invalid={!!errors.name}
>
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("name")}
size="lg"
{...register("name")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
type="submit"
bg="primary.400"
color="white"
fontWeight="bold"
w="100%"
h="45px"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
loading={isSubmitting}
>
{isSubmitting ? t("auth.registering") : t("auth.sign-up")}
</Button>
</ClientOnly>
</Field>
</Flex>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.already-have-an-account")}
<ChakraLink
as={Link}
color={{ base: "primary.400", _dark: "primary.200" }}
ml="2"
href="/signin"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-in")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
</Box>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { notFound } from 'next/navigation'; import { notFound } from "next/navigation";
export default function CatchAllPage() { export default function CatchAllPage() {
notFound(); 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() { function AboutPage() {
return <div>AboutPage</div>; return <div>AboutPage</div>;
+24 -5
View File
@@ -5,12 +5,31 @@ import { isAdminRole } from "@/lib/auth/roles";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "admin";
return { return {
title: `${t("admin.title")} | Suggest Bet`, title: t("admin.title"),
description: description: t("admin.description"),
"Admin panel for managing users, monitoring predictions, and system overview.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+24 -4
View File
@@ -1,11 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import AnalysisContent from "@/components/analysis/analysis-content"; import AnalysisContent from "@/components/analysis/analysis-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "analysis";
return { return {
title: `${t("analysis.title")} | Suggest Bet`, title: t("analysis.title"),
description: "AI-powered multi-match analysis for coupon generation.", description: t("analysis.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
@@ -1,12 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import CouponBuilderContent from "@/components/coupons/coupon-builder-content"; import CouponBuilderContent from "@/components/coupons/coupon-builder-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "coupon-builder";
return { return {
title: `${t("coupons.builder-title")} | Suggest Bet`, title: t("coupon-builder.title"),
description: description: t("coupon-builder.description"),
"Build your coupon with AI-powered suggestions. Choose your strategy and let AI optimize your bets.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
@@ -1,12 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import CouponHistoryContent from "@/components/coupons/coupon-history-content"; import CouponHistoryContent from "@/components/coupons/coupon-history-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "coupon-history";
return { return {
title: `${t("coupons.history-title")} | Suggest Bet`, title: t("coupon-history.title"),
description: description: t("coupon-history.description"),
"View your coupon history, track wins and losses, and analyze your betting performance.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+23 -5
View File
@@ -1,13 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import DashboardContent from "@/components/dashboard/dashboard-content"; import DashboardContent from "@/components/dashboard/dashboard-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "dashboard";
return { return {
title: `${t("dashboard.title")} | Suggest Bet`, title: t("dashboard.title"),
description: description: t("dashboard.description"),
"Your personalized betting dashboard with predictions, value bets, and match insights.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+25 -4
View File
@@ -1,11 +1,32 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import H2HContent from "@/components/h2h/h2h-content"; import H2HContent from "@/components/h2h/h2h-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "h2h";
return { return {
title: `${t("matches.head-to-head")} | Suggest Bet`, title: t("h2h.title"),
description: "Compare two teams and view their head-to-head match history.", description: t("h2h.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+24 -5
View File
@@ -1,13 +1,32 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import HomeContent from "@/components/home/home-content"; import HomeContent from "@/components/home/home-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "home";
return { return {
title: `${t("home")} | Suggest Bet`, title: t("home.title"),
description: description: t("home.description"),
"AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+7 -7
View File
@@ -1,15 +1,15 @@
'use client'; "use client";
import { Container, Flex } from '@chakra-ui/react'; import { Container, Flex } from "@chakra-ui/react";
import Header from '@/components/layout/header/header'; import Header from "@/components/layout/header/header";
import Footer from '@/components/layout/footer/footer'; import Footer from "@/components/layout/footer/footer";
import BackToTop from '@/components/ui/back-to-top'; import BackToTop from "@/components/ui/back-to-top";
function MainLayout({ children }: { children: React.ReactNode }) { function MainLayout({ children }: { children: React.ReactNode }) {
return ( return (
<Flex minH='100vh' direction='column'> <Flex minH="100vh" direction="column">
<Header /> <Header />
<Container as='main' maxW='8xl' flex='1' py={4}> <Container as="main" maxW="8xl" flex="1" py={4}>
{children} {children}
</Container> </Container>
<BackToTop /> <BackToTop />
@@ -0,0 +1,34 @@
import { getTranslations } from "next-intl/server";
import LeagueDetailContent from "@/components/leagues/league-detail-content";
import { Metadata } from "next";
export async function generateMetadata(props: {
params: Promise<{ locale: string; id: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale, id } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
const pathSegment = `leagues/${id}`;
return {
title: `${t("leagues.title")} - Detay`,
description: t("leagues.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
};
}
export default async function LeagueDetailPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params;
return <LeagueDetailContent leagueId={id} />;
}
+24 -4
View File
@@ -1,11 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import LeaguesContent from "@/components/leagues/leagues-content"; import LeaguesContent from "@/components/leagues/leagues-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "leagues";
return { return {
title: `${t("leagues.title")} | Suggest Bet`, title: t("leagues.title"),
description: "Browse football and basketball leagues, countries, and teams.", description: t("leagues.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+23 -3
View File
@@ -1,11 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import MatchDetailContent from "@/components/matches/match-detail-content"; import MatchDetailContent from "@/components/matches/match-detail-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "matches/[id]";
return { return {
title: `${t("matches.match-details")} | Suggest Bet`, title: t("matches.title"),
description: t("matches.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+26 -5
View File
@@ -1,13 +1,34 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import MatchesContent from "@/components/matches/matches-content"; import MatchesContent from "@/components/matches/matches-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "matches";
return { return {
title: `${t("matches.title")} | Suggest Bet`,
description:
"Browse and analyze upcoming football and basketball matches with AI predictions.", title: t("matches.title"),
description: t("matches.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+24 -5
View File
@@ -1,12 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import PredictionsContent from "@/components/predictions/predictions-content"; import PredictionsContent from "@/components/predictions/predictions-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "predictions";
return { return {
title: `${t("predictions.title")} | Suggest Bet`, title: t("predictions.title"),
description: description: t("predictions.description"),
"AI-powered match predictions with confidence scores, value bets, and prediction history.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+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 { getTranslations } from "next-intl/server";
import ProfileContent from "@/components/profile/profile-content"; import ProfileContent from "@/components/profile/profile-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "profile";
return { return {
title: `${t("profile.title")} | Suggest Bet`, title: t("profile.title"),
description: description: t("profile.description"),
"Manage your profile, view account info, and track your betting statistics.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
@@ -0,0 +1,122 @@
import { Metadata } from "next";
import { getLocale } from "next-intl/server";
import LegalPage from "@/components/legal/legal-page";
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
const isTr = locale === "tr";
return {
title: isTr ? "İade Politikası — iddaai" : "Refund Policy — iddaai",
description: isTr
? "iddaai abonelik iptali ve iade koşulları hakkında bilgi edinin."
: "Learn about iddaai subscription cancellation and refund conditions.",
};
}
const contentTR = {
title: "İade Politikası",
lastUpdated: "Son güncelleme: Mayıs 2026",
sections: [
{
title: "1. Genel İlkeler",
content: [
"iddaai olarak müşteri memnuniyetini ön planda tutuyoruz. Abonelik satın alımlarında adil bir iade politikası uygulamaktayız.",
"Tüm ödemeler Paddle altyapısı üzerinden güvenli şekilde işlenir.",
],
},
{
title: "2. İptal ve İade Koşulları",
content: [
"Aylık abonelikler: Ödeme tarihinden itibaren 7 gün içinde herhangi bir neden göstermeksizin iade talebinde bulunabilirsiniz.",
"Yıllık abonelikler: Ödeme tarihinden itibaren 14 gün içinde iade talep edebilirsiniz.",
"Belirtilen süreler dolduktan sonra yapılan iade talepleri, istisnai durumlar dışında kabul edilmez.",
"Ücretsiz deneme süresini kullandıktan sonra yapılan ödemelerde deneme süresi iade kapsamı dışındadır.",
],
},
{
title: "3. İade Süreci",
content: [
"İade talebini destek@iddaai.com adresine e-posta göndererek veya hesabınızdaki destek kanalı üzerinden iletebilirsiniz.",
"Talebiniz en geç 3 iş günü içinde değerlendirilir.",
"Onaylanan iadeler, ödemenin yapıldığı ödeme yöntemine 5-10 iş günü içinde yansıtılır.",
],
},
{
title: "4. İade Edilmeyecek Durumlar",
content: [
"Kullanım koşullarını ihlal etmeniz nedeniyle hesabınızın askıya alınması veya kapatılması.",
"İade süresinin dolmasından sonra yapılan talepler (istisnai durumlar hariç).",
"Kısmi ay kullanımları için orantılı iade yapılmaz.",
],
},
{
title: "5. Abonelik İptali",
content: [
"Aboneliğinizi istediğiniz zaman iptal edebilirsiniz. İptal işlemi, mevcut dönem sonunda geçerli olur.",
"İptal sonrasında hesabınız abonelik bitiş tarihine kadar aktif kalmaya devam eder.",
"İptal için hesabınızdaki Abonelik Yönetimi bölümünü veya destek@iddaai.com adresini kullanabilirsiniz.",
],
},
{
title: "6. İletişim",
content: "İade ve iptal talepleriniz için: destek@iddaai.com",
},
],
};
const contentEN = {
title: "Refund Policy",
lastUpdated: "Last updated: May 2026",
sections: [
{
title: "1. General Principles",
content: [
"At iddaai, we prioritize customer satisfaction and apply a fair refund policy for subscription purchases.",
"All payments are securely processed via the Paddle infrastructure.",
],
},
{
title: "2. Cancellation and Refund Conditions",
content: [
"Monthly subscriptions: You may request a refund within 7 days of payment without providing any reason.",
"Annual subscriptions: You may request a refund within 14 days of payment.",
"Refund requests made after the specified periods will not be accepted, except in exceptional circumstances.",
"Payments made after using a free trial period are not eligible for refund for the trial period.",
],
},
{
title: "3. Refund Process",
content: [
"You can submit a refund request by emailing support@iddaai.com or through the support channel in your account.",
"Your request will be reviewed within 3 business days.",
"Approved refunds will be reflected to the original payment method within 5-10 business days.",
],
},
{
title: "4. Non-Refundable Situations",
content: [
"Suspension or closure of your account due to violation of terms of service.",
"Requests made after the refund period has expired (except in exceptional circumstances).",
"No proportional refunds are made for partial month usage.",
],
},
{
title: "5. Subscription Cancellation",
content: [
"You may cancel your subscription at any time. The cancellation takes effect at the end of the current billing period.",
"After cancellation, your account remains active until the subscription end date.",
"To cancel, use the Subscription Management section in your account or contact support@iddaai.com.",
],
},
{
title: "6. Contact",
content: "For refund and cancellation requests: support@iddaai.com",
},
],
};
export default async function RefundPolicyPage() {
const locale = await getLocale();
const content = locale === "tr" ? contentTR : contentEN;
return <LegalPage {...content} />;
}
+24 -5
View File
@@ -1,12 +1,31 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import SporTotoContent from "@/components/spor-toto/spor-toto-content"; import SporTotoContent from "@/components/spor-toto/spor-toto-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "spor-toto";
return { return {
title: `${t("spor-toto.title")} | Suggest Bet`, title: t("spor-toto.title"),
description: description: t("spor-toto.description"),
"Spor Toto predictions with AI-powered analysis. Generate optimized system coupons with contrarian parimutuel strategy.", alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+25 -3
View File
@@ -1,10 +1,32 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import TeamDetailContent from "@/components/teams/team-detail-content"; import TeamDetailContent from "@/components/teams/team-detail-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "teams/[id]";
return { return {
title: `${t("nav.teams")} | Suggest Bet`, title: t("teams.title"),
description: t("teams.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
+25 -4
View File
@@ -1,11 +1,32 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import TeamsContent from "@/components/teams/teams-content"; import TeamsContent from "@/components/teams/teams-content";
export async function generateMetadata() { import { Metadata } from "next";
const t = await getTranslations();
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
// Next.js parses route variables automatically, but for canonical we'll just use a clean relative base if available,
// or let next.js construct it implicitly from metadataBase if not explicitly specified.
// We'll set alternates just for languages based on current path segment as a best effort
const pathSegment = "teams";
return { return {
title: `${t("nav.teams")} | Suggest Bet`, title: t("teams.title"),
description: "Search and explore football teams, view match history and stats.", description: t("teams.description"),
alternates: {
canonical: `${siteUrl}/${locale}/${pathSegment}`,
languages: {
en: `${siteUrl}/en/${pathSegment}`,
tr: `${siteUrl}/tr/${pathSegment}`,
},
},
}; };
} }
@@ -0,0 +1,138 @@
import { Metadata } from "next";
import { getLocale } from "next-intl/server";
import LegalPage from "@/components/legal/legal-page";
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
const isTr = locale === "tr";
return {
title: isTr ? "Kullanım Koşulları — iddaai" : "Terms of Service — iddaai",
description: isTr
? "iddaai platformunu kullanmadan önce kullanım koşullarını okuyunuz."
: "Please read our terms of service before using the iddaai platform.",
};
}
const contentTR = {
title: "Kullanım Koşulları",
lastUpdated: "Son güncelleme: Mayıs 2026",
sections: [
{
title: "1. Genel Hükümler",
content: [
"Bu Kullanım Koşulları, iddaai.com platformunu kullanan tüm kullanıcılar için geçerlidir. Platformu kullanarak bu koşulları kabul etmiş sayılırsınız.",
"iddaai, yapay zeka destekli spor analiz ve tahmin hizmetleri sunan bir bilgi platformudur. Sunulan analizler bilgilendirme amaçlıdır; kesin sonuç garantisi içermez.",
],
},
{
title: "2. Hizmetin Kapsamı",
content: [
"iddaai, futbol ve diğer spor dallarına yönelik AI tabanlı istatistik analizleri, maç tahminleri ve olasılık değerlendirmeleri sunar.",
"Platform, kullanıcılara bahis kararlarında yardımcı olmak amacıyla tasarlanmıştır. Ancak hiçbir analiz kesin kazanç garantisi vermez.",
"Hizmetlerimiz; Tüm Maçlar, Tahminler, Kadro Analizleri, Kuponlar ve Karşılıklı Karşılaşma istatistiklerini kapsar.",
],
},
{
title: "3. Kullanıcı Yükümlülükleri",
content: [
"Platformu yalnızca yasal amaçlarla kullanmayı kabul edersiniz.",
"Bahis oynamanın yasal olduğu ülke veya bölgede ikamet etmekten ve yasal yaşı (18+) karşılamaktan tamamen siz sorumlusunuz.",
"Hesap bilgilerinizi üçüncü şahıslarla paylaşmamalısınız.",
"Platformun içeriklerini izinsiz kopyalamak, dağıtmak veya ticari amaçla kullanmak yasaktır.",
],
},
{
title: "4. Sorumluluk Reddi",
content: [
"iddaai'nin sunduğu analizler ve tahminler tamamen bilgilendirme amaçlıdır. Bahis kayıplarından iddaai sorumlu tutulamaz.",
"Platform, bahis şirketleri ile herhangi bir bağlantısı bulunmamaktadır ve herhangi bir bahis şirketini tavsiye etmez.",
"Sunulan istatistikler ve olasılıklar, geçmiş veriler ve yapay zeka modelleri kullanılarak üretilmekte olup geleceği kesin olarak tahmin etmez.",
],
},
{
title: "5. Ücretli Üyelik",
content: [
"Bazı özellikler ücretli abonelik gerektirir. Abonelik detayları Fiyatlandırma sayfasında belirtilmiştir.",
"Ödemeler Paddle altyapısı üzerinden güvenli biçimde işlenir.",
"İptal ve iade koşulları için Geri Ödeme Politikamızı inceleyiniz.",
],
},
{
title: "6. Fikri Mülkiyet",
content: "Platform üzerindeki tüm içerik, tasarım, yazılım ve analizler iddaai'ye aittir. İzinsiz kullanım yasal işlem başlatılmasına neden olabilir.",
},
{
title: "7. Değişiklikler",
content: "iddaai, bu koşulları önceden bildirim yapmaksızın değiştirme hakkını saklı tutar. Güncel koşullar her zaman bu sayfada yayınlanır.",
},
{
title: "8. İletişim",
content: "Kullanım koşullarıyla ilgili sorularınız için: destek@iddaai.com",
},
],
};
const contentEN = {
title: "Terms of Service",
lastUpdated: "Last updated: May 2026",
sections: [
{
title: "1. General Terms",
content: [
"These Terms of Service apply to all users of the iddaai.com platform. By using the platform, you agree to these terms.",
"iddaai is an information platform offering AI-powered sports analysis and prediction services. The analyses provided are for informational purposes only and do not guarantee specific outcomes.",
],
},
{
title: "2. Scope of Service",
content: [
"iddaai provides AI-based statistical analyses, match predictions, and probability assessments for football and other sports.",
"The platform is designed to assist users in making betting decisions. However, no analysis guarantees a definite win.",
"Our services include All Matches, Predictions, Squad Analyses, Coupons, and Head-to-Head statistics.",
],
},
{
title: "3. User Obligations",
content: [
"You agree to use the platform for legal purposes only.",
"You are solely responsible for ensuring that sports betting is legal in your country or region and that you meet the legal age requirement (18+).",
"You must not share your account credentials with third parties.",
"Copying, distributing, or using the platform's content for commercial purposes without permission is prohibited.",
],
},
{
title: "4. Disclaimer",
content: [
"The analyses and predictions provided by iddaai are purely for informational purposes. iddaai cannot be held responsible for betting losses.",
"The platform has no affiliation with any bookmaker and does not endorse any betting company.",
"Statistics and probabilities are generated using historical data and AI models and do not predict the future with certainty.",
],
},
{
title: "5. Paid Membership",
content: [
"Some features require a paid subscription. Subscription details are listed on the Pricing page.",
"Payments are securely processed via the Paddle infrastructure.",
"For cancellation and refund conditions, please review our Refund Policy.",
],
},
{
title: "6. Intellectual Property",
content: "All content, design, software, and analyses on the platform belong to iddaai. Unauthorized use may result in legal action.",
},
{
title: "7. Changes",
content: "iddaai reserves the right to modify these terms without prior notice. The current terms are always published on this page.",
},
{
title: "8. Contact",
content: "For questions about the terms of service: support@iddaai.com",
},
],
};
export default async function TermsPage() {
const locale = await getLocale();
const content = locale === "tr" ? contentTR : contentEN;
return <LegalPage {...content} />;
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════
Suggest-Bet — Global CSS iddaai — Global CSS
Premium animations, gradients, and utility keyframes 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 { notFound } from "next/navigation";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { dir } from "i18next"; import { dir } from "i18next";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import "./global.css"; import "./global.css";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "seo" });
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
return {
metadataBase: new URL(siteUrl),
title: {
template: `%s | ${t("global.title").split(" | ")[0]}`,
default: t("global.title"),
},
description: t("global.description"),
keywords: t("global.keywords"),
openGraph: {
title: t("global.title"),
description: t("global.description"),
siteName: t("global.title").split(" | ")[0],
locale: locale,
type: "website",
},
twitter: {
card: "summary_large_image",
title: t("global.title"),
description: t("global.description"),
},
};
}
const bricolage = Bricolage_Grotesque({ const bricolage = Bricolage_Grotesque({
variable: "--font-bricolage", variable: "--font-bricolage",
subsets: ["latin"], subsets: ["latin"],
@@ -23,6 +57,27 @@ export default async function RootLayout({
notFound(); notFound();
} }
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://iddaai.com";
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "iddaai.com",
url: siteUrl,
potentialAction: {
"@type": "SearchAction",
target: `${siteUrl}/search?q={search_term_string}`,
"query-input": "required name=search_term_string",
},
};
const orgJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "iddaai.com",
url: siteUrl,
logo: `${siteUrl}/favicon/android-chrome-512x512.png`,
};
return ( return (
<html <html
lang={locale} lang={locale}
@@ -35,8 +90,16 @@ export default async function RootLayout({
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' /> <link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' /> */} <link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' /> */}
<link rel="manifest" href="/favicon/site.webmanifest" /> <link rel="manifest" href="/favicon/site.webmanifest" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
/>
</head> </head>
<body className={bricolage.variable}> <body className={bricolage.variable} suppressHydrationWarning>
<NextIntlClientProvider> <NextIntlClientProvider>
<Provider>{children}</Provider> <Provider>{children}</Provider>
</NextIntlClientProvider> </NextIntlClientProvider>
+23 -14
View File
@@ -1,27 +1,36 @@
import { Link } from '@/i18n/navigation'; import { Link } from "@/i18n/navigation";
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react'; import { Flex, Text, Button, VStack, Heading } from "@chakra-ui/react";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from "next-intl/server";
export default async function NotFoundPage() { export default async function NotFoundPage() {
const t = await getTranslations(); const t = await getTranslations();
return ( return (
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}> <Flex
h="100vh"
alignItems="center"
justifyContent="center"
textAlign="center"
px={6}
>
<VStack spaceY={6}> <VStack spaceY={6}>
<Heading <Heading
as='h1' as="h1"
fontSize={{ base: '5xl', md: '6xl' }} fontSize={{ base: "5xl", md: "6xl" }}
fontWeight='bold' fontWeight="bold"
color={{ base: 'primary.600', _dark: 'primary.400' }} color={{ base: "primary.600", _dark: "primary.400" }}
> >
{t('error.404')} {t("error.404")}
</Heading> </Heading>
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}> <Text
{t('error.not-found')} fontSize={{ base: "md", md: "lg" }}
color={{ base: "fg.muted", _dark: "white" }}
>
{t("error.not-found")}
</Text> </Text>
<Link href='/home' passHref> <Link href="/home" passHref>
<Button size={{ base: 'md', md: 'lg' }} rounded='md'> <Button size={{ base: "md", md: "lg" }} rounded="md">
{t('error.back-to-home')} {t("error.back-to-home")}
</Button> </Button>
</Link> </Link>
</VStack> </VStack>
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'; import { redirect } from "next/navigation";
export default async function Page() { export default async function Page() {
redirect('/home'); redirect("/home");
} }
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, Spinner,
Button, Button,
Separator, Separator,
Input,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import {
NativeSelectRoot,
NativeSelectField,
} from "@/components/ui/forms/native-select";
import { useTranslations, useFormatter } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { import {
SlideUp, SlideUp,
@@ -25,11 +30,19 @@ import {
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks"; import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types"; import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles"; import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu"; import {
import { useState } from "react"; LuUsers,
LuChartBar,
LuActivity,
LuShield,
LuPencil,
} from "react-icons/lu";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { EditUserModal } from "./edit-user-modal";
import LeagueTiersContent from "./league-tiers-content";
type AdminTab = "overview" | "users"; type AdminTab = "overview" | "users" | "league-tiers";
// ======================== // ========================
// Admin Stat Card // Admin Stat Card
@@ -82,7 +95,26 @@ function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
export default function AdminContent() { export default function AdminContent() {
const t = useTranslations("admin"); const t = useTranslations("admin");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const format = useFormatter();
const [activeTab, setActiveTab] = useState<AdminTab>("overview"); const [activeTab, setActiveTab] = useState<AdminTab>("overview");
const [editingUser, setEditingUser] = useState<AdminUserDto | null>(null);
const [searchParams, setSearchParams] = useState({
search: "",
role: "",
subscriptionStatus: "",
page: 1,
limit: 10,
});
const [debouncedSearch, setDebouncedSearch] = useState("");
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchParams.search);
setSearchParams((prev) => ({ ...prev, page: 1 }));
}, 500);
return () => clearTimeout(handler);
}, [searchParams.search]);
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
@@ -92,16 +124,24 @@ export default function AdminContent() {
const { data: analyticsData, isLoading: analyticsLoading } = const { data: analyticsData, isLoading: analyticsLoading } =
useAdminAnalytics(canAccessAdmin); useAdminAnalytics(canAccessAdmin);
const { data: usersData, isLoading: usersLoading } = useAdminUsers( const { data: usersData, isLoading: usersLoading } = useAdminUsers(
undefined, {
search: debouncedSearch,
role: searchParams.role,
subscriptionStatus: searchParams.subscriptionStatus,
page: searchParams.page,
limit: searchParams.limit,
},
canAccessAdmin, canAccessAdmin,
); );
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined; const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
const users = usersData?.data?.items ?? []; const users = usersData?.data?.items ?? [];
const meta = usersData?.data?.meta;
const tabs: { key: AdminTab; label: string }[] = [ const tabs: { key: AdminTab; label: string }[] = [
{ key: "overview", label: t("overview") }, { key: "overview", label: t("overview") },
{ key: "users", label: t("user-management") }, { key: "users", label: t("user-management") },
{ key: "league-tiers", label: "Lig Tier" },
]; ];
const getUserDisplayName = (user: AdminUserDto) => { const getUserDisplayName = (user: AdminUserDto) => {
@@ -127,13 +167,13 @@ export default function AdminContent() {
<VStack gap={3}> <VStack gap={3}>
<Badge colorPalette="red" variant="subtle" borderRadius="full"> <Badge colorPalette="red" variant="subtle" borderRadius="full">
<LuShield /> <LuShield />
Restricted {t("restricted")}
</Badge> </Badge>
<Heading as="h2" size="md"> <Heading as="h2" size="md">
Admin access required {t("admin-access-required")}
</Heading> </Heading>
<Text color="fg.muted" textAlign="center" maxW="md"> <Text color="fg.muted" textAlign="center" maxW="md">
This area is only available to superadmin accounts. {t("admin-access-description")}
</Text> </Text>
</VStack> </VStack>
</Card.Body> </Card.Body>
@@ -194,7 +234,9 @@ export default function AdminContent() {
<StaggerItem> <StaggerItem>
<AdminStat <AdminStat
label={t("total-users")} label={t("total-users")}
value={analytics?.totalUsers ?? analytics?.users?.total ?? 0} value={
analytics?.totalUsers ?? analytics?.users?.total ?? 0
}
icon={<LuUsers />} icon={<LuUsers />}
colorPalette="primary" colorPalette="primary"
/> />
@@ -202,15 +244,27 @@ export default function AdminContent() {
<StaggerItem> <StaggerItem>
<AdminStat <AdminStat
label={t("total-predictions")} label={t("total-predictions")}
value={analytics?.totalPredictions ?? analytics?.predictions ?? 0} value={
analytics?.totalPredictions ?? analytics?.predictions ?? 0
}
icon={<LuChartBar />} icon={<LuChartBar />}
colorPalette="green" colorPalette="green"
/> />
</StaggerItem> </StaggerItem>
<StaggerItem>
<AdminStat
label={t("premium-users")}
value={analytics?.users?.premium ?? 0}
icon={<LuShield />}
colorPalette="purple"
/>
</StaggerItem>
<StaggerItem> <StaggerItem>
<AdminStat <AdminStat
label={t("active-users")} label={t("active-users")}
value={analytics?.activeUsers ?? analytics?.users?.active ?? 0} value={
analytics?.activeUsers ?? analytics?.users?.active ?? 0
}
icon={<LuActivity />} icon={<LuActivity />}
colorPalette="orange" colorPalette="orange"
/> />
@@ -228,13 +282,73 @@ export default function AdminContent() {
))} ))}
{/* Users Tab */} {/* Users Tab */}
{activeTab === "users" && {activeTab === "users" && (
(usersLoading ? ( <VStack gap={4} align="stretch">
{/* Filters */}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body py={4}>
<SimpleGrid columns={{ base: 1, md: 3 }} gap={4}>
<Input
placeholder={t("search-users-placeholder")}
value={searchParams.search}
onChange={(e) =>
setSearchParams({
...searchParams,
search: e.target.value,
})
}
/>
<NativeSelectRoot>
<NativeSelectField
placeholder={t("all-roles")}
value={searchParams.role}
onChange={(e) =>
setSearchParams({
...searchParams,
role: e.target.value,
page: 1,
})
}
items={[
{ label: t("standard-user"), value: "user" },
{ label: t("superadmin"), value: "superadmin" },
]}
/>
</NativeSelectRoot>
<NativeSelectRoot>
<NativeSelectField
placeholder={t("all-plans")}
value={searchParams.subscriptionStatus}
onChange={(e) =>
setSearchParams({
...searchParams,
subscriptionStatus: e.target.value,
page: 1,
})
}
items={[
{ label: t("plan-free"), value: "free" },
{ label: "Plus", value: "plus" },
{ label: "Premium", value: "premium" },
{ label: t("plan-past-due"), value: "past_due" },
{ label: t("plan-cancelled"), value: "cancelled" },
]}
/>
</NativeSelectRoot>
</SimpleGrid>
</Card.Body>
</Card.Root>
{usersLoading ? (
<Flex justify="center" py={16}> <Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" /> <Spinner size="lg" color="primary.500" />
</Flex> </Flex>
) : users.length > 0 ? ( ) : users.length > 0 ? (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl"> <Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body> <Card.Body>
<VStack gap={0} align="stretch"> <VStack gap={0} align="stretch">
{/* Table Header */} {/* Table Header */}
@@ -253,9 +367,13 @@ export default function AdminContent() {
<Text flex={1} textAlign="center"> <Text flex={1} textAlign="center">
{t("user-role")} {t("user-role")}
</Text> </Text>
<Text flex={1} textAlign="center">
{t("subscription")}
</Text>
<Text flex={1} textAlign="center"> <Text flex={1} textAlign="center">
{t("user-status")} {t("user-status")}
</Text> </Text>
<Text width="40px" textAlign="center"></Text>
</Flex> </Flex>
{/* User Rows */} {/* User Rows */}
@@ -277,12 +395,19 @@ export default function AdminContent() {
> >
{getUserDisplayName(user)} {getUserDisplayName(user)}
</Text> </Text>
<Text flex={2} fontSize="sm" color="fg.muted" truncate> <Text
flex={2}
fontSize="sm"
color="fg.muted"
truncate
>
{user.email} {user.email}
</Text> </Text>
<Flex flex={1} justify="center"> <Flex flex={1} justify="center">
<Badge <Badge
colorPalette={isAdminRole([user.role]) ? "red" : "gray"} colorPalette={
isAdminRole([user.role]) ? "red" : "gray"
}
variant="subtle" variant="subtle"
fontSize="2xs" fontSize="2xs"
borderRadius="full" borderRadius="full"
@@ -290,6 +415,61 @@ export default function AdminContent() {
{formatRoleLabel(user.role)} {formatRoleLabel(user.role)}
</Badge> </Badge>
</Flex> </Flex>
<Flex
flex={1}
justify="center"
direction="column"
align="center"
gap={1}
>
<Badge
colorPalette={
user.subscriptionStatus === "premium" ||
user.subscriptionStatus === "plus"
? "purple"
: "gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
textTransform="capitalize"
>
{user.subscriptionStatus || "free"}
</Badge>
{user.subscriptionExpiresAt ? (
<Text fontSize="2xs" color="fg.muted">
{format.dateTime(
new Date(user.subscriptionExpiresAt),
{
year: "numeric",
month: "2-digit",
day: "2-digit",
},
)}
</Text>
) : (
<Text fontSize="2xs" color="fg.muted">
-
</Text>
)}
</Flex>
<Flex flex={1} justify="center">
<Badge
colorPalette={
user.subscriptionStatus === "premium"
? "purple"
: user.subscriptionStatus === "plus"
? "blue"
: "gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
textTransform="capitalize"
>
{user.subscriptionStatus || "free"}
</Badge>
</Flex>
<Flex flex={1} justify="center"> <Flex flex={1} justify="center">
<Badge <Badge
colorPalette={user.isActive ? "green" : "gray"} colorPalette={user.isActive ? "green" : "gray"}
@@ -302,9 +482,63 @@ export default function AdminContent() {
: tCommon("inactive")} : tCommon("inactive")}
</Badge> </Badge>
</Flex> </Flex>
<Flex width="40px" justify="center">
<Button
size="sm"
variant="ghost"
onClick={() => setEditingUser(user)}
>
<LuPencil />
</Button>
</Flex>
</Flex> </Flex>
</Box> </Box>
))} ))}
{/* Pagination */}
{meta && meta.totalPages > 1 && (
<Flex
justify="center"
pt={4}
pb={2}
gap={2}
borderTopWidth="1px"
borderColor={borderColor}
mt={2}
>
<Button
size="sm"
variant="outline"
disabled={!meta.hasPreviousPage}
onClick={() =>
setSearchParams({
...searchParams,
page: meta.page - 1,
})
}
>
{tCommon("previous")}
</Button>
<Flex align="center" gap={2} fontSize="sm">
<Text>
{tCommon("page")} {meta.page} / {meta.totalPages}
</Text>
</Flex>
<Button
size="sm"
variant="outline"
disabled={!meta.hasNextPage}
onClick={() =>
setSearchParams({
...searchParams,
page: meta.page + 1,
})
}
>
{tCommon("next")}
</Button>
</Flex>
)}
</VStack> </VStack>
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
@@ -312,7 +546,18 @@ export default function AdminContent() {
<Flex justify="center" py={16}> <Flex justify="center" py={16}>
<Text color="fg.muted">{t("no-users")}</Text> <Text color="fg.muted">{t("no-users")}</Text>
</Flex> </Flex>
))} )}
</VStack>
)}
{/* League Tiers Tab */}
{activeTab === "league-tiers" && <LeagueTiersContent />}
<EditUserModal
user={editingUser}
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
/>
</Box> </Box>
</SlideUp> </SlideUp>
); );
+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 }) => const toast = (opts: { title: string; status: string }) =>
toaster.create({ toaster.create({
title: opts.title, title: opts.title,
type: opts.status as type: opts.status as "success" | "warning" | "error" | "info" | "loading",
| "success"
| "warning"
| "error"
| "info"
| "loading",
}); });
const toggleMatch = (id: string) => { const toggleMatch = (id: string) => {
+16 -4
View File
@@ -19,7 +19,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react"; import { useState, useEffect } from "react";
import { MdMail } from "react-icons/md"; import { MdMail } from "react-icons/md";
import { BiUser } from "react-icons/bi"; import { BiUser } from "react-icons/bi";
import { authService } from "@/lib/api/auth/service"; import { authService } from "@/lib/api/auth/service";
@@ -45,15 +45,27 @@ type RegisterForm = yup.InferType<typeof registerSchema>;
interface LoginModalProps { interface LoginModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
initialMode?: "login" | "register";
} }
/* ────────────────────────── Component ────────────────────────── */ /* ────────────────────────── Component ────────────────────────── */
export function LoginModal({ open, onOpenChange }: LoginModalProps) { export function LoginModal({
open,
onOpenChange,
initialMode = "login",
}: LoginModalProps) {
const t = useTranslations(); const t = useTranslations();
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">(initialMode);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Update mode when modal opens
useEffect(() => {
if (open) {
setMode(initialMode);
}
}, [open, initialMode]);
/* ── Login form ── */ /* ── Login form ── */
const loginForm = useForm<LoginForm>({ const loginForm = useForm<LoginForm>({
resolver: yupResolver(loginSchema), resolver: yupResolver(loginSchema),
@@ -155,7 +167,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <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")} {mode === "login" ? t("auth.sign-in") : t("auth.sign-up")}
</Heading> </Heading>
</DialogTitle> </DialogTitle>
@@ -769,30 +769,51 @@ export default function CouponBuilderContent() {
{/* Engine Mode Toggle */} {/* Engine Mode Toggle */}
<VStack align="stretch" gap={2} mb={4}> <VStack align="stretch" gap={2} mb={4}>
<HStack gap={2}> <HStack gap={2}>
<Icon as={engineMode === "ai" ? LuSparkles : LuDatabase} color={engineMode === "ai" ? "teal.500" : "cyan.500"} /> <Icon
<Text fontWeight="semibold" fontSize="sm">{t("engine-mode-label")}</Text> as={engineMode === "ai" ? LuSparkles : LuDatabase}
<InfoIcon content={t("engine-mode-help")} label={t("engine-mode-label")} /> 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>
<HStack gap={2}> <HStack gap={2}>
<Badge <Badge
colorPalette={engineMode === "ai" ? "teal" : "gray"} colorPalette={engineMode === "ai" ? "teal" : "gray"}
variant={engineMode === "ai" ? "solid" : "outline"} variant={engineMode === "ai" ? "solid" : "outline"}
cursor="pointer" px={3} py={1} cursor="pointer"
px={3}
py={1}
onClick={() => setEngineMode("ai")} onClick={() => setEngineMode("ai")}
> >
<LuSparkles /> AI <LuSparkles /> AI
</Badge> </Badge>
<Badge <Badge
colorPalette={engineMode === "frequency" ? "cyan" : "gray"} colorPalette={
variant={engineMode === "frequency" ? "solid" : "outline"} engineMode === "frequency" ? "cyan" : "gray"
cursor="pointer" px={3} py={1} }
variant={
engineMode === "frequency" ? "solid" : "outline"
}
cursor="pointer"
px={3}
py={1}
onClick={() => setEngineMode("frequency")} onClick={() => setEngineMode("frequency")}
> >
<LuDatabase /> Frekans <LuDatabase /> Frekans
</Badge> </Badge>
</HStack> </HStack>
<Text fontSize="xs" color={engineMode === "ai" ? "teal.500" : "cyan.500"}> <Text
{engineMode === "ai" ? t("ai-mode-active") : t("freq-mode-active")} fontSize="xs"
color={engineMode === "ai" ? "teal.500" : "cyan.500"}
>
{engineMode === "ai"
? t("ai-mode-active")
: t("freq-mode-active")}
</Text> </Text>
</VStack> </VStack>
@@ -819,7 +840,9 @@ export default function CouponBuilderContent() {
key={entry.key} key={entry.key}
p={3} p={3}
borderWidth="1px" borderWidth="1px"
borderColor={active ? `${palette}.400` : borderColor} borderColor={
active ? `${palette}.400` : borderColor
}
bg={active ? `${palette}.50` : mutedBg} bg={active ? `${palette}.50` : mutedBg}
borderRadius="xl" borderRadius="xl"
cursor="pointer" cursor="pointer"
@@ -832,7 +855,9 @@ export default function CouponBuilderContent() {
> >
{entry.label} {entry.label}
</Badge> </Badge>
{active ? <LuCheck color="currentColor" /> : null} {active ? (
<LuCheck color="currentColor" />
) : null}
</HStack> </HStack>
<Text fontSize="sm" color="fg.muted"> <Text fontSize="sm" color="fg.muted">
{entry.description} {entry.description}
@@ -866,7 +891,9 @@ export default function CouponBuilderContent() {
min="2" min="2"
max="15" max="15"
value={matchCount} value={matchCount}
onChange={(e) => setMatchCount(Number(e.target.value))} onChange={(e) =>
setMatchCount(Number(e.target.value))
}
style={{ style={{
width: "100%", width: "100%",
accentColor: "teal", accentColor: "teal",
@@ -880,7 +907,9 @@ export default function CouponBuilderContent() {
> >
<Text>2</Text> <Text>2</Text>
<Text> <Text>
{t("match-count-auto", { count: allMatches.length })} {t("match-count-auto", {
count: allMatches.length,
})}
</Text> </Text>
<Text>15</Text> <Text>15</Text>
</HStack> </HStack>
+19 -4
View File
@@ -170,7 +170,11 @@ export default function FrequencyPanel() {
max="95" max="95"
value={minSignal * 100} value={minSignal * 100}
onChange={(e) => setMinSignal(Number(e.target.value) / 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"> <HStack justify="space-between" fontSize="xs" color="fg.muted">
<Text>50%</Text> <Text>50%</Text>
@@ -197,7 +201,11 @@ export default function FrequencyPanel() {
max="5" max="5"
value={maxMatches} value={maxMatches}
onChange={(e) => setMaxMatches(Number(e.target.value))} 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"> <HStack justify="space-between" fontSize="xs" color="fg.muted">
<Text>2</Text> <Text>2</Text>
@@ -325,7 +333,12 @@ export default function FrequencyPanel() {
borderRadius="xl" borderRadius="xl"
bg={mutedBg} 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}> <VStack align="flex-start" gap={1}>
<Text fontWeight="bold">{bet.match_name}</Text> <Text fontWeight="bold">{bet.match_name}</Text>
<Text fontSize="sm" color="fg.muted"> <Text fontSize="sm" color="fg.muted">
@@ -405,7 +418,9 @@ export default function FrequencyPanel() {
<Box p={4} bg="orange.50" borderRadius="xl"> <Box p={4} bg="orange.50" borderRadius="xl">
<HStack gap={2} mb={2}> <HStack gap={2} mb={2}>
<Icon as={LuBadgeAlert} color="orange.500" /> <Icon as={LuBadgeAlert} color="orange.500" />
<Text fontWeight="semibold">{t("rejected-matches-title")}</Text> <Text fontWeight="semibold">
{t("rejected-matches-title")}
</Text>
</HStack> </HStack>
<VStack align="stretch" gap={1}> <VStack align="stretch" gap={1}>
{result.rejected_matches.map((entry, i) => ( {result.rejected_matches.map((entry, i) => (
+25 -7
View File
@@ -14,7 +14,12 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion"; import {
SlideUp,
StaggerContainer,
StaggerItem,
ScrollSlideUp,
} from "@/components/motion";
import { Skeleton } from "@/components/ui/feedback/skeleton"; import { Skeleton } from "@/components/ui/feedback/skeleton";
import { MatchCard } from "@/components/matches"; import { MatchCard } from "@/components/matches";
import { useQueryMatches } from "@/lib/api/matches/use-hooks"; import { useQueryMatches } from "@/lib/api/matches/use-hooks";
@@ -26,8 +31,14 @@ import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu"; import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { LeagueWithMatchesDto, MatchResponseDto } from "@/lib/api/matches/types"; import type {
import type { MatchPredictionDto, ValueBetDto } from "@/lib/api/predictions/types"; LeagueWithMatchesDto,
MatchResponseDto,
} from "@/lib/api/matches/types";
import type {
MatchPredictionDto,
ValueBetDto,
} from "@/lib/api/predictions/types";
// ======================== // ========================
// Stats Card // Stats Card
@@ -181,8 +192,11 @@ export default function DashboardContent() {
queryMatches.mutate({ sport: "football", limit: 20 }); queryMatches.mutate({ sport: "football", limit: 20 });
} }
const todayMatches: MatchResponseDto[] = queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ?? []; const todayMatches: MatchResponseDto[] =
const upcomingPredictions: MatchPredictionDto[] = upcomingData?.data?.matches ?? []; queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ??
[];
const upcomingPredictions: MatchPredictionDto[] =
upcomingData?.data?.matches ?? [];
const valueBets: ValueBetDto[] = valueBetsData?.data ?? []; const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
const userStats = statsData?.data; const userStats = statsData?.data;
@@ -328,7 +342,9 @@ export default function DashboardContent() {
</VStack> </VStack>
) : upcomingPredictions.length > 0 ? ( ) : upcomingPredictions.length > 0 ? (
<VStack gap={2} align="stretch"> <VStack gap={2} align="stretch">
{upcomingPredictions.slice(0, 4).map((pred: MatchPredictionDto, idx: number) => ( {upcomingPredictions
.slice(0, 4)
.map((pred: MatchPredictionDto, idx: number) => (
<Box <Box
key={idx} key={idx}
p={2.5} p={2.5}
@@ -396,7 +412,9 @@ export default function DashboardContent() {
</VStack> </VStack>
) : valueBets.length > 0 ? ( ) : valueBets.length > 0 ? (
<VStack gap={2} align="stretch"> <VStack gap={2} align="stretch">
{valueBets.slice(0, 5).map((vb: ValueBetDto, idx: number) => ( {valueBets
.slice(0, 5)
.map((vb: ValueBetDto, idx: number) => (
<ValueBetMiniCard <ValueBetMiniCard
key={idx} key={idx}
matchName={vb.matchName} matchName={vb.matchName}
+4 -4
View File
@@ -18,10 +18,10 @@ import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion"; import { SlideUp } from "@/components/motion";
import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks"; import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks";
import type { TeamDto, HeadToHeadDto } from "@/lib/api/leagues/types"; import type { TeamDto } from "@/lib/api/leagues/types";
import type { MatchResponseDto } from "@/lib/api/matches/types"; import type { MatchResponseDto } from "@/lib/api/matches/types";
import { LuSearch, LuArrowLeftRight } from "react-icons/lu"; import { LuSearch, LuArrowLeftRight } from "react-icons/lu";
import { useState, useEffect } from "react"; import { useState } from "react";
import { useDebounce } from "@/hooks/use-debounce"; import { useDebounce } from "@/hooks/use-debounce";
function TeamSearchInput({ function TeamSearchInput({
@@ -134,7 +134,7 @@ export default function H2HContent() {
?.data ?.data
? [ ? [
{ {
label: team1?.name || t("team1"), label: team1?.name || t("team-1"),
value: h2h.data.data.team1Wins, value: h2h.data.data.team1Wins,
color: "green", color: "green",
}, },
@@ -144,7 +144,7 @@ export default function H2HContent() {
color: "gray", color: "gray",
}, },
{ {
label: team2?.name || t("team2"), label: team2?.name || t("team-2"),
value: h2h.data.data.team2Wins, value: h2h.data.data.team2Wins,
color: "blue", color: "blue",
}, },
+12 -2
View File
@@ -309,7 +309,11 @@ export default function HomeContent() {
shadow="lg" shadow="lg"
> >
<SimpleGrid columns={{ base: 2, md: 4 }} gap={6}> <SimpleGrid columns={{ base: 2, md: 4 }} gap={6}>
<StatBlock value={15000} label={t("stats-predictions")} suffix="+" /> <StatBlock
value={15000}
label={t("stats-predictions")}
suffix="+"
/>
<StatBlock value={72} label={t("stats-accuracy")} suffix="%" /> <StatBlock value={72} label={t("stats-accuracy")} suffix="%" />
<StatBlock value={3200} label={t("stats-users")} suffix="+" /> <StatBlock value={3200} label={t("stats-users")} suffix="+" />
<StatBlock value={50000} label={t("stats-matches")} suffix="+" /> <StatBlock value={50000} label={t("stats-matches")} suffix="+" />
@@ -320,7 +324,13 @@ export default function HomeContent() {
{/* Features Section */} {/* Features Section */}
<Box mb={16}> <Box mb={16}>
<ScrollScaleIn> <ScrollScaleIn>
<Heading as="h2" size="xl" textAlign="center" mb={3} fontWeight="bold"> <Heading
as="h2"
size="xl"
textAlign="center"
mb={3}
fontWeight="bold"
>
{t("features-title")} {t("features-title")}
</Heading> </Heading>
<Text <Text
+15 -1
View File
@@ -27,7 +27,7 @@ export default function Footer() {
focusRing="none" focusRing="none"
fontWeight="semibold" fontWeight="semibold"
> >
Suggest Bet iddaai
</ChakraLink> </ChakraLink>
. {t("all-right-reserved")} . {t("all-right-reserved")}
</Text> </Text>
@@ -61,6 +61,20 @@ export default function Footer() {
> >
{t("terms-of-service")} {t("terms-of-service")}
</ChakraLink> </ChakraLink>
<ChakraLink
as={Link}
href="/refund-policy"
fontSize="sm"
color="fg.muted"
focusRing="none"
textDecor="none"
transition="color 0.2s"
_hover={{
color: { base: "primary.500", _dark: "primary.300" },
}}
>
{t("refund-policy")}
</ChakraLink>
</HStack> </HStack>
</Flex> </Flex>
</Box> </Box>
+117 -31
View File
@@ -13,6 +13,7 @@ import {
ClientOnly, ClientOnly,
Text, Text,
Separator, Separator,
Badge,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Link, useRouter } from "@/i18n/navigation"; import { Link, useRouter } from "@/i18n/navigation";
import { ColorModeButton } from "@/components/ui/color-mode"; import { ColorModeButton } from "@/components/ui/color-mode";
@@ -40,19 +41,27 @@ import { signOut, useSession } from "next-auth/react";
import { authConfig } from "@/config/auth"; import { authConfig } from "@/config/auth";
import { LoginModal } from "@/components/auth/login-modal"; import { LoginModal } from "@/components/auth/login-modal";
import { isAdminRole } from "@/lib/auth/roles"; import { isAdminRole } from "@/lib/auth/roles";
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu"; import { LuLogIn, LuUser, LuShield, LuZap, LuCrown } from "react-icons/lu";
import { PlanBadge } from "@/components/subscription";
import GlobalSearch from "@/components/search/global-search"; import GlobalSearch from "@/components/search/global-search";
import Image from "next/image";
import { useGetMe } from "@/lib/api/users/use-hooks";
export default function Header() { export default function Header() {
const t = useTranslations(); const t = useTranslations();
const [isSticky, setIsSticky] = useState(false); const [isSticky, setIsSticky] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">(
"login",
);
const router = useRouter(); const router = useRouter();
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const isAuthenticated = !!session; const isAuthenticated = !!session;
const isLoading = status === "loading"; const isLoading = status === "loading";
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated); const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
const { data: meData } = useGetMe(isAuthenticated);
const usageLimit = meData?.data?.usageLimit;
useEffect(() => { useEffect(() => {
const handleScroll = () => setIsSticky(window.scrollY >= 10); const handleScroll = () => setIsSticky(window.scrollY >= 10);
@@ -63,16 +72,39 @@ export default function Header() {
const handleLogout = async () => { const handleLogout = async () => {
await signOut({ redirect: false }); await signOut({ redirect: false });
if (authConfig.isAuthRequired) { if (authConfig.isAuthRequired) {
router.replace("/signin"); router.replace("/home");
} }
}; };
const openAuthModal = (mode: "login" | "register") => {
setLoginModalMode(mode);
setLoginModalOpen(true);
};
// Desktop auth section // Desktop auth section
const renderAuthSection = () => { const renderAuthSection = () => {
if (isLoading) return <Skeleton boxSize="10" rounded="full" />; if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
if (isAuthenticated) { if (isAuthenticated) {
return ( return (
<HStack gap={2}>
{usageLimit && (
<Badge
size="sm"
colorPalette={
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
? "green"
: "red"
}
variant="subtle"
display={{ base: "none", sm: "inline-flex" }}
>
<LuZap style={{ marginRight: "4px" }} />
{usageLimit.maxAnalyses - usageLimit.analysisCount}{" "}
{t("common.limits.analysis_left", { defaultValue: "Analiz" })}
</Badge>
)}
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
<MenuRoot positioning={{ placement: "bottom-start" }}> <MenuRoot positioning={{ placement: "bottom-start" }}>
<MenuTrigger rounded="full" focusRing="none"> <MenuTrigger rounded="full" focusRing="none">
<Avatar name={session?.user?.name || "User"} variant="solid" /> <Avatar name={session?.user?.name || "User"} variant="solid" />
@@ -82,6 +114,15 @@ export default function Header() {
<LuUser /> <LuUser />
{t("nav.profile")} {t("nav.profile")}
</MenuItem> </MenuItem>
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
<MenuItem
value="pricing"
onClick={() => router.push("/pricing")}
>
<LuCrown />
{t("nav.pricing")}
</MenuItem>
)}
{session?.user && isAdminRole(session.user.roles) && ( {session?.user && isAdminRole(session.user.roles) && (
<MenuItem value="admin" onClick={() => router.push("/admin")}> <MenuItem value="admin" onClick={() => router.push("/admin")}>
<LuShield /> <LuShield />
@@ -93,20 +134,32 @@ export default function Header() {
</MenuItem> </MenuItem>
</MenuContent> </MenuContent>
</MenuRoot> </MenuRoot>
</HStack>
); );
} }
return ( return (
<HStack gap={2}>
<Button
variant="outline"
colorPalette="gray"
size="sm"
borderRadius="full"
onClick={() => openAuthModal("register")}
>
{t("auth.sign-up")}
</Button>
<Button <Button
variant="solid" variant="solid"
colorPalette="primary" colorPalette="primary"
size="sm" size="sm"
borderRadius="full" borderRadius="full"
onClick={() => setLoginModalOpen(true)} onClick={() => openAuthModal("login")}
> >
<LuLogIn /> <LuLogIn />
{t("auth.sign-in")} {t("auth.sign-in")}
</Button> </Button>
</HStack>
); );
}; };
@@ -123,9 +176,24 @@ export default function Header() {
variant="solid" variant="solid"
size="sm" size="sm"
/> />
<Text fontSize="sm" fontWeight="semibold" truncate> <Text fontSize="sm" fontWeight="semibold" truncate flex={1}>
{session?.user?.name || session?.user?.email} {session?.user?.name || session?.user?.email}
</Text> </Text>
{usageLimit && (
<Badge
size="sm"
colorPalette={
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
? "green"
: "red"
}
variant="subtle"
>
<LuZap style={{ marginRight: "4px" }} />
{usageLimit.maxAnalyses - usageLimit.analysisCount}
</Badge>
)}
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
</Flex> </Flex>
<Button <Button
variant="ghost" variant="ghost"
@@ -137,6 +205,18 @@ export default function Header() {
<LuUser /> <LuUser />
{t("nav.profile")} {t("nav.profile")}
</Button> </Button>
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
<Button
variant="outline"
size="sm"
width="full"
colorPalette="primary"
onClick={() => router.push("/pricing")}
>
<LuCrown />
{t("nav.pricing")}
</Button>
)}
<Button <Button
variant="surface" variant="surface"
size="sm" size="sm"
@@ -150,17 +230,29 @@ export default function Header() {
} }
return ( return (
<VStack gap={2} w="full">
<Button
variant="outline"
colorPalette="gray"
size="sm"
width="full"
borderRadius="full"
onClick={() => openAuthModal("register")}
>
{t("auth.sign-up")}
</Button>
<Button <Button
variant="solid" variant="solid"
colorPalette="primary" colorPalette="primary"
size="sm" size="sm"
width="full" width="full"
borderRadius="full" borderRadius="full"
onClick={() => setLoginModalOpen(true)} onClick={() => openAuthModal("login")}
> >
<LuLogIn /> <LuLogIn />
{t("auth.sign-in")} {t("auth.sign-in")}
</Button> </Button>
</VStack>
); );
}; };
@@ -198,36 +290,22 @@ export default function Header() {
flexShrink={0} flexShrink={0}
mr={6} mr={6}
> >
<Flex <img
boxSize="32px" src="/logo.png"
bg="primary.500" alt="iddaai logo"
borderRadius="lg" width={36}
align="center" height={36}
justify="center" style={{ objectFit: "contain" }}
shadow="sm" />
>
<LuZap color="white" size={18} />
</Flex>
<Box> <Box>
<Text <Text
fontSize="md" fontSize="xl"
fontWeight="800" fontWeight="900"
lineHeight="1" lineHeight="1"
color={{ base: "gray.900", _dark: "white" }} color={{ base: "gray.900", _dark: "white" }}
letterSpacing="-0.02em" letterSpacing="-0.04em"
> >
Suggest iddaai
</Text>
<Text
fontSize="xs"
fontWeight="600"
lineHeight="1"
mt="1px"
color={{ base: "primary.600", _dark: "primary.300" }}
letterSpacing="0.08em"
textTransform="uppercase"
>
BET
</Text> </Text>
</Box> </Box>
</ChakraLink> </ChakraLink>
@@ -273,6 +351,10 @@ export default function Header() {
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}> <PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
<PopoverBody> <PopoverBody>
<VStack mt="2" align="start" spaceY="2" w="full"> <VStack mt="2" align="start" spaceY="2" w="full">
{/* Mobile Search */}
<Box w="full">
<GlobalSearch />
</Box>
{visibleItems.map((item) => ( {visibleItems.map((item) => (
<MobileHeaderLink key={item.href} item={item} /> <MobileHeaderLink key={item.href} item={item} />
))} ))}
@@ -296,7 +378,11 @@ export default function Header() {
</Box> </Box>
{/* Login Modal */} {/* Login Modal */}
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} /> <LoginModal
open={loginModalOpen}
onOpenChange={setLoginModalOpen}
initialMode={loginModalMode}
/>
</> </>
); );
} }
@@ -0,0 +1,177 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
VStack,
HStack,
Badge,
Spinner,
} from "@chakra-ui/react";
import { Link as ChakraLink } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import { useLeagueById } from "@/lib/api/leagues/use-hooks";
import { useQuery } from "@tanstack/react-query";
import { matchesService } from "@/lib/api/matches/service";
import MatchList from "@/components/matches/match-list";
import { LuTrophy, LuMapPin, LuArrowLeft } from "react-icons/lu";
import { Link } from "@/i18n/navigation";
export default function LeagueDetailContent({
leagueId,
}: {
leagueId: string;
}) {
const t = useTranslations("leagues");
const leagueQuery = useLeagueById(leagueId);
const league = leagueQuery.data?.data;
const matchesQuery = useQuery({
queryKey: ["league-matches", leagueId, league?.sport],
queryFn: () =>
matchesService.queryMatches({
sport: league?.sport || "football",
leagueId: leagueId,
status: "Finished",
limit: 100,
}),
enabled: !!league,
});
const bgGradient = useColorModeValue(
"linear(to-r, primary.500, primary.700)",
"linear(to-r, primary.600, primary.900)",
);
const flatMatches = matchesQuery.data?.data?.[0]?.matches || [];
return (
<Box minH="calc(100vh - 80px)">
{/* Hero Section */}
<Box
bgGradient={bgGradient}
color="white"
pt={16}
pb={20}
px={6}
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top="-20%"
right="-10%"
opacity={0.1}
transform="rotate(15deg)"
>
<LuTrophy size={400} />
</Box>
<Box maxW="7xl" mx="auto" position="relative" zIndex={1}>
<SlideUp>
<VStack align="flex-start" gap={4} maxW="3xl">
<ChakraLink
as={Link}
href="/leagues"
color="whiteAlpha.900"
_hover={{ color: "white" }}
display="flex"
alignItems="center"
gap={2}
mb={2}
fontWeight="medium"
>
<LuArrowLeft /> Liglere Dön
</ChakraLink>
{leagueQuery.isLoading ? (
<Spinner color="white" borderWidth="3px" size="xl" />
) : league ? (
<>
<HStack gap={3}>
<Badge
colorScheme={
league.sport === "football" ? "green" : "orange"
}
variant="solid"
bg="whiteAlpha.300"
size="lg"
px={4}
py={1}
rounded="full"
>
{league.sport}
</Badge>
{league.season && (
<Badge
variant="outline"
color="white"
borderColor="whiteAlpha.400"
size="lg"
px={4}
py={1}
rounded="full"
>
SEZON: {league.season}
</Badge>
)}
</HStack>
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="800"
letterSpacing="tight"
>
{league.name}
</Heading>
<HStack fontSize="lg" color="whiteAlpha.900">
<LuMapPin />
<Text>{league.country?.name || "Global"}</Text>
</HStack>
</>
) : (
<Heading>Lig Bulunamadı</Heading>
)}
</VStack>
</SlideUp>
</Box>
</Box>
{/* Main Content Area */}
<Box
maxW="7xl"
mx="auto"
px={6}
mt={-10}
position="relative"
zIndex={2}
pb={20}
>
<SlideUp
transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
>
<Box
bg={useColorModeValue("white", "gray.900")}
p={{ base: 4, md: 8 }}
shadow="xl"
borderRadius="2xl"
borderWidth="1px"
borderColor={useColorModeValue("gray.200", "gray.800")}
>
<Heading size="md" mb={6}>
Geçmiş Maçlar
</Heading>
<MatchList
flatMatches={flatMatches}
isLoading={matchesQuery.isLoading || leagueQuery.isLoading}
/>
</Box>
</SlideUp>
</Box>
</Box>
);
}
+661 -172
View File
@@ -11,7 +11,9 @@ import {
Badge, Badge,
Spinner, Spinner,
Input, Input,
Tabs, Grid,
GridItem,
Icon,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
@@ -22,8 +24,15 @@ import {
useSearchTeams, useSearchTeams,
} from "@/lib/api/leagues/use-hooks"; } from "@/lib/api/leagues/use-hooks";
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types"; import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
import { LuSearch, LuGlobe, LuTrophy, LuUsers } from "react-icons/lu"; import {
import { useState } from "react"; LuSearch,
LuGlobe,
LuTrophy,
LuUsers,
LuArrowRight,
LuMapPin,
} from "react-icons/lu";
import { useMemo, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce"; import { useDebounce } from "@/hooks/use-debounce";
import { Link } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation";
import { InputGroup } from "@/components/ui/forms/input-group"; import { InputGroup } from "@/components/ui/forms/input-group";
@@ -33,13 +42,26 @@ export default function LeaguesContent() {
const t = useTranslations("leagues"); const t = useTranslations("leagues");
const tMatches = useTranslations("matches"); const tMatches = useTranslations("matches");
const cardBg = useColorModeValue("white", "gray.800"); const bgGradient = useColorModeValue(
const borderColor = useColorModeValue("gray.100", "gray.700"); "linear(to-r, primary.500, primary.700)",
"linear(to-r, primary.600, primary.900)",
);
const cardBg = useColorModeValue("white", "gray.900");
const borderColor = useColorModeValue("gray.200", "gray.800");
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.50");
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues"); const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
const [sportFilter, setSportFilter] = useState<string>(""); const [sportFilter, setSportFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState(""); const [selectedCountryId, setSelectedCountryId] = useState<string | null>(
const debouncedQuery = useDebounce(searchQuery, 300); null,
);
const [teamSearchQuery, setTeamSearchQuery] = useState("");
const debouncedTeamQuery = useDebounce(teamSearchQuery, 300);
const [countrySearchQuery, setCountrySearchQuery] = useState("");
const debouncedCountryQuery = useDebounce(countrySearchQuery, 300);
const countries = useCountries(); const countries = useCountries();
const leagues = useLeagues( const leagues = useLeagues(
@@ -48,182 +70,519 @@ export default function LeaguesContent() {
: undefined, : undefined,
); );
const searchTeams = useSearchTeams( const searchTeams = useSearchTeams(
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" }, debouncedTeamQuery.length >= 2 ? { q: debouncedTeamQuery } : { q: "" },
); );
const filteredCountries = useMemo(() => {
if (!countries.data?.data) return [];
if (!debouncedCountryQuery) return countries.data.data;
return countries.data.data.filter((c) =>
c.name.toLowerCase().includes(debouncedCountryQuery.toLowerCase()),
);
}, [countries.data?.data, debouncedCountryQuery]);
const displayedLeagues = useMemo(() => {
let sourceLeagues: LeagueDto[] = leagues.data?.data || [];
if (selectedCountryId) {
sourceLeagues = sourceLeagues.filter(
(l) => l.countryId === selectedCountryId,
);
}
// Apply sport filter if selected
if (sportFilter) {
return sourceLeagues.filter((l) => l.sport === sportFilter);
}
return sourceLeagues;
}, [selectedCountryId, leagues.data?.data, sportFilter]);
return ( return (
<SlideUp> <Box minH="calc(100vh - 80px)">
<Box maxW="6xl" mx="auto"> {/* Hero Section */}
<Heading as="h1" size="xl" fontWeight="bold" mb={6}> <Box
{t("title")} bgGradient={bgGradient}
</Heading> color="white"
pt={16}
<Tabs.Root pb={20}
value={activeTab} px={6}
onValueChange={(e) => setActiveTab(e.value as "leagues" | "teams")} position="relative"
overflow="hidden"
> >
<Tabs.List> <Box
<Tabs.Trigger value="leagues"> position="absolute"
<LuGlobe /> top="-20%"
{t("countries-leagues")} right="-10%"
</Tabs.Trigger> opacity={0.1}
<Tabs.Trigger value="teams"> transform="rotate(15deg)"
<LuUsers /> >
{tMatches("search-teams")} <LuTrophy size={400} />
</Tabs.Trigger> </Box>
</Tabs.List> <Box maxW="7xl" mx="auto" position="relative" zIndex={1}>
<SlideUp>
<VStack
align="center"
gap={4}
textAlign="center"
maxW="3xl"
mx="auto"
>
<Badge
colorScheme="whiteAlpha"
variant="subtle"
size="lg"
px={4}
py={1}
rounded="full"
>
{t("title")}
</Badge>
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="800"
letterSpacing="tight"
>
{activeTab === "leagues"
? t("countries-leagues")
: tMatches("search-teams")}
</Heading>
<Text fontSize="lg" color="whiteAlpha.800" maxW="xl">
{activeTab === "leagues"
? "Explore top football and basketball leagues around the world. Filter by country and analyze historical matches."
: "Find your favorite teams across all leagues. Get deep insights and head-to-head statistics."}
</Text>
</VStack>
</SlideUp>
</Box>
</Box>
{/* Countries & Leagues Tab */} {/* Main Content Area - Pulled up to overlap hero */}
<Tabs.Content value="leagues"> <Box
<Flex gap={6} direction={{ base: "column", lg: "row" }}> maxW="7xl"
{/* Countries Sidebar */} mx="auto"
<Box w={{ base: "full", lg: "280px" }} flexShrink={0}> px={6}
mt={-10}
position="relative"
zIndex={2}
pb={20}
>
<SlideUp
transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
>
<Card.Root <Card.Root
bg={cardBg} bg={cardBg}
shadow="xl"
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor} borderColor={borderColor}
borderRadius="xl" overflow="hidden"
> >
<Card.Header> {/* Tab Navigation */}
<Heading as="h4" size="sm"> <Flex
<HStack gap={2}> borderBottomWidth="1px"
borderColor={borderColor}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
>
<Flex flex={1}>
<Box
flex={1}
py={4}
textAlign="center"
cursor="pointer"
borderBottomWidth="2px"
borderColor={
activeTab === "leagues" ? "primary.500" : "transparent"
}
color={activeTab === "leagues" ? "primary.500" : "fg.muted"}
fontWeight={activeTab === "leagues" ? "bold" : "medium"}
onClick={() => setActiveTab("leagues")}
transition="all 0.2s"
_hover={{ bg: hoverBg }}
>
<HStack justify="center" gap={2}>
<LuGlobe /> <LuGlobe />
<Text>{t("countries")}</Text> <Text>{t("countries-leagues")}</Text>
</HStack> </HStack>
</Heading> </Box>
</Card.Header> <Box
<Card.Body pt={0} maxH="600px" overflowY="auto"> flex={1}
py={4}
textAlign="center"
cursor="pointer"
borderBottomWidth="2px"
borderColor={
activeTab === "teams" ? "primary.500" : "transparent"
}
color={activeTab === "teams" ? "primary.500" : "fg.muted"}
fontWeight={activeTab === "teams" ? "bold" : "medium"}
onClick={() => setActiveTab("teams")}
transition="all 0.2s"
_hover={{ bg: hoverBg }}
>
<HStack justify="center" gap={2}>
<LuUsers />
<Text>{tMatches("search-teams")}</Text>
</HStack>
</Box>
</Flex>
</Flex>
{/* LEAGUES TAB */}
{activeTab === "leagues" && (
<Flex direction={{ base: "column", lg: "row" }} minH="600px">
{/* Left Sidebar: Countries */}
<Box
w={{ base: "full", lg: "320px" }}
borderRightWidth={{ lg: "1px" }}
borderColor={borderColor}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
>
<VStack align="stretch" h="full" gap={0}>
<Box
p={4}
borderBottomWidth="1px"
borderColor={borderColor}
bg={cardBg}
>
<InputGroup
startElement={<LuSearch color="gray.400" />}
w="full"
>
<Input
placeholder={t("countries") + "..."}
variant="subtle"
borderRadius="full"
value={countrySearchQuery}
onChange={(e) =>
setCountrySearchQuery(e.target.value)
}
/>
</InputGroup>
</Box>
<Box
flex={1}
overflowY="auto"
maxH={{ base: "300px", lg: "600px" }}
p={2}
>
{countries.isLoading ? ( {countries.isLoading ? (
<Flex justify="center" py={4}> <Flex justify="center" py={10}>
<Spinner size="sm" /> <Spinner color="primary.500" />
</Flex> </Flex>
) : ( ) : (
<VStack gap={1} align="stretch"> <VStack gap={1} align="stretch">
{countries.data?.data?.map((country: CountryDto) => ( <Box
<Flex px={4}
key={country.id} py={3}
px={3} borderRadius="lg"
py={2}
borderRadius="md"
_hover={{
bg: "gray.50",
_dark: { bg: "gray.750" },
}}
cursor="pointer" cursor="pointer"
justify="space-between" bg={
align="center" selectedCountryId === null
? "primary.500"
: "transparent"
}
color={selectedCountryId === null ? "white" : "fg"}
_hover={{
bg:
selectedCountryId === null
? "primary.600"
: hoverBg,
}}
onClick={() => setSelectedCountryId(null)}
transition="all 0.2s"
> >
<HStack gap={2}> <HStack justify="space-between">
<HStack gap={3}>
<LuGlobe />
<Text
fontWeight={
selectedCountryId === null
? "bold"
: "medium"
}
>
{t("all")}
</Text>
</HStack>
<Badge
size="sm"
bg={
selectedCountryId === null
? "whiteAlpha.300"
: "gray.100"
}
color={
selectedCountryId === null ? "white" : "fg"
}
>
{leagues.data?.data?.length || 0}
</Badge>
</HStack>
</Box>
{filteredCountries.map((country: CountryDto) => {
const isSelected = selectedCountryId === country.id;
return (
<Box
key={country.id}
px={4}
py={3}
borderRadius="lg"
cursor="pointer"
bg={isSelected ? "primary.500" : "transparent"}
color={isSelected ? "white" : "fg"}
_hover={{
bg: isSelected ? "primary.600" : hoverBg,
}}
onClick={() => setSelectedCountryId(country.id)}
transition="all 0.2s"
>
<HStack justify="space-between">
<HStack gap={3}>
{country.flag ? ( {country.flag ? (
<img <img
src={country.flag} src={country.flag}
width="16" width="20"
height="16" height="20"
style={{ borderRadius: "2px" }} style={{
borderRadius: "50%",
objectFit: "cover",
}}
alt={country.name} alt={country.name}
/> />
) : null} ) : (
<Text fontSize="sm">{country.name}</Text> <LuMapPin />
)}
<Text
fontWeight={
isSelected ? "bold" : "medium"
}
>
{country.name}
</Text>
</HStack> </HStack>
<Badge size="xs" colorScheme="gray"> <Badge
{country.leagues?.length || 0} size="sm"
bg={
isSelected ? "whiteAlpha.300" : "gray.100"
}
color={isSelected ? "white" : "fg"}
>
{leagues.data?.data?.filter(
(l) => l.countryId === country.id,
).length || 0}
</Badge> </Badge>
</Flex> </HStack>
))} </Box>
);
})}
</VStack> </VStack>
)} )}
</Card.Body> </Box>
</Card.Root> </VStack>
</Box> </Box>
{/* Leagues List */} {/* Right Area: Leagues Grid */}
<Box flex={1}> <Box flex={1} p={{ base: 4, md: 8 }} bg={cardBg}>
<Card.Root {/* Top Filters */}
bg={cardBg} <Flex
borderColor={borderColor} justify="space-between"
borderRadius="xl" align="center"
mb={6}
direction={{ base: "column", sm: "row" }}
gap={4}
> >
<Card.Header> <Heading size="md" fontWeight="bold">
<Flex justify="space-between" align="center"> {selectedCountryId
<Heading as="h4" size="sm"> ? `${countries.data?.data?.find((c) => c.id === selectedCountryId)?.name} ${t("leagues")}`
<HStack gap={2}> : t("leagues")}
<LuTrophy /> <Text
<Text>{t("leagues")}</Text> as="span"
</HStack> color="fg.muted"
ml={2}
fontWeight="normal"
fontSize="sm"
>
({displayedLeagues.length})
</Text>
</Heading> </Heading>
<HStack gap={2}>
<Badge <HStack
cursor="pointer" gap={2}
colorScheme={!sportFilter ? "primary" : "gray"} bg={useColorModeValue("gray.100", "gray.800")}
onClick={() => setSportFilter("")} p={1}
borderRadius="full"
> >
{tMatches("all")} <Box
</Badge> px={4}
<Badge py={1.5}
borderRadius="full"
cursor="pointer" cursor="pointer"
colorScheme={ fontSize="sm"
sportFilter === "football" ? "green" : "gray" fontWeight="medium"
bg={!sportFilter ? "white" : "transparent"}
color={!sportFilter ? "black" : "fg.muted"}
shadow={!sportFilter ? "sm" : "none"}
onClick={() => setSportFilter("")}
transition="all 0.2s"
_dark={{
bg: !sportFilter ? "gray.600" : "transparent",
color: !sportFilter ? "white" : "gray.400",
}}
>
{t("all")}
</Box>
<Box
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
fontSize="sm"
fontWeight="medium"
bg={
sportFilter === "football"
? "green.500"
: "transparent"
} }
color={
sportFilter === "football" ? "white" : "fg.muted"
}
shadow={sportFilter === "football" ? "sm" : "none"}
onClick={() => onClick={() =>
setSportFilter( setSportFilter(
sportFilter === "football" ? "" : "football", sportFilter === "football" ? "" : "football",
) )
} }
transition="all 0.2s"
> >
{tMatches("football")} {tMatches("football")}
</Badge> </Box>
<Badge <Box
px={4}
py={1.5}
borderRadius="full"
cursor="pointer" cursor="pointer"
colorScheme={ fontSize="sm"
sportFilter === "basketball" ? "orange" : "gray" fontWeight="medium"
bg={
sportFilter === "basketball"
? "orange.500"
: "transparent"
} }
color={
sportFilter === "basketball" ? "white" : "fg.muted"
}
shadow={sportFilter === "basketball" ? "sm" : "none"}
onClick={() => onClick={() =>
setSportFilter( setSportFilter(
sportFilter === "basketball" ? "" : "basketball", sportFilter === "basketball" ? "" : "basketball",
) )
} }
transition="all 0.2s"
> >
{tMatches("basketball")} {tMatches("basketball")}
</Badge> </Box>
</HStack> </HStack>
</Flex> </Flex>
</Card.Header>
<Card.Body pt={0}> {/* Leagues Grid */}
{leagues.isLoading ? ( {leagues.isLoading ? (
<Flex justify="center" py={6}> <Flex justify="center" py={20}>
<Spinner size="sm" /> <Spinner
size="xl"
color="primary.500"
borderWidth="3px"
/>
</Flex>
) : displayedLeagues.length === 0 ? (
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Box
bg="gray.100"
_dark={{ bg: "gray.800" }}
p={6}
borderRadius="full"
mb={4}
>
<LuTrophy size={40} color="gray" />
</Box>
<Heading size="md" mb={2}>
Bulunamadı
</Heading>
<Text color="fg.muted">
Seçili kriterlere uygun lig bulunamadı.
</Text>
</Flex> </Flex>
) : ( ) : (
<VStack gap={2}> <Grid
{leagues.data?.data?.map((league: LeagueDto) => ( templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
xl: "repeat(3, 1fr)",
}}
gap={4}
>
{displayedLeagues.map((league: LeagueDto) => (
<GridItem key={league.id}>
<ChakraLink <ChakraLink
key={league.id}
as={Link} as={Link}
href="/matches" href={`/leagues/${league.id}`}
p={3} display="block"
borderRadius="md" h="full"
p={5}
borderRadius="xl"
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor={borderColor}
bg={cardBg}
_hover={{ _hover={{
borderColor: "primary.300", borderColor: "primary.300",
bg: "primary.50", shadow: "md",
_dark: { bg: "gray.750" }, transform: "translateY(-2px)",
}} }}
display="flex" transition="all 0.2s"
justifyContent="space-between"
alignItems="center"
textDecoration="none" textDecoration="none"
color="inherit" color="inherit"
data-group
> >
<VStack align="start" gap={0}> <Flex
<Text fontWeight="semibold">{league.name}</Text> justify="space-between"
<Text fontSize="xs" color="fg.muted"> align="flex-start"
{league.country?.name || ""} mb={4}
</Text> >
</VStack> <Box
<HStack gap={2}> p={2}
{league.sport ? ( borderRadius="lg"
bg={
league.sport === "football"
? "green.50"
: "orange.50"
}
_dark={{
bg:
league.sport === "football"
? "green.900"
: "orange.900",
}}
>
<LuTrophy
size={20}
color={
league.sport === "football"
? "var(--chakra-colors-green-500)"
: "var(--chakra-colors-orange-500)"
}
/>
</Box>
<Badge <Badge
size="xs" size="sm"
variant="subtle"
colorScheme={ colorScheme={
league.sport === "football" league.sport === "football"
? "green" ? "green"
@@ -232,104 +591,234 @@ export default function LeaguesContent() {
> >
{league.sport} {league.sport}
</Badge> </Badge>
) : null} </Flex>
{league.season ? (
<Text fontSize="xs" color="fg.muted"> <Heading
{league.season} size="sm"
mb={1}
lineClamp={1}
_groupHover={{ color: "primary.500" }}
>
{league.name}
</Heading>
<HStack color="fg.muted" fontSize="sm" gap={1}>
<LuMapPin size={14} />
<Text lineClamp={1}>
{league.country?.name || "Global"}
</Text> </Text>
) : null}
</HStack> </HStack>
</ChakraLink>
))} {league.season && (
</VStack> <Flex
mt={4}
pt={4}
borderTopWidth="1px"
borderColor={borderColor}
justify="space-between"
align="center"
>
<Text
fontSize="xs"
color="fg.muted"
fontWeight="medium"
>
SEZON: {league.season}
</Text>
<Icon
as={LuArrowRight}
color="gray.400"
_groupHover={{
color: "primary.500",
transform: "translateX(4px)",
}}
transition="all 0.2s"
/>
</Flex>
)}
</ChakraLink>
</GridItem>
))}
</Grid>
)} )}
</Card.Body>
</Card.Root>
</Box> </Box>
</Flex> </Flex>
</Tabs.Content> )}
{/* Teams Search Tab */} {/* TEAMS TAB */}
<Tabs.Content value="teams"> {activeTab === "teams" && (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl"> <Box p={{ base: 4, md: 8 }}>
<Card.Body> <Box maxW="2xl" mx="auto" mb={10}>
<InputGroup startElement={<LuSearch />} mb={4}> <InputGroup
startElement={<LuSearch color="gray.400" size={20} />}
w="full"
>
<Input <Input
placeholder={tMatches("search-teams")} placeholder={tMatches("search-teams") + "..."}
value={searchQuery} value={teamSearchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setTeamSearchQuery(e.target.value)}
variant="outline"
borderRadius="xl"
fontSize="lg"
py={6}
boxShadow="sm"
_focus={{
boxShadow: "0 0 0 2px var(--chakra-colors-primary-500)",
}}
/> />
</InputGroup> </InputGroup>
{debouncedQuery.length < 2 ? ( </Box>
<Text color="fg.muted" textAlign="center" py={8}>
{debouncedTeamQuery.length < 2 ? (
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Box
bg="primary.50"
_dark={{ bg: "primary.900" }}
p={8}
borderRadius="full"
mb={6}
>
<LuUsers
size={64}
color="var(--chakra-colors-primary-500)"
/>
</Box>
<Heading size="lg" mb={3}>
{t("search-at-least-2")} {t("search-at-least-2")}
</Heading>
<Text color="fg.muted" maxW="md">
Find detailed statistics, upcoming matches, and
head-to-head analysis by searching for any team worldwide.
</Text> </Text>
</Flex>
) : searchTeams.isLoading ? ( ) : searchTeams.isLoading ? (
<Flex justify="center" py={6}> <Flex justify="center" py={20}>
<Spinner size="md" /> <Spinner size="xl" color="primary.500" borderWidth="3px" />
</Flex>
) : searchTeams.data?.data?.length === 0 ? (
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Heading size="md" mb={2}>
Takım Bulunamadı
</Heading>
<Text color="fg.muted">
"{debouncedTeamQuery}" aramasıyla eşleşen bir takım
bulunamadı.
</Text>
</Flex> </Flex>
) : ( ) : (
<VStack gap={2}> <Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
xl: "repeat(3, 1fr)",
}}
gap={4}
>
{searchTeams.data?.data?.map((team: TeamDto) => ( {searchTeams.data?.data?.map((team: TeamDto) => (
<GridItem key={team.id}>
<ChakraLink <ChakraLink
key={team.id}
as={Link} as={Link}
href={`/teams/${team.id}`} href={`/teams/${team.id}`}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
_hover={{
borderColor: "primary.300",
bg: "primary.50",
_dark: { bg: "gray.750" },
}}
display="flex" display="flex"
alignItems="center" alignItems="center"
gap={3} p={4}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
bg={cardBg}
_hover={{
borderColor: "primary.300",
shadow: "md",
transform: "translateY(-2px)",
}}
transition="all 0.2s"
textDecoration="none" textDecoration="none"
color="inherit" color="inherit"
data-group
> >
{team.logo ? ( {team.logo ? (
<Box
w={12}
h={12}
borderRadius="full"
overflow="hidden"
flexShrink={0}
mr={4}
bg="white"
p={1}
shadow="sm"
>
<img <img
src={team.logo} src={team.logo}
width="32" width="100%"
height="32" height="100%"
style={{ borderRadius: "50%" }} style={{ objectFit: "contain" }}
alt={team.name} alt={team.name}
/> />
</Box>
) : ( ) : (
<Box <Flex
boxSize="32px" w={12}
h={12}
borderRadius="full" borderRadius="full"
bg="gray.200" bg="gray.100"
_dark={{ bg: "gray.600" }} _dark={{ bg: "gray.700" }}
/> align="center"
justify="center"
flexShrink={0}
mr={4}
>
<LuUsers size={20} color="gray" />
</Flex>
)} )}
<VStack align="start" gap={0}>
<Text fontWeight="semibold">{team.name}</Text> <VStack align="start" gap={0} flex={1}>
<Text fontSize="xs" color="fg.muted"> <Heading
{team.country || ""} size="sm"
lineClamp={1}
_groupHover={{ color: "primary.500" }}
>
{team.name}
</Heading>
<HStack color="fg.muted" fontSize="xs" gap={1}>
<LuMapPin size={12} />
<Text lineClamp={1}>
{team.country || "Global"}
</Text> </Text>
</HStack>
</VStack> </VStack>
<Badge <Badge
ml="auto" ml={2}
size="xs" size="sm"
colorScheme={ colorScheme={
team.sport === "football" ? "green" : "orange" team.sport === "football" ? "green" : "orange"
} }
variant="subtle"
> >
{team.sport} {team.sport}
</Badge> </Badge>
</ChakraLink> </ChakraLink>
</GridItem>
))} ))}
</VStack> </Grid>
)} )}
</Card.Body>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Box> </Box>
)}
</Card.Root>
</SlideUp> </SlideUp>
</Box>
</Box>
); );
} }
+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 MatchList } from "./match-list";
export { default as SportFilter } from "./sport-filter"; export { default as SportFilter } from "./sport-filter";
export { default as LeagueSidebar } from "./league-sidebar"; export { default as LeagueSidebar } from "./league-sidebar";
export { default as LeagueFilterBar } from "./league-filter-bar";
export { default as PredictionCard } from "./prediction-card"; export { default as PredictionCard } from "./prediction-card";
export { default as MatchDetailContent } from "./match-detail-content"; export { default as MatchDetailContent } from "./match-detail-content";
export { default as MatchesContent } from "./matches-content"; export { default as MatchesContent } from "./matches-content";
@@ -0,0 +1,175 @@
"use client";
import { Box, Flex, Text, Badge, Image, ScrollArea } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import type { ActiveLeagueDto } from "@/lib/api/matches/types";
interface LeagueFilterBarProps {
leagues: ActiveLeagueDto[];
selectedLeagueId: string | null;
onSelect: (leagueId: string | null) => void;
isLoading?: boolean;
}
/**
* LeagueFilterBar — Horizontal scrollable league filter chips for mobile.
* Shows country flag, league name, country name, and live/match count badges.
*/
export default function LeagueFilterBar({
leagues,
selectedLeagueId,
onSelect,
isLoading,
}: LeagueFilterBarProps) {
const t = useTranslations("matches");
const chipBg = useColorModeValue("white", "gray.800");
const chipBorder = useColorModeValue("gray.200", "gray.600");
const activeBg = useColorModeValue("primary.50", "primary.900");
const activeBorder = useColorModeValue("primary.400", "primary.500");
const countryText = useColorModeValue("gray.500", "gray.400");
if (isLoading) {
return (
<Flex gap={2} overflow="hidden" pb={2}>
{Array.from({ length: 5 }).map((_, i) => (
<Box
key={i}
h="42px"
w="120px"
bg="bg.muted"
borderRadius="full"
flexShrink={0}
animation="pulse 1.5s ease-in-out infinite"
/>
))}
</Flex>
);
}
return (
<ScrollArea.Root width="full" size="xs">
<ScrollArea.Viewport>
<ScrollArea.Content py="1">
<Flex gap={2} flexWrap="nowrap" pb={1}>
{/* "All Leagues" chip */}
<Box
as="button"
onClick={() => onSelect(null)}
px={3.5}
py={2}
borderRadius="full"
borderWidth="1.5px"
borderColor={
selectedLeagueId === null ? activeBorder : chipBorder
}
bg={selectedLeagueId === null ? activeBg : chipBg}
cursor="pointer"
flexShrink={0}
transition="all 0.15s"
_hover={{ borderColor: activeBorder }}
whiteSpace="nowrap"
>
<Text
fontSize="xs"
fontWeight={selectedLeagueId === null ? "bold" : "medium"}
color={selectedLeagueId === null ? "primary.fg" : "fg"}
>
{t("all-leagues")}
</Text>
</Box>
{/* League chips */}
{leagues.map((league) => {
const isActive = selectedLeagueId === league.id;
return (
<Box
as="button"
key={league.id}
onClick={() => onSelect(league.id)}
px={3}
py={1.5}
borderRadius="full"
borderWidth="1.5px"
borderColor={isActive ? activeBorder : chipBorder}
bg={isActive ? activeBg : chipBg}
cursor="pointer"
flexShrink={0}
transition="all 0.15s"
_hover={{ borderColor: activeBorder }}
whiteSpace="nowrap"
>
<Flex align="center" gap={1.5}>
{/* Flag or fallback */}
{league.countryFlag ? (
<Image
src={league.countryFlag}
alt={league.countryName || ""}
boxSize="14px"
objectFit="contain"
borderRadius="xs"
flexShrink={0}
/>
) : league.countryName ? (
<Flex
boxSize="14px"
bg="gray.200"
borderRadius="xs"
align="center"
justify="center"
flexShrink={0}
fontSize="6px"
fontWeight="bold"
color="gray.600"
>
{league.countryName.slice(0, 2).toUpperCase()}
</Flex>
) : null}
{/* League name + country */}
<Flex
direction="column"
align="flex-start"
gap={0}
lineHeight="1"
>
<Text
fontSize="xs"
fontWeight={isActive ? "bold" : "medium"}
color={isActive ? "primary.fg" : "fg"}
>
{league.name}
</Text>
{league.countryName && (
<Text fontSize="2xs" color={countryText} lineHeight="1">
{league.countryName}
</Text>
)}
</Flex>
{/* Live badge */}
{league.liveCount > 0 && (
<Badge
colorPalette="red"
variant="solid"
borderRadius="full"
fontSize="2xs"
px={1}
ml={0.5}
>
{league.liveCount}
</Badge>
)}
</Flex>
</Box>
);
})}
</Flex>
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="horizontal" />
<ScrollArea.Corner />
</ScrollArea.Root>
);
}
+36 -3
View File
@@ -24,6 +24,7 @@ export default function LeagueSidebar({
const borderColor = useColorModeValue("gray.100", "gray.700"); const borderColor = useColorModeValue("gray.100", "gray.700");
const activeBg = useColorModeValue("primary.50", "primary.900"); const activeBg = useColorModeValue("primary.50", "primary.900");
const hoverBg = useColorModeValue("gray.50", "gray.750"); const hoverBg = useColorModeValue("gray.50", "gray.750");
const countryTextColor = useColorModeValue("gray.500", "gray.400");
if (isLoading) { if (isLoading) {
return ( return (
@@ -111,26 +112,58 @@ export default function LeagueSidebar({
> >
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<Flex align="center" gap={2} minW={0} flex={1}> <Flex align="center" gap={2} minW={0} flex={1}>
{league.countryFlag && ( {/* Country Flag or Fallback */}
{league.countryFlag ? (
<Image <Image
src={league.countryFlag} src={league.countryFlag}
alt={league.countryName || ""} alt={league.countryName || ""}
boxSize="16px" boxSize="18px"
objectFit="contain" objectFit="contain"
flexShrink={0} flexShrink={0}
borderRadius="sm"
/> />
) : (
<Flex
boxSize="18px"
bg="gray.200"
borderRadius="sm"
align="center"
justify="center"
flexShrink={0}
fontSize="8px"
fontWeight="bold"
color="gray.600"
>
{league.countryName?.slice(0, 2)?.toUpperCase() || "??"}
</Flex>
)} )}
{/* League Name + Country */}
<Box minW={0} flex={1}>
<Text <Text
fontSize="sm" fontSize="sm"
fontWeight={isActive ? "bold" : "medium"} fontWeight={isActive ? "bold" : "medium"}
color={isActive ? "primary.fg" : "fg"} color={isActive ? "primary.fg" : "fg"}
truncate truncate
lineHeight="1.3"
> >
{league.name} {league.name}
</Text> </Text>
{league.countryName && (
<Text
fontSize="2xs"
color={countryTextColor}
truncate
lineHeight="1.2"
>
{league.countryName}
</Text>
)}
</Box>
</Flex> </Flex>
<Flex gap={1.5} flexShrink={0}> {/* Badges */}
<Flex gap={1.5} flexShrink={0} ml={2}>
{league.liveCount > 0 && ( {league.liveCount > 0 && (
<Badge <Badge
colorPalette="red" colorPalette="red"
+301
View File
@@ -0,0 +1,301 @@
"use client";
import {
Box,
Card,
Flex,
HStack,
Text,
VStack,
Badge,
SimpleGrid,
Icon,
} from "@chakra-ui/react";
import { useColorModeValue } from "@/components/ui/color-mode";
import {
LuUsers,
LuUser,
LuInfo,
LuShieldCheck,
LuClock,
} from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
import type { MatchPredictionDto } from "@/lib/api/predictions/types";
interface LineupsCardProps {
match: MatchResponseDto;
prediction?: MatchPredictionDto | null;
}
/**
* Lineup source metadata used for title, badge, and informational banners.
*/
function getLineupSourceMeta(source?: string) {
switch (source) {
case "confirmed_live":
return {
title: "Resmi İlk 11",
badge: "Onaylı Kadro",
badgeColor: "green" as const,
icon: LuShieldCheck,
description: "Kadro resmi olarak onaylandı.",
};
case "confirmed_participation":
return {
title: "Onaylı Kadro",
badge: "Onaylı",
badgeColor: "green" as const,
icon: LuShieldCheck,
description: "Kadro maç katılım verilerinden alındı.",
};
case "probable_xi":
return {
title: "Muhtemel Kadro",
badge: "Muhtemel",
badgeColor: "orange" as const,
icon: LuUsers,
description:
"Son maçlardaki ilk 11 verilerine dayalı muhtemel kadro. AI analizi bu kadro üzerinden yapılmaktadır.",
};
case "none":
default:
return {
title: "Kadro Bilgisi",
badge: "Kadro Bekleniyor",
badgeColor: "gray" as const,
icon: LuClock,
description:
"Kadro henüz açıklanmadı. AI analizi, takımların genel güç dengesi ve istatistiklerine dayalı olarak üretilmiştir.",
};
}
}
export default function LineupsCard({ match, prediction }: LineupsCardProps) {
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const headerBg = useColorModeValue("gray.50", "whiteAlpha.50");
const infoBg = useColorModeValue("blue.50", "whiteAlpha.100");
const infoBorder = useColorModeValue("blue.200", "blue.800");
let homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
let awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
// Determine lineup source from prediction data quality
const source = prediction?.data_quality?.lineup_source;
const meta = getLineupSourceMeta(source);
// Fallback: If no starting players are marked, but we have players, treat them as probable XI
if (
homeLineups.length === 0 &&
match.lineups?.home &&
match.lineups.home.length > 0
) {
homeLineups = match.lineups.home.slice(0, 11);
}
if (
awayLineups.length === 0 &&
match.lineups?.away &&
match.lineups.away.length > 0
) {
awayLineups = match.lineups.away.slice(0, 11);
}
const hasLineups = homeLineups.length > 0 || awayLineups.length > 0;
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
<Card.Body>
{/* ── Header ────────────────────────────────── */}
<Flex justify="space-between" align="center" mb={4}>
<HStack gap={2}>
<Icon as={meta.icon} boxSize={5} color="fg.muted" />
<Text fontSize="lg" fontWeight="semibold">
{meta.title}
</Text>
</HStack>
<Badge colorPalette={meta.badgeColor} variant="subtle">
{meta.badge}
</Badge>
</Flex>
{/* ── Info Banner ───────────────────────────── */}
{source !== "confirmed_live" && (
<Flex
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
p={3}
mb={4}
align="center"
gap={2}
>
<Icon as={LuInfo} color="blue.500" flexShrink={0} />
<Text fontSize="xs" color="fg.muted">
{meta.description}
</Text>
</Flex>
)}
{/* ── Lineups Grid ─────────────────────────── */}
{hasLineups ? (
<SimpleGrid columns={{ base: 1, md: 2 }} gap={6}>
{/* Home Team Lineup */}
<Box>
<Flex
bg={headerBg}
p={3}
borderRadius="md"
align="center"
justify="center"
mb={3}
gap={2}
>
<Text fontWeight="bold">{match.homeTeamName}</Text>
<Badge size="sm" variant="outline" colorPalette="blue">
Ev Sahibi
</Badge>
</Flex>
{homeLineups.length > 0 ? (
<VStack align="stretch" gap={2}>
{homeLineups.map((p, idx) => (
<HStack
key={p.player?.id || idx}
p={2}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
>
<Icon as={LuUser} color="fg.muted" />
{p.shirtNumber && (
<Text fontSize="xs" fontWeight="bold" w="20px">
{p.shirtNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium">
{p.player?.name || "Bilinmiyor"}
</Text>
{p.position && (
<Badge ml="auto" size="sm" variant="surface">
{p.position}
</Badge>
)}
</HStack>
))}
</VStack>
) : (
<Flex
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
justify="center"
align="center"
direction="column"
gap={1}
>
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
Kadro henüz belli değil
</Text>
<Text fontSize="xs" color="fg.subtle">
Maç saatine yakın güncellenecek
</Text>
</Flex>
)}
</Box>
{/* Away Team Lineup */}
<Box>
<Flex
bg={headerBg}
p={3}
borderRadius="md"
align="center"
justify="center"
mb={3}
gap={2}
>
<Text fontWeight="bold">{match.awayTeamName}</Text>
<Badge size="sm" variant="outline" colorPalette="red">
Deplasman
</Badge>
</Flex>
{awayLineups.length > 0 ? (
<VStack align="stretch" gap={2}>
{awayLineups.map((p, idx) => (
<HStack
key={p.player?.id || idx}
p={2}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
>
<Icon as={LuUser} color="fg.muted" />
{p.shirtNumber && (
<Text fontSize="xs" fontWeight="bold" w="20px">
{p.shirtNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium">
{p.player?.name || "Bilinmiyor"}
</Text>
{p.position && (
<Badge ml="auto" size="sm" variant="surface">
{p.position}
</Badge>
)}
</HStack>
))}
</VStack>
) : (
<Flex
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
justify="center"
align="center"
direction="column"
gap={1}
>
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
Kadro henüz belli değil
</Text>
<Text fontSize="xs" color="fg.subtle">
Maç saatine yakın güncellenecek
</Text>
</Flex>
)}
</Box>
</SimpleGrid>
) : (
/* ── Empty State: No lineups at all ─────── */
<Flex
direction="column"
align="center"
justify="center"
py={8}
gap={3}
>
<Icon as={LuClock} boxSize={8} color="fg.subtle" />
<VStack gap={1}>
<Text fontWeight="semibold" color="fg.muted">
Kadro Henüz Açıklanmadı
</Text>
<Text
fontSize="sm"
color="fg.subtle"
textAlign="center"
maxW="sm"
>
{match.homeTeamName} ve {match.awayTeamName} kadroları maç
saatine yakın güncellenecektir. AI analizi, takım istatistikleri
ve güç dengesi üzerinden yapılmaktadır.
</Text>
</VStack>
</Flex>
)}
</Card.Body>
</Card.Root>
);
}
+35
View File
@@ -11,10 +11,13 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { slideUpVariants } from "@/components/motion"; import { slideUpVariants } from "@/components/motion";
import type { MatchResponseDto } from "@/lib/api/matches/types"; import type { MatchResponseDto } from "@/lib/api/matches/types";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { useState } from "react";
import { LoginModal } from "@/components/auth/login-modal";
interface MatchCardProps { interface MatchCardProps {
match: MatchResponseDto; match: MatchResponseDto;
@@ -24,7 +27,10 @@ const MotionBox = motion.create(Box);
export default function MatchCard({ match }: MatchCardProps) { export default function MatchCard({ match }: MatchCardProps) {
const t = useTranslations("matches"); const t = useTranslations("matches");
const tAuth = useTranslations("auth");
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const [loginModalOpen, setLoginModalOpen] = useState(false);
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
const cardBorder = useColorModeValue("gray.100", "gray.700"); const cardBorder = useColorModeValue("gray.100", "gray.700");
@@ -42,6 +48,10 @@ export default function MatchCard({ match }: MatchCardProps) {
: t("not-started"); : t("not-started");
const handleClick = () => { const handleClick = () => {
if (!session) {
setLoginModalOpen(true);
return;
}
router.push(`/matches/${match.id}`); router.push(`/matches/${match.id}`);
}; };
@@ -49,6 +59,7 @@ export default function MatchCard({ match }: MatchCardProps) {
const matchDate = new Date(match.mstUtc); const matchDate = new Date(match.mstUtc);
return ( return (
<>
<MotionBox <MotionBox
variants={slideUpVariants} variants={slideUpVariants}
bg={cardBg} bg={cardBg}
@@ -223,6 +234,30 @@ export default function MatchCard({ match }: MatchCardProps) {
</Text> </Text>
</Flex> </Flex>
)} )}
{/* Auth hint for unauthenticated users */}
{!session && (
<Flex
mt={2}
pt={2}
borderTopWidth="1px"
borderColor={cardBorder}
justify="center"
align="center"
>
<Text fontSize="xs" color="orange.500" fontWeight="semibold">
🔒 {tAuth("login-required-title")}
</Text>
</Flex>
)}
</MotionBox> </MotionBox>
{/* Login Modal — shown when unauthenticated user clicks a match */}
<LoginModal
open={loginModalOpen}
onOpenChange={setLoginModalOpen}
initialMode="login"
/>
</>
); );
} }
File diff suppressed because it is too large Load Diff
+32 -4
View File
@@ -3,7 +3,11 @@
import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react"; import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion"; import {
StaggerContainer,
StaggerItem,
ScrollSlideUp,
} from "@/components/motion";
import { Skeleton } from "@/components/ui/feedback/skeleton"; import { Skeleton } from "@/components/ui/feedback/skeleton";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
import type { import type {
@@ -53,7 +57,13 @@ function MatchCardSkeleton() {
</HStack> </HStack>
{/* League */} {/* League */}
<Flex mt={3} pt={2} borderTopWidth="1px" borderColor={border} justify="center"> <Flex
mt={3}
pt={2}
borderTopWidth="1px"
borderColor={border}
justify="center"
>
<Skeleton height="12px" width="120px" /> <Skeleton height="12px" width="120px" />
</Flex> </Flex>
</Box> </Box>
@@ -117,6 +127,10 @@ export default function MatchList({
); );
} }
const sortedFlatMatches = [...flatMatches].sort(
(a, b) => Number(a.mstUtc) - Number(b.mstUtc),
);
return ( return (
<StaggerContainer> <StaggerContainer>
<Grid <Grid
@@ -127,7 +141,7 @@ export default function MatchList({
}} }}
gap={4} gap={4}
> >
{flatMatches.map((match) => ( {sortedFlatMatches.map((match) => (
<StaggerItem key={match.id}> <StaggerItem key={match.id}>
<MatchCard match={match} /> <MatchCard match={match} />
</StaggerItem> </StaggerItem>
@@ -148,9 +162,23 @@ export default function MatchList({
); );
} }
// Sort leagues by their earliest match, and sort matches within each league
const sortedLeagues = [...leagues]
.map((league) => ({
...league,
matches: [...league.matches].sort(
(a, b) => Number(a.mstUtc) - Number(b.mstUtc),
),
}))
.sort((a, b) => {
const earliestA = Math.min(...a.matches.map((m) => Number(m.mstUtc)));
const earliestB = Math.min(...b.matches.map((m) => Number(m.mstUtc)));
return earliestA - earliestB;
});
return ( return (
<StaggerContainer> <StaggerContainer>
{leagues.map((league) => ( {sortedLeagues.map((league) => (
<StaggerItem key={league.id}> <StaggerItem key={league.id}>
<Box mb={6}> <Box mb={6}>
{/* League Header */} {/* League Header */}
+132 -22
View File
@@ -1,13 +1,20 @@
"use client"; "use client";
import { Box, Flex, Heading } from "@chakra-ui/react"; import { Box, Flex, Heading, Group, Button } from "@chakra-ui/react";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { SlideUp } from "@/components/motion"; import { SlideUp } from "@/components/motion";
import { SportFilter, LeagueSidebar, MatchList } from "@/components/matches"; import {
SportFilter,
LeagueSidebar,
LeagueFilterBar,
MatchList,
} from "@/components/matches";
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks"; import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
import { useMatchStore } from "@/lib/stores/match-store"; import { useMatchStore } from "@/lib/stores/match-store";
type QuickFilter = "all" | "today" | "live" | "next_1_hour";
export default function MatchesContent() { export default function MatchesContent() {
const t = useTranslations("matches"); const t = useTranslations("matches");
@@ -16,6 +23,9 @@ export default function MatchesContent() {
const setSport = useMatchStore((s) => s.setSport); const setSport = useMatchStore((s) => s.setSport);
const setLeague = useMatchStore((s) => s.setLeague); const setLeague = useMatchStore((s) => s.setLeague);
const [quickFilter, setQuickFilter] = useState<QuickFilter>("all");
const [dateFilter, setDateFilter] = useState<string>("");
// Fetch active leagues for sidebar // Fetch active leagues for sidebar
const { data: leaguesData, isLoading: leaguesLoading } = const { data: leaguesData, isLoading: leaguesLoading } =
useActiveLeagues(sport); useActiveLeagues(sport);
@@ -26,42 +36,63 @@ export default function MatchesContent() {
// Trigger query on sport/league change // Trigger query on sport/league change
const { data: matchesData, isPending: matchesLoading } = (() => { const { data: matchesData, isPending: matchesLoading } = (() => {
// We use the queryMatches mutation for initial data
// but for the UI we want a reactive approach.
// Let's use the standard list with league filter
return { return {
data: queryMatches.data, data: queryMatches.data,
isPending: queryMatches.isPending, isPending: queryMatches.isPending,
}; };
})(); })();
const triggerQuery = (
currentSport: typeof sport,
currentLeague: string | null,
currentFilter: QuickFilter,
currentDate?: string,
) => {
const payload: any = {
sport: currentSport,
leagueId: currentLeague || undefined,
limit: 100,
};
if (currentDate) {
payload.date = currentDate;
} else if (currentFilter === "today") {
// YYYY-MM-DD for today
payload.date = new Date().toISOString().split("T")[0];
} else if (currentFilter === "live") {
payload.status = "LIVE";
} else if (currentFilter === "next_1_hour") {
payload.dateRange = {
from: new Date().toISOString(),
to: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
}
queryMatches.mutate(payload);
};
// Auto-trigger query when sport or league changes // Auto-trigger query when sport or league changes
const handleSportChange = (newSport: typeof sport) => { const handleSportChange = (newSport: typeof sport) => {
setSport(newSport); setSport(newSport);
queryMatches.mutate({ setLeague(null);
sport: newSport, triggerQuery(newSport, null, quickFilter, dateFilter);
leagueId: undefined,
limit: 100,
});
}; };
const handleLeagueChange = (leagueId: string | null) => { const handleLeagueChange = (leagueId: string | null) => {
setLeague(leagueId); setLeague(leagueId);
queryMatches.mutate({ triggerQuery(sport, leagueId, quickFilter, dateFilter);
sport, };
leagueId: leagueId || undefined,
limit: 100, const handleQuickFilterChange = (filter: QuickFilter) => {
}); setDateFilter(""); // Clear specific date
setQuickFilter(filter);
triggerQuery(sport, leagueFilter, filter, undefined);
}; };
// Initial load // Initial load
useEffect(() => { useEffect(() => {
if (!queryMatches.data && !queryMatches.isPending) { if (!queryMatches.data && !queryMatches.isPending) {
queryMatches.mutate({ triggerQuery(sport, leagueFilter, quickFilter, dateFilter);
sport,
leagueId: leagueFilter || undefined,
limit: 100,
});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -75,7 +106,7 @@ export default function MatchesContent() {
<Flex <Flex
justify="space-between" justify="space-between"
align="center" align="center"
mb={6} mb={4}
flexWrap="wrap" flexWrap="wrap"
gap={3} gap={3}
> >
@@ -85,6 +116,85 @@ export default function MatchesContent() {
<SportFilter value={sport} onChange={handleSportChange} /> <SportFilter value={sport} onChange={handleSportChange} />
</Flex> </Flex>
{/* Quick Filters */}
<Flex
mb={6}
overflowX="auto"
pb={2}
css={{ "&::-webkit-scrollbar": { display: "none" } }}
gap={4}
align="center"
>
<Group attached>
<Button
size="sm"
onClick={() => handleQuickFilterChange("all")}
colorPalette={quickFilter === "all" ? "primary" : "gray"}
variant={quickFilter === "all" ? "solid" : "outline"}
>
{t("all-matches")}
</Button>
<Button
size="sm"
onClick={() => handleQuickFilterChange("today")}
colorPalette={quickFilter === "today" ? "primary" : "gray"}
variant={quickFilter === "today" ? "solid" : "outline"}
>
{t("today-matches")}
</Button>
<Button
size="sm"
onClick={() => handleQuickFilterChange("live")}
colorPalette={quickFilter === "live" ? "primary" : "gray"}
variant={quickFilter === "live" ? "solid" : "outline"}
>
{t("live")}
</Button>
<Button
size="sm"
onClick={() => handleQuickFilterChange("next_1_hour")}
colorPalette={quickFilter === "next_1_hour" ? "primary" : "gray"}
variant={quickFilter === "next_1_hour" ? "solid" : "outline"}
>
{t("next-1-hour")}
</Button>
</Group>
<input
type="date"
value={dateFilter}
onChange={(e) => {
const dateVal = e.target.value;
setDateFilter(dateVal);
if (dateVal) {
setQuickFilter("all"); // Reset quick filter highlight
triggerQuery(sport, leagueFilter, "all", dateVal);
} else {
handleQuickFilterChange("all");
}
}}
style={{
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "1px solid var(--chakra-colors-gray-200)",
fontSize: "0.875rem",
background: "transparent",
color: "inherit",
outline: "none",
}}
/>
</Flex>
{/* Mobile League Filter Bar (visible on small screens only) */}
<Box display={{ base: "block", lg: "none" }} mb={4}>
<LeagueFilterBar
leagues={leagues}
selectedLeagueId={leagueFilter}
onSelect={handleLeagueChange}
isLoading={leaguesLoading}
/>
</Box>
{/* Main Content */} {/* Main Content */}
<Flex <Flex
gap={6} gap={6}
File diff suppressed because it is too large Load Diff
+44 -15
View File
@@ -153,7 +153,12 @@ function TripleValueCard({
isValue ? "green.300" : "gray.200", isValue ? "green.300" : "gray.200",
isValue ? "green.700" : "gray.700", 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 ( return (
<Box <Box
@@ -249,13 +254,7 @@ function ProgressBar({
const trackBg = useColorModeValue("gray.100", "gray.700"); const trackBg = useColorModeValue("gray.100", "gray.700");
const w = max > 0 ? Math.min(100, (value / max) * 100) : 0; const w = max > 0 ? Math.min(100, (value / max) * 100) : 0;
return ( return (
<Box <Box h="10px" w="full" bg={trackBg} borderRadius="full" overflow="hidden">
h="10px"
w="full"
bg={trackBg}
borderRadius="full"
overflow="hidden"
>
<Box <Box
h="full" h="full"
w={`${w}%`} w={`${w}%`}
@@ -460,13 +459,28 @@ function HtftGrid({
{/* Column headers */} {/* Column headers */}
<Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}> <Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}>
<Box /> <Box />
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted"> <Text
fontSize="2xs"
fontWeight="bold"
textAlign="center"
color="fg.muted"
>
MS 1 MS 1
</Text> </Text>
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted"> <Text
fontSize="2xs"
fontWeight="bold"
textAlign="center"
color="fg.muted"
>
MS X MS X
</Text> </Text>
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted"> <Text
fontSize="2xs"
fontWeight="bold"
textAlign="center"
color="fg.muted"
>
MS 2 MS 2
</Text> </Text>
</Grid> </Grid>
@@ -611,7 +625,12 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
{/* Engine version badge */} {/* Engine version badge */}
<HStack> <HStack>
<Badge colorPalette="purple" variant="subtle" borderRadius="full" fontSize="2xs"> <Badge
colorPalette="purple"
variant="subtle"
borderRadius="full"
fontSize="2xs"
>
{engine.version} {engine.version}
</Badge> </Badge>
{engine.consensus && ( {engine.consensus && (
@@ -621,11 +640,18 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
borderRadius="full" borderRadius="full"
fontSize="2xs" fontSize="2xs"
> >
{engine.consensus === "AGREE" ? "Motorlar Uyumlu" : "Motorlar Farklı"} {engine.consensus === "AGREE"
? "Motorlar Uyumlu"
: "Motorlar Farklı"}
</Badge> </Badge>
)} )}
{valueHits.length > 0 && ( {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 {valueHits.length} Değer Sinyali
</Badge> </Badge>
)} )}
@@ -656,7 +682,10 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
{/* Cards + HTFT side by side on large screens */} {/* Cards + HTFT side by side on large screens */}
{(hasCards || hasHtft) && ( {(hasCards || hasHtft) && (
<Grid <Grid
templateColumns={{ base: "1fr", xl: hasCards && hasHtft ? "1fr 1fr" : "1fr" }} templateColumns={{
base: "1fr",
xl: hasCards && hasHtft ? "1fr 1fr" : "1fr",
}}
gap={4} gap={4}
> >
{hasCards && <CardsSection cards={cards} />} {hasCards && <CardsSection cards={cards} />}
+79 -14
View File
@@ -8,7 +8,14 @@ import {
useInView, useInView,
type HTMLMotionProps, type HTMLMotionProps,
} from "framer-motion"; } from "framer-motion";
import { forwardRef, type ReactNode, useEffect, useRef } from "react"; import {
forwardRef,
Key,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
// ======================== // ========================
// Shared animation variants // Shared animation variants
@@ -381,34 +388,92 @@ interface SparkleProps {
color?: string; color?: string;
} }
export function Sparkles({ count = 6, color = "rgba(56, 178, 172, 0.6)" }: SparkleProps) { interface SparkleConfig {
id: number;
size: number;
left: number;
bottom: number;
y: number;
duration: number;
delay: number;
}
export function Sparkles({
count = 6,
color = "rgba(56, 178, 172, 0.6)",
}: SparkleProps) {
const [sparkles, setSparkles] = useState<SparkleConfig[]>([]);
useEffect(() => {
const newSparkles = Array.from({ length: count }).map((_, i) => ({
id: i,
size: 4 + Math.random() * 4,
left: 10 + Math.random() * 80,
bottom: Math.random() * 30,
y: -(60 + Math.random() * 80),
duration: 2.5 + Math.random() * 2,
delay: Math.random() * 3,
}));
setSparkles(newSparkles);
}, [count]);
if (sparkles.length === 0) {
return ( return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}> <div
{Array.from({ length: count }).map((_, i) => (
<motion.div
key={i}
style={{ style={{
position: "absolute", position: "absolute",
width: 4 + Math.random() * 4, inset: 0,
height: 4 + Math.random() * 4, overflow: "hidden",
pointerEvents: "none",
}}
/>
);
}
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
pointerEvents: "none",
}}
>
{sparkles.map(
(sparkle: {
id: Key | null | undefined;
size: any;
left: any;
bottom: any;
y: string | number | null;
duration: any;
delay: any;
}) => (
<motion.div
key={sparkle.id}
style={{
position: "absolute",
width: sparkle.size,
height: sparkle.size,
borderRadius: "50%", borderRadius: "50%",
background: color, background: color,
left: `${10 + Math.random() * 80}%`, left: `${sparkle.left}%`,
bottom: `${Math.random() * 30}%`, bottom: `${sparkle.bottom}%`,
}} }}
animate={{ animate={{
y: [0, -(60 + Math.random() * 80)], y: [0, sparkle.y],
opacity: [0, 1, 1, 0], opacity: [0, 1, 1, 0],
scale: [0.5, 1, 0.8, 0], scale: [0.5, 1, 0.8, 0],
}} }}
transition={{ transition={{
duration: 2.5 + Math.random() * 2, duration: sparkle.duration,
repeat: Infinity, repeat: Infinity,
delay: Math.random() * 3, delay: sparkle.delay,
ease: "easeOut", ease: "easeOut",
}} }}
/> />
))} ),
)}
</div> </div>
); );
} }
@@ -154,8 +154,9 @@ export default function PredictionsContent() {
</Badge> </Badge>
<Badge <Badge
colorPalette={ colorPalette={
riskColors[pred.risk?.level?.toUpperCase()] || riskColors[
"gray" pred.risk?.level?.toUpperCase()
] || "gray"
} }
variant="subtle" variant="subtle"
fontSize="2xs" fontSize="2xs"
+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 { yupResolver } from "@hookform/resolvers/yup";
import { PasswordInput } from "@/components/ui/forms/password-input"; import { PasswordInput } from "@/components/ui/forms/password-input";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SubscriptionCard } from "@/components/subscription";
interface InfoRowProps { interface InfoRowProps {
icon: React.ReactNode; icon: React.ReactNode;
@@ -174,6 +175,9 @@ export default function ProfileContent() {
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
{/* Subscription Info */}
<SubscriptionCard />
{/* Account Info */} {/* Account Info */}
<Card.Root <Card.Root
bg={cardBg} bg={cardBg}
+10 -1
View File
@@ -16,8 +16,10 @@ import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { LuSearch, LuX } from "react-icons/lu"; import { LuSearch, LuX } from "react-icons/lu";
import type { TeamDto } from "@/lib/api/leagues/types"; import type { TeamDto } from "@/lib/api/leagues/types";
import { useSession } from "next-auth/react";
export default function GlobalSearch() { export default function GlobalSearch() {
const { data: session } = useSession();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [debouncedQuery, setDebouncedQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState("");
@@ -35,6 +37,7 @@ export default function GlobalSearch() {
const borderColor = useColorModeValue("gray.200", "gray.700"); const borderColor = useColorModeValue("gray.200", "gray.700");
const hoverBg = useColorModeValue("gray.50", "gray.800"); const hoverBg = useColorModeValue("gray.50", "gray.800");
const inputBg = useColorModeValue("gray.50", "gray.800"); const inputBg = useColorModeValue("gray.50", "gray.800");
const shortcutBg = useColorModeValue("gray.100", "gray.700");
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300); const timer = setTimeout(() => setDebouncedQuery(query), 300);
@@ -85,6 +88,12 @@ export default function GlobalSearch() {
[router], [router],
); );
// If user is not logged in, don't show the team search,
// as it requires auth to view team detail pages.
if (!session) {
return null;
}
return ( return (
<Box <Box
ref={containerRef} ref={containerRef}
@@ -142,7 +151,7 @@ export default function GlobalSearch() {
fontSize="xs" fontSize="xs"
color="fg.muted" color="fg.muted"
flexShrink={0} flexShrink={0}
bg={useColorModeValue("gray.100", "gray.700")} bg={shortcutBg}
px={1.5} px={1.5}
py={0.5} py={0.5}
borderRadius="md" borderRadius="md"
+4 -4
View File
@@ -399,7 +399,7 @@ function HomeCard() {
const imagesCollection = createListCollection({ items: images }); const imagesCollection = createListCollection({ items: images });
const currentImage = imagesCollection.items.find( const currentImage = imagesCollection.items.find(
(img) => img.value === selectedImage (img) => img.value === selectedImage,
); );
const nodeCollection = createTreeCollection<Node>({ const nodeCollection = createTreeCollection<Node>({
@@ -410,7 +410,7 @@ function HomeCard() {
const [tabs, setTabs] = React.useState<Item[]>(itemsTabs); const [tabs, setTabs] = React.useState<Item[]>(itemsTabs);
const [selectedTab, setSelectedTab] = React.useState<string | null>( const [selectedTab, setSelectedTab] = React.useState<string | null>(
itemsTabs[0].id itemsTabs[0].id,
); );
const uuid = () => { const uuid = () => {
@@ -2682,7 +2682,7 @@ function HomeCard() {
} }
onCheckedChange={(changes) => { onCheckedChange={(changes) => {
setSelection( setSelection(
changes.checked ? items.map((item) => item.name) : [] changes.checked ? items.map((item) => item.name) : [],
); );
}} }}
/> />
@@ -2710,7 +2710,7 @@ function HomeCard() {
setSelection((prev) => setSelection((prev) =>
changes.checked changes.checked
? [...prev, item.name] ? [...prev, item.name]
: selection.filter((name) => name !== item.name) : selection.filter((name) => name !== item.name),
); );
}} }}
/> />
@@ -22,6 +22,8 @@ import {
useSyncBulletin, useSyncBulletin,
useRolloverHistory, useRolloverHistory,
} from "@/lib/api/spor-toto/use-hooks"; } from "@/lib/api/spor-toto/use-hooks";
import { useQueryClient } from "@tanstack/react-query";
import { UsersQueryKeys } from "@/lib/api/users/use-hooks";
import type { import type {
SporTotoBulletinDto, SporTotoBulletinDto,
SporTotoPredictionResultDto, SporTotoPredictionResultDto,
@@ -59,15 +61,11 @@ export default function SporTotoContent() {
const rolloverHistory = useRolloverHistory(10); const rolloverHistory = useRolloverHistory(10);
const syncBulletin = useSyncBulletin(); const syncBulletin = useSyncBulletin();
const generatePrediction = useGeneratePrediction(); const generatePrediction = useGeneratePrediction();
const queryClient = useQueryClient();
const toast = (opts: { title: string; status: string }) => const toast = (opts: { title: string; status: string }) =>
toaster.create({ toaster.create({
title: opts.title, title: opts.title,
type: opts.status as type: opts.status as "success" | "warning" | "error" | "info" | "loading",
| "success"
| "warning"
| "error"
| "info"
| "loading",
}); });
const handleSync = async () => { const handleSync = async () => {
@@ -91,6 +89,7 @@ export default function SporTotoContent() {
bulletinId: selectedBulletin, bulletinId: selectedBulletin,
strategy, strategy,
}); });
queryClient.invalidateQueries({ queryKey: UsersQueryKeys.me() });
toast({ toast({
title: t("prediction-generated"), title: t("prediction-generated"),
status: "success", status: "success",
+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>
);
}
+175 -87
View File
@@ -15,53 +15,69 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, FadeIn } from "@/components/motion"; import { SlideUp, FadeIn } from "@/components/motion";
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks"; import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
import { LuArrowLeft, LuCalendar, LuTrophy, LuChevronDown } from "react-icons/lu"; import {
LuArrowLeft,
LuCalendar,
LuTrophy,
LuChevronDown,
} from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types"; import type { MatchResponseDto } from "@/lib/api/matches/types";
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { LoginModal } from "@/components/auth/login-modal";
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// Utility Functions // Utility Functions
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
function getMatchTimestamp(match: MatchResponseDto): number { function getMatchTimestamp(match: MatchResponseDto): number {
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc; const raw =
typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
return Number.isFinite(raw) ? raw : 0; return Number.isFinite(raw) ? raw : 0;
} }
function getMatchStatus(match: MatchResponseDto): string { function getMatchStatus(match: MatchResponseDto): string {
return String(match.status || (match as Record<string, unknown>).state || "").toUpperCase(); return String(match.status || match.state || "").toUpperCase();
} }
function isMatchFinished(match: MatchResponseDto): boolean { function isMatchFinished(match: MatchResponseDto): boolean {
const status = getMatchStatus(match); const status = getMatchStatus(match);
return status === "FT" || status === "FINISHED" || status === "POSTGAME" || status === "POST_GAME"; return (
status === "FT" ||
status === "FINISHED" ||
status === "POSTGAME" ||
status === "POST_GAME"
);
} }
function isMatchLive(match: MatchResponseDto): boolean { function isMatchLive(match: MatchResponseDto): boolean {
const status = getMatchStatus(match); const status = getMatchStatus(match);
return status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS"; return (
status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS"
);
} }
function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string { function getTeamSideName(
team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"],
fallback?: unknown,
): string {
return String(team?.name || fallback || ""); return String(team?.name || fallback || "");
} }
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string { function getTeamSideLogo(
return String(team?.logo || (team as Record<string, unknown> | undefined)?.logoUrl || fallback || ""); team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"],
fallback?: unknown,
): string {
return String(team?.logo || fallback || "");
} }
function getLeagueLabel(match: MatchResponseDto): string { function getLeagueLabel(match: MatchResponseDto): string {
return String(match.leagueName || match.league?.name || ""); return String(match.leagueName || match.league?.name || "");
} }
/**
* Football season logic: AugJun
* If month >= August (8) → season starts this year: "YYYY-(YYYY+1)"
* If month < August → season started last year: "(YYYY-1)-YYYY"
*/
function getSeasonFromTimestamp(timestampMs: number): string { function getSeasonFromTimestamp(timestampMs: number): string {
const date = new Date(timestampMs); const date = new Date(timestampMs);
const year = date.getFullYear(); const year = date.getFullYear();
@@ -73,28 +89,15 @@ function getSeasonFromTimestamp(timestampMs: number): string {
return `${year - 1}-${year}`; return `${year - 1}-${year}`;
} }
/** const SEASONS = (() => {
* Group matches by season string, returning a Map ordered by newest season first. const currentYear = new Date().getFullYear();
*/ const currentMonth = new Date().getMonth() + 1;
function groupMatchesBySeason(matches: MatchResponseDto[]): Map<string, MatchResponseDto[]> { const startYear = currentMonth >= 8 ? currentYear : currentYear - 1;
const groups = new Map<string, MatchResponseDto[]>(); return Array.from(
{ length: 5 },
for (const match of matches) { (_, i) => `${startYear - i}-${startYear - i + 1}`,
const ts = getMatchTimestamp(match);
const season = ts ? getSeasonFromTimestamp(ts) : "Bilinmiyor";
if (!groups.has(season)) {
groups.set(season, []);
}
groups.get(season)!.push(match);
}
// Sort by season key descending (newest first)
const sorted = new Map(
[...groups.entries()].sort((a, b) => b[0].localeCompare(a[0]))
); );
})();
return sorted;
}
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// Main Component // Main Component
@@ -104,62 +107,70 @@ export default function TeamDetailContent() {
const t = useTranslations(); const t = useTranslations();
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const [loginModalOpen, setLoginModalOpen] = useState(false);
const teamId = params.id as string; const teamId = params.id as string;
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [activeSeason, setActiveSeason] = useState<string>(SEASONS[0]);
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId); const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
const { const {
data: matchesResponse, data: matchesResponse,
isLoading: matchesLoading, isLoading: matchesLoading,
isFetching: matchesFetching, isFetching: matchesFetching,
} = useTeamMatches(teamId, { page: currentPage, limit: 20 }); } = useTeamMatches(teamId, {
page: currentPage,
limit: 20,
season: activeSeason,
});
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700"); const borderColor = useColorModeValue("gray.100", "gray.700");
const seasonActiveBg = useColorModeValue("primary.500", "primary.400"); const seasonActiveBg = useColorModeValue("primary.500", "primary.400");
const seasonInactiveBg = useColorModeValue("gray.100", "gray.700"); const seasonInactiveBg = useColorModeValue("gray.100", "gray.700");
const team = (teamData as Record<string, unknown> | undefined)?.data as Record<string, unknown> | undefined; // Backend ResponseInterceptor wraps all responses in { success, status, message, data }
const paginationData = matchesResponse; const teamWrapper = teamData as Record<string, unknown> | undefined;
const matches: MatchResponseDto[] = paginationData?.data ?? []; const team = teamWrapper?.data as Record<string, unknown> | undefined;
const totalPages = paginationData?.totalPages ?? 1;
const totalMatches = paginationData?.total ?? 0; // 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 // Separate past and upcoming matches
const pastMatches = useMemo( const pastMatches = useMemo(
() => matches.filter((m) => isMatchFinished(m)), () => matches.filter((m) => isMatchFinished(m)),
[matches] [matches],
); );
const upcomingMatches = useMemo( const upcomingMatches = useMemo(
() => matches.filter((m) => !isMatchFinished(m)), () => matches.filter((m) => !isMatchFinished(m)),
[matches] [matches],
); );
// Group past matches by season
const seasonGroups = useMemo(
() => groupMatchesBySeason(pastMatches),
[pastMatches]
);
const seasonKeys = useMemo(() => [...seasonGroups.keys()], [seasonGroups]);
// Active season selection
const [activeSeason, setActiveSeason] = useState<string | null>(null);
const displaySeason = activeSeason ?? seasonKeys[0] ?? null;
const displayMatches = displaySeason ? seasonGroups.get(displaySeason) ?? [] : [];
// Pagination handlers // Pagination handlers
const handleNextPage = useCallback(() => { const handleNextPage = useCallback(() => {
if (currentPage < totalPages) { if (currentPage < totalPages) {
setCurrentPage((p) => p + 1); setCurrentPage((p) => p + 1);
setActiveSeason(null); // Reset season on page change
} }
}, [currentPage, totalPages]); }, [currentPage, totalPages]);
const handlePrevPage = useCallback(() => { const handlePrevPage = useCallback(() => {
if (currentPage > 1) { if (currentPage > 1) {
setCurrentPage((p) => p - 1); setCurrentPage((p) => p - 1);
setActiveSeason(null);
} }
}, [currentPage]); }, [currentPage]);
@@ -194,7 +205,9 @@ export default function TeamDetailContent() {
if (!team) { if (!team) {
return ( return (
<Flex justify="center" py={20} direction="column" align="center" gap={4}> <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()}> <Button variant="outline" onClick={() => router.back()}>
<LuArrowLeft /> Geri <LuArrowLeft /> Geri
</Button> </Button>
@@ -206,13 +219,24 @@ export default function TeamDetailContent() {
<SlideUp> <SlideUp>
<Box> <Box>
{/* Back Button */} {/* Back Button */}
<Button variant="ghost" size="sm" mb={4} onClick={() => router.back()} gap={1.5}> <Button
variant="ghost"
size="sm"
mb={4}
onClick={() => router.back()}
gap={1.5}
>
<LuArrowLeft /> <LuArrowLeft />
Geri Geri
</Button> </Button>
{/* Team Header */} {/* Team Header */}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}> <Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Body> <Card.Body>
<HStack gap={6} justify="center" align="center"> <HStack gap={6} justify="center" align="center">
{(team as Record<string, unknown>).logo ? ( {(team as Record<string, unknown>).logo ? (
@@ -231,7 +255,9 @@ export default function TeamDetailContent() {
justify="center" justify="center"
> >
<Text fontSize="3xl" fontWeight="bold" color="primary.fg"> <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> </Text>
</Flex> </Flex>
)} )}
@@ -274,7 +300,11 @@ export default function TeamDetailContent() {
cardBg={cardBg} cardBg={cardBg}
borderColor={borderColor} borderColor={borderColor}
statusBadge={getStatusBadge(match)} statusBadge={getStatusBadge(match)}
onClick={() => router.push(`/tr/matches/${match.id}`)} onClick={() =>
session
? router.push(`/matches/${match.id}`)
: setLoginModalOpen(true)
}
/> />
))} ))}
</VStack> </VStack>
@@ -285,7 +315,13 @@ export default function TeamDetailContent() {
{/* Past Matches — Season Grouped */} {/* Past Matches — Season Grouped */}
<FadeIn> <FadeIn>
<Box> <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"> <Heading as="h2" size="lg">
📊 Geçmiş Maçlar 📊 Geçmiş Maçlar
</Heading> </Heading>
@@ -296,11 +332,10 @@ export default function TeamDetailContent() {
</Flex> </Flex>
{/* Season Tabs */} {/* Season Tabs */}
{seasonKeys.length > 0 && ( {SEASONS.length > 0 && (
<HStack gap={2} mb={4} flexWrap="wrap"> <HStack gap={2} mb={4} flexWrap="wrap">
{seasonKeys.map((season) => { {SEASONS.map((season) => {
const isActive = season === displaySeason; const isActive = season === activeSeason;
const count = seasonGroups.get(season)?.length ?? 0;
return ( return (
<Button <Button
key={season} key={season}
@@ -312,14 +347,17 @@ export default function TeamDetailContent() {
fontWeight={isActive ? "700" : "500"} fontWeight={isActive ? "700" : "500"}
fontSize="xs" fontSize="xs"
px={4} px={4}
onClick={() => setActiveSeason(season)} onClick={() => {
setActiveSeason(season);
setCurrentPage(1);
}}
_hover={{ _hover={{
transform: "translateY(-1px)", transform: "translateY(-1px)",
shadow: "sm", shadow: "sm",
}} }}
transition="all 0.2s" transition="all 0.2s"
> >
🏆 {season} ({count}) 🏆 {season}
</Button> </Button>
); );
})} })}
@@ -330,24 +368,24 @@ export default function TeamDetailContent() {
<Flex justify="center" py={8}> <Flex justify="center" py={8}>
<Spinner size="md" color="primary.500" /> <Spinner size="md" color="primary.500" />
</Flex> </Flex>
) : displayMatches.length === 0 && pastMatches.length === 0 ? ( ) : pastMatches.length === 0 ? (
<Text color="fg.muted" textAlign="center" py={8}> <Text color="fg.muted" textAlign="center" py={8}>
Bu sayfada geçmiş maç bulunamadı Bu sezonda geçmiş maç bulunamadı
</Text>
) : displayMatches.length === 0 ? (
<Text color="fg.muted" textAlign="center" py={8}>
Bu sezonda maç bulunamadı
</Text> </Text>
) : ( ) : (
<VStack gap={2} align="stretch"> <VStack gap={2} align="stretch">
{displayMatches.map((match: MatchResponseDto) => ( {pastMatches.map((match: MatchResponseDto) => (
<MatchRow <MatchRow
key={match.id} key={match.id}
match={match} match={match}
cardBg={cardBg} cardBg={cardBg}
borderColor={borderColor} borderColor={borderColor}
statusBadge={getStatusBadge(match)} statusBadge={getStatusBadge(match)}
onClick={() => router.push(`/tr/matches/${match.id}`)} onClick={() =>
session
? router.push(`/matches/${match.id}`)
: setLoginModalOpen(true)
}
/> />
))} ))}
</VStack> </VStack>
@@ -384,13 +422,14 @@ export default function TeamDetailContent() {
key={pageNum} key={pageNum}
size="sm" size="sm"
variant={pageNum === currentPage ? "solid" : "ghost"} variant={pageNum === currentPage ? "solid" : "ghost"}
bg={pageNum === currentPage ? seasonActiveBg : undefined} bg={
pageNum === currentPage ? seasonActiveBg : undefined
}
color={pageNum === currentPage ? "white" : undefined} color={pageNum === currentPage ? "white" : undefined}
borderRadius="full" borderRadius="full"
minW="36px" minW="36px"
onClick={() => { onClick={() => {
setCurrentPage(pageNum); setCurrentPage(pageNum);
setActiveSeason(null);
}} }}
> >
{pageNum} {pageNum}
@@ -411,6 +450,13 @@ export default function TeamDetailContent() {
)} )}
</Box> </Box>
</FadeIn> </FadeIn>
{/* Login Modal — shown when unauthenticated user clicks a match */}
<LoginModal
open={loginModalOpen}
onOpenChange={setLoginModalOpen}
initialMode="login"
/>
</Box> </Box>
</SlideUp> </SlideUp>
); );
@@ -428,7 +474,13 @@ interface MatchRowProps {
onClick: () => void; onClick: () => void;
} }
function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRowProps) { function MatchRow({
match,
cardBg,
borderColor,
statusBadge,
onClick,
}: MatchRowProps) {
const hoverBg = useColorModeValue("gray.50", "gray.700"); const hoverBg = useColorModeValue("gray.50", "gray.700");
const matchTimestamp = getMatchTimestamp(match); const matchTimestamp = getMatchTimestamp(match);
const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName); const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName);
@@ -457,17 +509,34 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
{homeTeamName} {homeTeamName}
</Text> </Text>
{homeTeamLogo ? ( {homeTeamLogo ? (
<Image src={homeTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} /> <Image
src={homeTeamLogo}
alt=""
boxSize="24px"
objectFit="contain"
flexShrink={0}
/>
) : ( ) : (
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}> <Flex
<Text fontSize="xs" fontWeight="bold">{homeTeamName?.charAt(0)}</Text> boxSize="24px"
bg="primary.subtle"
borderRadius="full"
align="center"
justify="center"
flexShrink={0}
>
<Text fontSize="xs" fontWeight="bold">
{homeTeamName?.charAt(0)}
</Text>
</Flex> </Flex>
)} )}
</HStack> </HStack>
{/* Score / VS */} {/* Score / VS */}
<VStack gap={0} flexShrink={0} minW="60px"> <VStack gap={0} flexShrink={0} minW="60px">
{hasScore && match.scoreHome !== undefined && match.scoreHome !== null ? ( {hasScore &&
match.scoreHome !== undefined &&
match.scoreHome !== null ? (
<Text fontSize="md" fontWeight="900"> <Text fontSize="md" fontWeight="900">
{match.scoreHome} - {match.scoreAway} {match.scoreHome} - {match.scoreAway}
</Text> </Text>
@@ -489,10 +558,25 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
{/* Away Team */} {/* Away Team */}
<HStack gap={2} flex={1}> <HStack gap={2} flex={1}>
{awayTeamLogo ? ( {awayTeamLogo ? (
<Image src={awayTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} /> <Image
src={awayTeamLogo}
alt=""
boxSize="24px"
objectFit="contain"
flexShrink={0}
/>
) : ( ) : (
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}> <Flex
<Text fontSize="xs" fontWeight="bold">{awayTeamName?.charAt(0)}</Text> boxSize="24px"
bg="primary.subtle"
borderRadius="full"
align="center"
justify="center"
flexShrink={0}
>
<Text fontSize="xs" fontWeight="bold">
{awayTeamName?.charAt(0)}
</Text>
</Flex> </Flex>
)} )}
<Text fontSize="sm" fontWeight="600" truncate> <Text fontSize="sm" fontWeight="600" truncate>
@@ -504,7 +588,11 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
{/* Status + League */} {/* Status + League */}
<HStack gap={2} flexShrink={0} ml={3}> <HStack gap={2} flexShrink={0} ml={3}>
{leagueLabel && ( {leagueLabel && (
<Text fontSize="2xs" color="fg.muted" display={{ base: "none", md: "block" }}> <Text
fontSize="2xs"
color="fg.muted"
display={{ base: "none", md: "block" }}
>
{leagueLabel} {leagueLabel}
</Text> </Text>
)} )}
+12 -2
View File
@@ -69,7 +69,13 @@ export default function TeamsContent() {
<Spinner size="lg" color="primary.500" /> <Spinner size="lg" color="primary.500" />
</Flex> </Flex>
) : query.length < 2 ? ( ) : query.length < 2 ? (
<Flex justify="center" py={16} direction="column" align="center" gap={3}> <Flex
justify="center"
py={16}
direction="column"
align="center"
gap={3}
>
<Text fontSize="5xl"></Text> <Text fontSize="5xl"></Text>
<Text color="fg.muted" fontSize="lg"> <Text color="fg.muted" fontSize="lg">
Aramak istediğiniz takımın adını yazın Aramak istediğiniz takımın adını yazın
@@ -117,7 +123,11 @@ export default function TeamsContent() {
align="center" align="center"
justify="center" justify="center"
> >
<Text fontSize="xl" fontWeight="bold" color="primary.fg"> <Text
fontSize="xl"
fontWeight="bold"
color="primary.fg"
>
{team.name?.charAt(0) || "T"} {team.name?.charAt(0) || "T"}
</Text> </Text>
</Flex> </Flex>
+18 -18
View File
@@ -1,8 +1,8 @@
'use client'; "use client";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { Icon, IconButton, Presence } from '@chakra-ui/react'; import { Icon, IconButton, Presence } from "@chakra-ui/react";
import { FiChevronUp } from 'react-icons/fi'; import { FiChevronUp } from "react-icons/fi";
const BackToTop = () => { const BackToTop = () => {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -12,14 +12,14 @@ const BackToTop = () => {
setIsVisible(window.pageYOffset > 300); setIsVisible(window.pageYOffset > 300);
}; };
window.addEventListener('scroll', handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
const scrollToTop = () => { const scrollToTop = () => {
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: 'smooth', behavior: "smooth",
}); });
}; };
@@ -27,19 +27,19 @@ const BackToTop = () => {
<Presence <Presence
unmountOnExit unmountOnExit
present={isVisible} present={isVisible}
animationName={{ _open: 'fade-in', _closed: 'fade-out' }} animationName={{ _open: "fade-in", _closed: "fade-out" }}
animationDuration='moderate' animationDuration="moderate"
> >
<IconButton <IconButton
variant={{ base: 'solid', _dark: 'subtle' }} variant={{ base: "solid", _dark: "subtle" }}
aria-label='Back to top' aria-label="Back to top"
position='fixed' position="fixed"
bottom='8' bottom="8"
right='8' right="8"
borderRadius='full' borderRadius="full"
size='lg' size="lg"
shadow='lg' shadow="lg"
zIndex='999' zIndex="999"
onClick={scrollToTop} onClick={scrollToTop}
> >
<Icon> <Icon>
+15 -8
View File
@@ -1,6 +1,11 @@
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react'; import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react";
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react'; import {
import * as React from 'react'; AbsoluteCenter,
Button as ChakraButton,
Span,
Spinner,
} from "@chakra-ui/react";
import * as React from "react";
interface ButtonLoadingProps { interface ButtonLoadingProps {
loading?: boolean; loading?: boolean;
@@ -9,20 +14,21 @@ interface ButtonLoadingProps {
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {} export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) { export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props; const { loading, disabled, loadingText, children, ...rest } = props;
return ( return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}> <ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? ( {loading && !loadingText ? (
<> <>
<AbsoluteCenter display='inline-flex'> <AbsoluteCenter display="inline-flex">
<Spinner size='inherit' color='inherit' /> <Spinner size="inherit" color="inherit" />
</AbsoluteCenter> </AbsoluteCenter>
<Span opacity={0}>{children}</Span> <Span opacity={0}>{children}</Span>
</> </>
) : loading && loadingText ? ( ) : loading && loadingText ? (
<> <>
<Spinner size='inherit' color='inherit' /> <Spinner size="inherit" color="inherit" />
{loadingText} {loadingText}
</> </>
) : ( ) : (
@@ -30,4 +36,5 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
)} )}
</ChakraButton> </ChakraButton>
); );
}); },
);
+9 -6
View File
@@ -1,13 +1,16 @@
import type { ButtonProps } from '@chakra-ui/react'; import type { ButtonProps } from "@chakra-ui/react";
import { IconButton as ChakraIconButton } from '@chakra-ui/react'; import { IconButton as ChakraIconButton } from "@chakra-ui/react";
import * as React from 'react'; import * as React from "react";
import { LuX } from 'react-icons/lu'; import { LuX } from "react-icons/lu";
export type CloseButtonProps = ButtonProps; export type CloseButtonProps = ButtonProps;
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) { export const CloseButton = React.forwardRef<
HTMLButtonElement,
CloseButtonProps
>(function CloseButton(props, ref) {
return ( return (
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}> <ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
{props.children ?? <LuX />} {props.children ?? <LuX />}
</ChakraIconButton> </ChakraIconButton>
); );
+9 -6
View File
@@ -1,11 +1,14 @@
'use client'; "use client";
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react'; import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react";
import { createRecipeContext } from '@chakra-ui/react'; import { createRecipeContext } from "@chakra-ui/react";
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {} export interface LinkButtonProps extends HTMLChakraProps<
"a",
RecipeProps<"button">
> {}
const { withContext } = createRecipeContext({ key: 'button' }); const { withContext } = createRecipeContext({ key: "button" });
// Replace "a" with your framework's link component // Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a'); export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a");
+35 -22
View File
@@ -1,23 +1,28 @@
'use client'; "use client";
import type { ButtonProps } from '@chakra-ui/react'; import type { ButtonProps } from "@chakra-ui/react";
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react'; import {
import * as React from 'react'; Button,
Toggle as ChakraToggle,
useToggleContext,
} from "@chakra-ui/react";
import * as React from "react";
interface ToggleProps extends ChakraToggle.RootProps { interface ToggleProps extends ChakraToggle.RootProps {
variant?: keyof typeof variantMap; variant?: keyof typeof variantMap;
size?: ButtonProps['size']; size?: ButtonProps["size"];
} }
const variantMap = { const variantMap = {
solid: { on: 'solid', off: 'outline' }, solid: { on: "solid", off: "outline" },
surface: { on: 'surface', off: 'outline' }, surface: { on: "surface", off: "outline" },
subtle: { on: 'subtle', off: 'ghost' }, subtle: { on: "subtle", off: "ghost" },
ghost: { on: 'subtle', off: 'ghost' }, ghost: { on: "subtle", off: "ghost" },
} as const; } as const;
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) { export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
const { variant = 'subtle', size, children, ...rest } = props; function Toggle(props, ref) {
const { variant = "subtle", size, children, ...rest } = props;
const variantConfig = variantMap[variant]; const variantConfig = variantMap[variant];
return ( return (
@@ -27,18 +32,26 @@ export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function
</ToggleBaseButton> </ToggleBaseButton>
</ChakraToggle.Root> </ChakraToggle.Root>
); );
});
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
variant: Record<'on' | 'off', ButtonProps['variant']>;
}
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
function ToggleBaseButton(props, ref) {
const toggle = useToggleContext();
const { variant, ...rest } = props;
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
}, },
); );
interface ToggleBaseButtonProps extends Omit<ButtonProps, "variant"> {
variant: Record<"on" | "off", ButtonProps["variant"]>;
}
const ToggleBaseButton = React.forwardRef<
HTMLButtonElement,
ToggleBaseButtonProps
>(function ToggleBaseButton(props, ref) {
const toggle = useToggleContext();
const { variant, ...rest } = props;
return (
<Button
variant={toggle.pressed ? variant.on : variant.off}
ref={ref}
{...rest}
/>
);
});
export const ToggleIndicator = ChakraToggle.Indicator; export const ToggleIndicator = ChakraToggle.Indicator;
+48 -30
View File
@@ -1,15 +1,17 @@
'use client'; "use client";
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react'; import { Combobox as ChakraCombobox, Portal } from "@chakra-ui/react";
import { CloseButton } from '@/components/ui/buttons/close-button'; import { CloseButton } from "@/components/ui/buttons/close-button";
import * as React from 'react'; import * as React from "react";
interface ComboboxControlProps extends ChakraCombobox.ControlProps { interface ComboboxControlProps extends ChakraCombobox.ControlProps {
clearable?: boolean; clearable?: boolean;
} }
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>( export const ComboboxControl = React.forwardRef<
function ComboboxControl(props, ref) { HTMLDivElement,
ComboboxControlProps
>(function ComboboxControl(props, ref) {
const { children, clearable, ...rest } = props; const { children, clearable, ...rest } = props;
return ( return (
<ChakraCombobox.Control {...rest} ref={ref}> <ChakraCombobox.Control {...rest} ref={ref}>
@@ -20,26 +22,34 @@ export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlP
</ChakraCombobox.IndicatorGroup> </ChakraCombobox.IndicatorGroup>
</ChakraCombobox.Control> </ChakraCombobox.Control>
); );
}, });
);
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>( const ComboboxClearTrigger = React.forwardRef<
function ComboboxClearTrigger(props, ref) { HTMLButtonElement,
ChakraCombobox.ClearTriggerProps
>(function ComboboxClearTrigger(props, ref) {
return ( return (
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}> <ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' /> <CloseButton
size="xs"
variant="plain"
focusVisibleRing="inside"
focusRingWidth="2px"
pointerEvents="auto"
/>
</ChakraCombobox.ClearTrigger> </ChakraCombobox.ClearTrigger>
); );
}, });
);
interface ComboboxContentProps extends ChakraCombobox.ContentProps { interface ComboboxContentProps extends ChakraCombobox.ContentProps {
portalled?: boolean; portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>; portalRef?: React.RefObject<HTMLElement | null>;
} }
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>( export const ComboboxContent = React.forwardRef<
function ComboboxContent(props, ref) { HTMLDivElement,
ComboboxContentProps
>(function ComboboxContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props; const { portalled = true, portalRef, ...rest } = props;
return ( return (
<Portal disabled={!portalled} container={portalRef}> <Portal disabled={!portalled} container={portalRef}>
@@ -48,11 +58,12 @@ export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentP
</ChakraCombobox.Positioner> </ChakraCombobox.Positioner>
</Portal> </Portal>
); );
}, });
);
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>( export const ComboboxItem = React.forwardRef<
function ComboboxItem(props, ref) { HTMLDivElement,
ChakraCombobox.ItemProps
>(function ComboboxItem(props, ref) {
const { item, children, ...rest } = props; const { item, children, ...rest } = props;
return ( return (
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}> <ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
@@ -60,21 +71,29 @@ export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.Item
<ChakraCombobox.ItemIndicator /> <ChakraCombobox.ItemIndicator />
</ChakraCombobox.Item> </ChakraCombobox.Item>
); );
}, });
);
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>( export const ComboboxRoot = React.forwardRef<
function ComboboxRoot(props, ref) { HTMLDivElement,
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />; ChakraCombobox.RootProps
}, >(function ComboboxRoot(props, ref) {
) as ChakraCombobox.RootComponent; return (
<ChakraCombobox.Root
{...props}
ref={ref}
positioning={{ sameWidth: true, ...props.positioning }}
/>
);
}) as ChakraCombobox.RootComponent;
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps { interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
label: React.ReactNode; label: React.ReactNode;
} }
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>( export const ComboboxItemGroup = React.forwardRef<
function ComboboxItemGroup(props, ref) { HTMLDivElement,
ComboboxItemGroupProps
>(function ComboboxItemGroup(props, ref) {
const { children, label, ...rest } = props; const { children, label, ...rest } = props;
return ( return (
<ChakraCombobox.ItemGroup {...rest} ref={ref}> <ChakraCombobox.ItemGroup {...rest} ref={ref}>
@@ -82,8 +101,7 @@ export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGr
{children} {children}
</ChakraCombobox.ItemGroup> </ChakraCombobox.ItemGroup>
); );
}, });
);
export const ComboboxLabel = ChakraCombobox.Label; export const ComboboxLabel = ChakraCombobox.Label;
export const ComboboxInput = ChakraCombobox.Input; export const ComboboxInput = ChakraCombobox.Input;

Some files were not shown because too many files have changed in this diff Show More