Initial commit: Trading Portal - FastAPI + React + PostgreSQL
This commit is contained in:
Executable
+6
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.local
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
# Trading Portal — Frontend Dockerfile
|
||||
# Multi-stage: Vite build → Nginx serve
|
||||
|
||||
# ---- Build stage ----
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Serve stage ----
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Fix permissions so nginx can read all files
|
||||
RUN chmod -R 644 /usr/share/nginx/html/*.json /usr/share/nginx/html/*.js /usr/share/nginx/html/*.html /usr/share/nginx/html/*.svg && \
|
||||
chmod -R 644 /usr/share/nginx/html/assets/* && \
|
||||
find /usr/share/nginx/html -type d -exec chmod 755 {} \;
|
||||
|
||||
# Copy nginx config (full http wrapper with rate limiting)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -sf http://localhost:80/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>Trading Portal</title>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_req_status 429;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 10m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|json|webmanifest)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
|
||||
location /api/v1/backtest/ {
|
||||
limit_req zone=api burst=5 nodelay;
|
||||
proxy_pass http://trading-backend-api:8001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://trading-backend-api:8001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://trading-backend-api:8001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://trading-backend-api:8001/health;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6480
File diff suppressed because it is too large
Load Diff
Executable
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"oxlint": "^1.69.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
}
|
||||
Executable
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Executable
+24
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Executable
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Trading Portal",
|
||||
"short_name": "Trading",
|
||||
"description": "Trading Portal - Real-time trading signals and charts",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
// Trading Portal - Minimal Service Worker
|
||||
const CACHE_NAME = 'trading-portal-v1';
|
||||
|
||||
// Assets to cache on install
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/favicon.svg',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// Install: pre-cache key assets
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(PRECACHE_URLS).catch(() => {
|
||||
// Non-critical; proceed even if some assets fail
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
return self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch: network-first, fallback to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only handle GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip non-http(s) requests (chrome-extension, etc.)
|
||||
if (!event.request.url.startsWith('http')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Cache successful responses
|
||||
if (response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline fallback
|
||||
return caches.match(event.request).then((cached) => {
|
||||
return cached || new Response('Offline', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './app/store';
|
||||
import LoginPage from './features/auth/LoginPage';
|
||||
// import RegisterPage from './features/auth/RegisterPage'; // Temporarily hidden
|
||||
import DashboardPage from './features/dashboard/DashboardPage';
|
||||
import AdminPage from './features/admin/AdminPage';
|
||||
import ProfilePage from './features/profile/ProfilePage';
|
||||
import BacktestPage from './features/backtest/BacktestPage';
|
||||
import AnalyticsPage from './features/analytics/AnalyticsPage';
|
||||
import AlertsPage from './features/alerts/AlertsPage';
|
||||
import AuditLogPage from './pages/AuditLogPage';
|
||||
import { TProvider } from './translations';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
const [lang, setLangState] = useState<'en' | 'vi'>(() => {
|
||||
return (localStorage.getItem('lang') as 'en' | 'vi') || 'vi';
|
||||
});
|
||||
const setLang = useCallback((l: 'en' | 'vi') => {
|
||||
setLangState(l);
|
||||
localStorage.setItem('lang', l);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<TProvider lang={lang} setLang={setLang}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* <Route path="/register" element={<RegisterPage />} /> */}
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/backtest" element={<BacktestPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/alerts" element={<AlertsPage />} />
|
||||
<Route path="/audit" element={<AuditLogPage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './store';
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from '../features/auth/authSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Executable
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
Executable
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,55 @@
|
||||
// P3-2: Error boundary for React app
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div style={{
|
||||
padding: 40, textAlign: 'center', color: '#e74c3c',
|
||||
background: '#1a1a2e', minHeight: '100vh',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 12 }}>⚠️ Something went wrong</h1>
|
||||
<p style={{ color: '#aaa', fontSize: 14, maxWidth: 500 }}>
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { this.setState({ hasError: false, error: null }); window.location.href = '/'; }}
|
||||
style={{
|
||||
marginTop: 20, padding: '10px 24px', borderRadius: 6, border: 'none',
|
||||
background: '#3498db', color: '#fff', cursor: 'pointer', fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Reload App
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// P3-8: Loading skeleton component
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
borderRadius?: number;
|
||||
count?: number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width = '100%',
|
||||
height = 20,
|
||||
borderRadius = 4,
|
||||
count = 1,
|
||||
style = {},
|
||||
}) => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
background: 'linear-gradient(90deg, #2a2a3e 25%, #3a3a4e 50%, #2a2a3e 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'skeleton-shimmer 1.5s ease-in-out infinite',
|
||||
marginBottom: count > 1 ? 8 : 0,
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined' && !document.getElementById('skeleton-keyframes')) {
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'skeleton-keyframes';
|
||||
styleEl.textContent = `
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} style={{ ...baseStyle, ...style }} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DashboardSkeletonProps {
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export const DashboardSkeleton: React.FC<DashboardSkeletonProps> = ({ lines = 5 }) => (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Skeleton height={32} width="60%" style={{ marginBottom: 20 }} />
|
||||
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton key={i} height={16} width={`${70 + Math.random() * 30}%`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
Executable
+822
@@ -0,0 +1,822 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { fetchCurrentUser, logoutThunk } from '../auth/authSlice';
|
||||
import {
|
||||
adminGetUsers,
|
||||
adminUpdateUser,
|
||||
adminCreateUser,
|
||||
adminDeleteUser,
|
||||
adminResetPassword,
|
||||
adminGetExchanges,
|
||||
adminCreateExchange,
|
||||
adminUpdateExchange,
|
||||
adminGetHealth,
|
||||
} from '../api/apiService';
|
||||
import type { User, Exchange } from '../../types/trading';
|
||||
|
||||
interface HealthData {
|
||||
status: string;
|
||||
version: string;
|
||||
uptime: number;
|
||||
db: { connected: boolean; latency_ms: number; pool_size: number };
|
||||
exchange_connections: { exchange: string; connected: boolean }[];
|
||||
}
|
||||
|
||||
// ── Modal helper ──
|
||||
interface ModalState {
|
||||
show: boolean;
|
||||
type: 'add' | 'edit' | 'reset' | 'delete';
|
||||
targetUser: User | null;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'exchanges' | 'health'>('users');
|
||||
|
||||
// Users
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(true);
|
||||
const [usersError, setUsersError] = useState('');
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
|
||||
// Modal
|
||||
const [modal, setModal] = useState<ModalState>({ show: false, type: 'add', targetUser: null });
|
||||
const [formData, setFormData] = useState({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
// Exchanges
|
||||
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||
const [exLoading, setExLoading] = useState(true);
|
||||
const [showAddEx, setShowAddEx] = useState(false);
|
||||
const [newEx, setNewEx] = useState({ name: '', display_name: '', base_url: '', ws_url: '' });
|
||||
|
||||
// Health
|
||||
const [health, setHealth] = useState<HealthData | null>(null);
|
||||
const [healthLoading, setHealthLoading] = useState(true);
|
||||
|
||||
// ── Auth guard ──
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
dispatch(fetchCurrentUser())
|
||||
.unwrap()
|
||||
.catch(() => navigate('/login', { replace: true }))
|
||||
.finally(() => setInitializing(false));
|
||||
} else {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, [isAuthenticated, user, dispatch, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && user && !user.is_admin) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}, [initializing, user, navigate]);
|
||||
|
||||
// ── Data loaders ──
|
||||
useEffect(() => {
|
||||
if (user?.is_admin && activeTab === 'users') {
|
||||
setUsersLoading(true);
|
||||
setUsersError('');
|
||||
adminGetUsers()
|
||||
.then(setUsers)
|
||||
.catch((err) => setUsersError(err.message || 'Failed to load users'))
|
||||
.finally(() => setUsersLoading(false));
|
||||
}
|
||||
}, [activeTab, user?.is_admin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_admin && activeTab === 'exchanges') {
|
||||
setExLoading(true);
|
||||
adminGetExchanges()
|
||||
.then(setExchanges)
|
||||
.catch(() => {})
|
||||
.finally(() => setExLoading(false));
|
||||
}
|
||||
}, [activeTab, user?.is_admin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_admin && activeTab === 'health') {
|
||||
setHealthLoading(true);
|
||||
adminGetHealth()
|
||||
.then(setHealth)
|
||||
.catch(() => {})
|
||||
.finally(() => setHealthLoading(false));
|
||||
}
|
||||
}, [activeTab, user?.is_admin]);
|
||||
|
||||
// ── User actions ──
|
||||
const toggleUserField = useCallback(async (u: User, field: 'is_active' | 'is_admin') => {
|
||||
try {
|
||||
const payload: any = { [field]: !u[field] };
|
||||
if (field === 'is_admin') {
|
||||
payload.role = !u[field] ? 'admin' : 'trader';
|
||||
}
|
||||
const updated = await adminUpdateUser(String(u.id), payload);
|
||||
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, ...updated } : x)));
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Update failed');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openModal = useCallback((type: ModalState['type'], targetUser?: User) => {
|
||||
setFormError('');
|
||||
if (type === 'add') {
|
||||
setFormData({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
|
||||
setModal({ show: true, type, targetUser: null });
|
||||
} else if (type === 'edit' && targetUser) {
|
||||
setFormData({
|
||||
username: targetUser.username,
|
||||
email: targetUser.email,
|
||||
password: '',
|
||||
display_name: targetUser.display_name || '',
|
||||
is_admin: targetUser.is_admin || false,
|
||||
role: targetUser.role || (targetUser.is_admin ? 'admin' : 'trader'),
|
||||
});
|
||||
setModal({ show: true, type, targetUser });
|
||||
} else if (type === 'reset' && targetUser) {
|
||||
setFormData({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
|
||||
setModal({ show: true, type, targetUser });
|
||||
} else if (type === 'delete' && targetUser) {
|
||||
setFormData({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
|
||||
setModal({ show: true, type, targetUser });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModal({ show: false, type: 'add', targetUser: null });
|
||||
setFormError('');
|
||||
}, []);
|
||||
|
||||
const handleAddUser = useCallback(async () => {
|
||||
setFormError('');
|
||||
// Client-side validation
|
||||
if (!formData.username.trim()) {
|
||||
setFormError('Username is required');
|
||||
return;
|
||||
}
|
||||
const emailRe = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
if (!emailRe.test(formData.email)) {
|
||||
setFormError('Invalid email format');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 8) {
|
||||
setFormError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
if (!/\d/.test(formData.password)) {
|
||||
setFormError('Password must contain at least 1 number');
|
||||
return;
|
||||
}
|
||||
if (!/[^a-zA-Z0-9]/.test(formData.password)) {
|
||||
setFormError('Password must contain at least 1 special character');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await adminCreateUser({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
display_name: formData.display_name || undefined,
|
||||
is_admin: formData.role === 'admin',
|
||||
role: formData.role,
|
||||
});
|
||||
setUsers((prev) => [...prev, created]);
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to create user');
|
||||
}
|
||||
}, [formData, closeModal]);
|
||||
|
||||
const handleEditUser = useCallback(async () => {
|
||||
if (!modal.targetUser) return;
|
||||
setFormError('');
|
||||
try {
|
||||
const updates: any = {};
|
||||
if (formData.email !== modal.targetUser.email) updates.email = formData.email;
|
||||
if (formData.display_name !== (modal.targetUser.display_name || '')) updates.display_name = formData.display_name || null;
|
||||
const newRole = formData.role;
|
||||
if (newRole !== (modal.targetUser.role || (modal.targetUser.is_admin ? 'admin' : 'trader'))) {
|
||||
updates.role = newRole;
|
||||
updates.is_admin = newRole === 'admin';
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const updated = await adminUpdateUser(String(modal.targetUser.id), updates);
|
||||
setUsers((prev) => prev.map((x) => (x.id === modal.targetUser!.id ? { ...x, ...updated } : x)));
|
||||
}
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to update user');
|
||||
}
|
||||
}, [modal.targetUser, formData, closeModal]);
|
||||
|
||||
const handleResetPassword = useCallback(async () => {
|
||||
if (!modal.targetUser) return;
|
||||
setFormError('');
|
||||
if (formData.password.length < 8) {
|
||||
setFormError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
if (!/\d/.test(formData.password)) {
|
||||
setFormError('Password must contain at least 1 number');
|
||||
return;
|
||||
}
|
||||
if (!/[^a-zA-Z0-9]/.test(formData.password)) {
|
||||
setFormError('Password must contain at least 1 special character');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminResetPassword(String(modal.targetUser.id), formData.password);
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to reset password');
|
||||
}
|
||||
}, [modal.targetUser, formData.password, closeModal]);
|
||||
|
||||
const handleDeleteUser = useCallback(async () => {
|
||||
if (!modal.targetUser) return;
|
||||
setFormError('');
|
||||
try {
|
||||
await adminDeleteUser(String(modal.targetUser.id));
|
||||
setUsers((prev) => prev.filter((x) => x.id !== modal.targetUser!.id));
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to delete user');
|
||||
}
|
||||
}, [modal.targetUser, closeModal]);
|
||||
|
||||
// ── Exchange actions ──
|
||||
const handleAddExchange = useCallback(async () => {
|
||||
try {
|
||||
await adminCreateExchange(newEx);
|
||||
setShowAddEx(false);
|
||||
setNewEx({ name: '', display_name: '', base_url: '', ws_url: '' });
|
||||
const data = await adminGetExchanges();
|
||||
setExchanges(data);
|
||||
} catch (err: any) {
|
||||
alert('Error: ' + (err.message || 'Unknown error'));
|
||||
}
|
||||
}, [newEx]);
|
||||
|
||||
const toggleExchange = useCallback(async (ex: Exchange) => {
|
||||
try {
|
||||
await adminUpdateExchange(ex.id, { is_active: !ex.is_active });
|
||||
setExchanges((prev) => prev.map((x) => (x.id === ex.id ? { ...x, is_active: !x.is_active } : x)));
|
||||
} catch (err: any) {
|
||||
alert('Error: ' + (err.message || 'Unknown error'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Filtered users ──
|
||||
const filteredUsers = users.filter((u) => {
|
||||
if (!userSearch) return true;
|
||||
const q = userSearch.toLowerCase();
|
||||
return (
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.display_name || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// Stats
|
||||
const totalUsers = users.length;
|
||||
const activeUsers = users.filter((u) => u.is_active).length;
|
||||
const adminUsers = users.filter((u) => u.is_admin).length;
|
||||
|
||||
// ── Helpers ──
|
||||
const formatUptime = (seconds: number) => {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${d}d ${h}h ${m}m`;
|
||||
};
|
||||
|
||||
if (!user?.is_admin) return null;
|
||||
if (initializing || isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading admin panel...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
|
||||
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
|
||||
touchAction: 'manipulation', minHeight: 36,
|
||||
};
|
||||
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
|
||||
const cellBtn: React.CSSProperties = {
|
||||
padding: '3px 10px', fontSize: 11, border: '1px solid #30363d',
|
||||
borderRadius: 4, background: 'transparent', color: '#58a6ff', cursor: 'pointer',
|
||||
touchAction: 'manipulation', minHeight: 36,
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = { ...cellBtn, color: '#f85149', borderColor: '#f8514933' };
|
||||
const badgeGreen: React.CSSProperties = { color: '#3fb950', fontSize: 12, fontWeight: 600 };
|
||||
const badgeRed: React.CSSProperties = { color: '#f85149', fontSize: 12, fontWeight: 600 };
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
|
||||
border: '1px solid #30363d', borderRadius: 4, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
{/* ═══ Navbar ═══ */}
|
||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 18 }}>🛡️</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Admin Panel</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<a href="/profile" style={{ fontSize: 12, color: '#8b949e', textDecoration: 'none', whiteSpace: 'nowrap' }}>👤 {user.username}</a>
|
||||
<button style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button
|
||||
style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ═══ Tabs ═══ */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px', borderBottom: '1px solid #21262d' }}>
|
||||
{(['users', 'exchanges', 'health'] as const).map((tab) => (
|
||||
<button key={tab} style={activeTab === tab ? activeBtn : btnStyle} onClick={() => setActiveTab(tab)}>
|
||||
{tab === 'users' ? '👥 Users' : tab === 'exchanges' ? '🔌 Exchanges' : '❤️ Health'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════════════════ */}
|
||||
{/* ═══ USERS TAB ═══ */}
|
||||
{/* ════════════════════════════════════════════════════ */}
|
||||
{activeTab === 'users' && (
|
||||
<div style={{ padding: 16 }}>
|
||||
{/* Stats bar */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>TOTAL</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{totalUsers}</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ACTIVE</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{activeUsers}</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ADMINS</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#d29922' }}>{adminUsers}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Role Permission Legend ═══ */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 11, color: '#d29922', fontWeight: 700, marginBottom: 6 }}>🛡️ Admin</div>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
||||
<strong style={{ color: '#f0f6fc' }}>Toàn quyền</strong> — mọi tính năng.<br />
|
||||
• Quản lý user (thêm/sửa/xóa, đặt role)<br />
|
||||
Quản lý exchange & symbol (thêm, sửa, active/deactive)<br />
|
||||
Xem audit log toàn bộ hệ thống<br />
|
||||
Quản lý system config, health check<br />
|
||||
Xem tất cả trades, signals, backtest của mọi user<br />
|
||||
Đặt lệnh real trade (place order)<br />
|
||||
Quản lý strategies, alerts, notification channels
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', fontWeight: 700, marginBottom: 6 }}>👤 Trader</div>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
||||
• Xem dashboard, signals & multi-timeframe chart<br />
|
||||
Chạy backtest & lưu kết quả<br />
|
||||
Xem & quản lý hypothetical trades cá nhân<br />
|
||||
<strong style={{ color: '#f0f6fc' }}>Đặt lệnh real trade</strong> (place order)<br />
|
||||
Quản lý watchlist, API credentials, personal alerts<br />
|
||||
Quản lý strategies cá nhân<br />
|
||||
Xem analytics & performance cá nhân
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 11, color: '#58a6ff', fontWeight: 700, marginBottom: 6 }}>👁️ Viewer</div>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
||||
<strong style={{ color: '#f85149' }}>Chỉ xem (read-only)</strong><br />
|
||||
• Xem dashboard & real-time chart<br />
|
||||
Xem signals, trades, backtest history<br />
|
||||
Xem analytics, alerts, watchlist<br />
|
||||
<strong style={{ color: '#f85149' }}>Không thể:</strong> trade, đặt lệnh, tạo/sửa user,<br />
|
||||
chạy backtest mới, chỉnh sửa credentials
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + Add bar */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
|
||||
<input
|
||||
style={{ ...inputStyle, maxWidth: 300 }}
|
||||
placeholder="🔍 Search by username, email, display name..."
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
onClick={() => openModal('add')}
|
||||
>
|
||||
+ Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User table */}
|
||||
{usersLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading users...</p>}
|
||||
{usersError && <p style={{ color: '#f85149', fontSize: 13 }}>{usersError}</p>}
|
||||
{!usersLoading && !usersError && (
|
||||
<>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
|
||||
{userSearch ? 'No users match your search.' : 'No users yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -12px', padding: '0 12px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 600 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Username</th>
|
||||
<th style={thStyle}>Display Name</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Role</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Created</th>
|
||||
<th style={thStyle}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={String(u.id)} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ ...tdStyle, fontWeight: 600, color: '#f0f6fc' }}>{u.username}</td>
|
||||
<td style={tdStyle}>{u.display_name || <span style={{ color: '#484f58' }}>—</span>}</td>
|
||||
<td style={tdStyle}>{u.email}</td>
|
||||
<td style={tdStyle}>
|
||||
{(() => {
|
||||
const role = u.role || (u.is_admin ? 'admin' : 'trader');
|
||||
if (role === 'admin') return <span style={{ color: '#d29922', fontWeight: 600 }}>🛡️ Admin</span>;
|
||||
if (role === 'viewer') return <span style={{ color: '#58a6ff', fontWeight: 500 }}>👁️ Viewer</span>;
|
||||
return <span style={{ color: '#8b949e' }}>👤 Trader</span>;
|
||||
})()}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_active')}>
|
||||
{u.is_active ? <span style={badgeGreen}>● Active</span> : <span style={badgeRed}>● Inactive</span>}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, color: '#484f58' }}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_admin')}>
|
||||
{u.is_admin ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button style={cellBtn} onClick={() => openModal('edit', u)}>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button style={cellBtn} onClick={() => openModal('reset', u)}>
|
||||
🔑 Reset
|
||||
</button>
|
||||
<button style={dangerBtn} onClick={() => openModal('delete', u)}>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginTop: 8 }}>
|
||||
Showing {filteredUsers.length} of {totalUsers} users
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══ MODAL ═══ */}
|
||||
{modal.show && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#161b22', border: '1px solid #30363d', borderRadius: 12,
|
||||
padding: 24, maxWidth: 440, width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<h3 style={{ fontSize: 15, color: '#f0f6fc', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{modal.type === 'add' && '➕ Add User'}
|
||||
{modal.type === 'edit' && '✏️ Edit User'}
|
||||
{modal.type === 'reset' && '🔑 Reset Password'}
|
||||
{modal.type === 'delete' && '🗑️ Delete User'}
|
||||
</h3>
|
||||
|
||||
{/* Form fields */}
|
||||
{modal.type === 'add' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div style={labelStyle}>Username</div>
|
||||
<input style={inputStyle} value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Email</div>
|
||||
<input style={inputStyle} type="email" value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Password (8+ chars, 1 number, 1 special)</div>
|
||||
<input style={inputStyle} type="password" value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Display Name</div>
|
||||
<input style={inputStyle} value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Role</div>
|
||||
<select style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
||||
<option value="admin">🛡️ Admin</option>
|
||||
<option value="trader">👤 Trader</option>
|
||||
<option value="viewer">👁️ Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal.type === 'edit' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div style={labelStyle}>Username</div>
|
||||
<input style={{ ...inputStyle, opacity: 0.5 }} value={formData.username} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Email</div>
|
||||
<input style={inputStyle} type="email" value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Display Name</div>
|
||||
<input style={inputStyle} value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Role</div>
|
||||
<select style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
||||
<option value="admin">🛡️ Admin</option>
|
||||
<option value="trader">👤 Trader</option>
|
||||
<option value="viewer">👁️ Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal.type === 'reset' && (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: '#8b949e', margin: '0 0 12px 0' }}>
|
||||
Reset password for <strong style={{ color: '#f0f6fc' }}>{modal.targetUser?.username}</strong>.
|
||||
All active sessions will be revoked.
|
||||
</p>
|
||||
<div>
|
||||
<div style={labelStyle}>New Password (8+ chars, 1 number, 1 special)</div>
|
||||
<input style={inputStyle} type="password" value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal.type === 'delete' && (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: '#f85149', margin: '0 0 12px 0', background: 'rgba(248,81,73,0.1)', padding: 10, borderRadius: 6, border: '1px solid rgba(248,81,73,0.2)' }}>
|
||||
⚠️ This will permanently delete user <strong>{modal.targetUser?.username}</strong>.
|
||||
Watchlist, credentials, and sessions will be removed. This cannot be undone!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{formError && <p style={{ color: '#f85149', fontSize: 12, marginTop: 8 }}>{formError}</p>}
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button style={btnStyle} onClick={closeModal}>Cancel</button>
|
||||
{modal.type === 'add' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636' }}
|
||||
onClick={handleAddUser}
|
||||
disabled={!formData.username || !formData.email || formData.password.length < 8}
|
||||
>
|
||||
✅ Create User
|
||||
</button>
|
||||
)}
|
||||
{modal.type === 'edit' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}
|
||||
onClick={handleEditUser}
|
||||
>
|
||||
💾 Save Changes
|
||||
</button>
|
||||
)}
|
||||
{modal.type === 'reset' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#d29922', color: '#fff', borderColor: '#d29922' }}
|
||||
onClick={handleResetPassword}
|
||||
disabled={!formData.password || formData.password.length < 8}
|
||||
>
|
||||
🔑 Reset Password
|
||||
</button>
|
||||
)}
|
||||
{modal.type === 'delete' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#da3633', color: '#fff', borderColor: '#da3633' }}
|
||||
onClick={handleDeleteUser}
|
||||
>
|
||||
🗑️ Delete Forever
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════ */}
|
||||
{/* ═══ EXCHANGES TAB ═══ */}
|
||||
{/* ════════════════════════════════════════════════════ */}
|
||||
{activeTab === 'exchanges' && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: 0 }}>Exchange Management</h3>
|
||||
<button style={btnStyle} onClick={() => setShowAddEx(!showAddEx)}>
|
||||
{showAddEx ? '✕ Cancel' : '+ Add Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddEx && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 12, background: '#161b22', borderRadius: 8, marginBottom: 12, border: '1px solid #30363d' }}>
|
||||
<input style={inputStyle} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
|
||||
<input style={inputStyle} placeholder="Display Name (e.g. Binance)" value={newEx.display_name} onChange={(e) => setNewEx({ ...newEx, display_name: e.target.value })} />
|
||||
<input style={inputStyle} placeholder="Base URL (e.g. https://api.binance.com)" value={newEx.base_url} onChange={(e) => setNewEx({ ...newEx, base_url: e.target.value })} />
|
||||
<input style={inputStyle} placeholder="WS URL (e.g. wss://stream.binance.com:9443)" value={newEx.ws_url} onChange={(e) => setNewEx({ ...newEx, ws_url: e.target.value })} />
|
||||
<button style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', marginTop: 4 }} onClick={handleAddExchange}>
|
||||
✅ Create Exchange
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading exchanges...</p>}
|
||||
{!exLoading && (
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Name</th>
|
||||
<th style={thStyle}>Display</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exchanges.map((ex) => (
|
||||
<tr key={ex.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={tdStyle}>{ex.name}</td>
|
||||
<td style={tdStyle}>{ex.display_name || ex.name}</td>
|
||||
<td style={tdStyle}>
|
||||
{ex.is_active ? <span style={badgeGreen}>Active</span> : <span style={badgeRed}>Inactive</span>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button style={cellBtn} onClick={() => toggleExchange(ex)}>
|
||||
{ex.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ HEALTH TAB ═══ */}
|
||||
{activeTab === 'health' && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: '0 0 12px 0' }}>System Health</h3>
|
||||
{healthLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading health data...</p>}
|
||||
{!healthLoading && !health && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#484f58', fontSize: 13, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 24, marginBottom: 8 }}>⚠️</div>
|
||||
Unable to fetch health data. The health endpoint may not be available.
|
||||
</div>
|
||||
)}
|
||||
{health && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>STATUS</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: health.status === 'healthy' ? '#3fb950' : '#f85149' }}>
|
||||
{health.status === 'healthy' ? '✅ Healthy' : '⚠️ Degraded'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>UPTIME</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{formatUptime(health.uptime)}</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>VERSION</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{health.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Database</h4>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Connected</th>
|
||||
<th style={thStyle}>Latency</th>
|
||||
<th style={thStyle}>Pool Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={tdStyle}>{health.db.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}</td>
|
||||
<td style={tdStyle}>{health.db.latency_ms} ms</td>
|
||||
<td style={tdStyle}>{health.db.pool_size} connections</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Exchange Connections</h4>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Exchange</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{health.exchange_connections.map((ec) => (
|
||||
<tr key={ec.exchange} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={tdStyle}>{ec.exchange}</td>
|
||||
<td style={tdStyle}>
|
||||
{ec.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
};
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '8px 12px', verticalAlign: 'middle',
|
||||
};
|
||||
Executable
+507
@@ -0,0 +1,507 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { fetchCurrentUser, logoutThunk } from '../auth/authSlice';
|
||||
import {
|
||||
fetchAlerts,
|
||||
createAlert,
|
||||
updateAlert,
|
||||
deleteAlert,
|
||||
type AlertItem,
|
||||
type ConditionObject,
|
||||
} from '../api/alertApi';
|
||||
|
||||
// ── Styles ──
|
||||
const pageStyle: React.CSSProperties = {
|
||||
minHeight: '100vh', background: '#0d1117', color: '#c9d1d9',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||
padding: '24px',
|
||||
};
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 24, flexWrap: 'wrap', gap: 12,
|
||||
};
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: 16, background: '#161b22', borderRadius: 8,
|
||||
border: '1px solid #30363d', marginBottom: 12,
|
||||
};
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
|
||||
borderRadius: 6, background: '#21262d', color: '#c9d1d9',
|
||||
cursor: 'pointer', touchAction: 'manipulation', minHeight: 32,
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb',
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = {
|
||||
...btnStyle, color: '#f85149', borderColor: '#f8514933',
|
||||
};
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
|
||||
border: '1px solid #30363d', borderRadius: 4, outline: 'none',
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
};
|
||||
const selectStyle: React.CSSProperties = { ...inputStyle, width: '100%' };
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
|
||||
};
|
||||
const modalOverlay: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
};
|
||||
const modalContent: React.CSSProperties = {
|
||||
background: '#161b22', borderRadius: 8, border: '1px solid #30363d',
|
||||
padding: 24, width: '90%', maxWidth: 600, maxHeight: '80vh',
|
||||
overflow: 'auto', color: '#c9d1d9',
|
||||
};
|
||||
const toggleStyle: React.CSSProperties = {
|
||||
width: 36, height: 20, borderRadius: 10, border: 'none',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background 0.2s',
|
||||
};
|
||||
|
||||
const INDICATOR_OPTIONS = [
|
||||
{ value: 'rsi', label: 'RSI' },
|
||||
{ value: 'macd', label: 'MACD Histogram' },
|
||||
{ value: 'bb_width', label: 'BB Width' },
|
||||
{ value: 'volume', label: 'Volume' },
|
||||
{ value: 'price', label: 'Price' },
|
||||
{ value: 'sma', label: 'SMA' },
|
||||
{ value: 'ema', label: 'EMA' },
|
||||
{ value: 'momentum', label: 'Momentum' },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<=', label: '<=' },
|
||||
{ value: '==', label: '==' },
|
||||
{ value: 'cross_above', label: 'Cross Above' },
|
||||
{ value: 'cross_below', label: 'Cross Below' },
|
||||
];
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: '', label: 'Any' },
|
||||
{ value: '15m', label: '15m' },
|
||||
{ value: '30m', label: '30m' },
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '4h', label: '4h' },
|
||||
{ value: '1d', label: '1d' },
|
||||
];
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
{ value: 'discord', label: 'Discord' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
];
|
||||
|
||||
export default function AlertsPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isAuthenticated } = useAppSelector((state) => state.auth);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<AlertItem | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formPlatform, setFormPlatform] = useState('telegram');
|
||||
const [formConditions, setFormConditions] = useState<ConditionObject[]>([
|
||||
{ indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' },
|
||||
]);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Auth guard
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) { navigate('/login', { replace: true }); return; }
|
||||
if (!user) {
|
||||
dispatch(fetchCurrentUser())
|
||||
.unwrap()
|
||||
.catch(() => navigate('/login', { replace: true }))
|
||||
.finally(() => setInitializing(false));
|
||||
} else {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, [isAuthenticated, user, dispatch, navigate]);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await fetchAlerts();
|
||||
setAlerts(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load alerts');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing) loadAlerts();
|
||||
}, [initializing, loadAlerts]);
|
||||
|
||||
// ── Modal handlers ──
|
||||
const openCreateModal = () => {
|
||||
setEditTarget(null);
|
||||
setFormName('');
|
||||
setFormPlatform('telegram');
|
||||
setFormConditions([{ indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' }]);
|
||||
setFormError('');
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (alert: AlertItem) => {
|
||||
setEditTarget(alert);
|
||||
setFormName(alert.name);
|
||||
setFormPlatform(alert.notify_platform);
|
||||
setFormConditions(alert.conditions.length > 0 ? alert.conditions : [{ indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' }]);
|
||||
setFormError('');
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditTarget(null);
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
setFormConditions([...formConditions, { indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' }]);
|
||||
};
|
||||
|
||||
const removeCondition = (idx: number) => {
|
||||
if (formConditions.length <= 1) return;
|
||||
setFormConditions(formConditions.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateCondition = (idx: number, field: keyof ConditionObject, value: any) => {
|
||||
const updated = formConditions.map((c, i) =>
|
||||
i === idx ? { ...c, [field]: value } : c,
|
||||
);
|
||||
setFormConditions(updated);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setFormError('');
|
||||
if (!formName.trim()) { setFormError('Name is required'); return; }
|
||||
if (formConditions.length === 0) { setFormError('At least one condition required'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateAlert(editTarget.id, {
|
||||
name: formName.trim(),
|
||||
conditions: formConditions,
|
||||
notify_platform: formPlatform,
|
||||
});
|
||||
} else {
|
||||
await createAlert({
|
||||
name: formName.trim(),
|
||||
conditions: formConditions,
|
||||
notify_platform: formPlatform,
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
loadAlerts();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Save failed');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (alert: AlertItem) => {
|
||||
if (!confirm(`Delete alert "${alert.name}"?`)) return;
|
||||
try {
|
||||
await deleteAlert(alert.id);
|
||||
loadAlerts();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (alert: AlertItem) => {
|
||||
try {
|
||||
await updateAlert(alert.id, { is_active: !alert.is_active });
|
||||
loadAlerts();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Toggle failed');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ──
|
||||
if (initializing) {
|
||||
return <div style={pageStyle}><p style={{ textAlign: 'center', paddingTop: 80 }}>Loading...</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
{/* ─── NAV ─── */}
|
||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>🔔</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Smart Alerts</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button
|
||||
style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '16px 24px' }}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>🔔 Smart Alerts</h2>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button style={primaryBtn} onClick={openCreateModal}>+ New Alert</button>
|
||||
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ padding: '8px 12px', background: 'rgba(248,81,73,0.15)', borderRadius: 6, marginBottom: 16, fontSize: 12, color: '#f85149' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <p style={{ textAlign: 'center', color: '#8b949e' }}>Loading alerts...</p>}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && alerts.length === 0 && (
|
||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#8b949e' }}>
|
||||
<p style={{ fontSize: 40, margin: 0 }}>🔔</p>
|
||||
<p>No alerts configured yet.</p>
|
||||
<button style={primaryBtn} onClick={openCreateModal}>Create your first alert</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert cards */}
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{alert.name}</h3>
|
||||
<span style={{
|
||||
fontSize: 10, padding: '1px 6px', borderRadius: 4,
|
||||
background: alert.is_active ? 'rgba(63,185,80,0.15)' : 'rgba(72,79,88,0.3)',
|
||||
color: alert.is_active ? '#3fb950' : '#484f58',
|
||||
}}>
|
||||
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: '#8b949e' }}>via {alert.notify_platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{/* Toggle */}
|
||||
<button
|
||||
style={{
|
||||
...toggleStyle,
|
||||
background: alert.is_active ? '#1f6feb' : '#21262d',
|
||||
}}
|
||||
onClick={() => handleToggle(alert)}
|
||||
title={alert.is_active ? 'Pause' : 'Activate'}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, width: 16, height: 16,
|
||||
borderRadius: '50%', background: '#fff',
|
||||
left: alert.is_active ? 18 : 2,
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</button>
|
||||
<button style={btnStyle} onClick={() => openEditModal(alert)}>Edit</button>
|
||||
<button style={dangerBtn} onClick={() => handleDelete(alert)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>Conditions:</div>
|
||||
{alert.conditions.map((cond, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', background: '#0d1117', borderRadius: 4,
|
||||
margin: '2px 4px 2px 0', fontSize: 11, color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
}}>
|
||||
<span style={{ color: '#58a6ff' }}>{cond.indicator}</span>
|
||||
<span style={{ color: '#d29922' }}>{cond.operator}</span>
|
||||
<span style={{ color: '#3fb950' }}>{cond.value}</span>
|
||||
{cond.timeframe && <span style={{ color: '#8b949e' }}>({cond.timeframe})</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Create/Edit Modal ── */}
|
||||
{showModal && (
|
||||
<div style={modalOverlay} onClick={closeModal}>
|
||||
<div style={modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
|
||||
{editTarget ? 'Edit Alert' : 'New Alert'}
|
||||
</h3>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={labelStyle}>Name</div>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="e.g. RSI overbought + volume spike"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={labelStyle}>Notify via</div>
|
||||
<select
|
||||
style={selectStyle}
|
||||
value={formPlatform}
|
||||
onChange={(e) => setFormPlatform(e.target.value)}
|
||||
>
|
||||
{PLATFORM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Conditions (all must pass)</span>
|
||||
<button style={btnStyle} onClick={addCondition}>+ Add Condition</button>
|
||||
</div>
|
||||
|
||||
{formConditions.map((cond, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap',
|
||||
padding: '8px 0', borderBottom: idx < formConditions.length - 1 ? '1px solid #21262d' : 'none',
|
||||
}}>
|
||||
{/* Indicator */}
|
||||
<select
|
||||
style={{ ...selectStyle, width: 110 }}
|
||||
value={cond.indicator}
|
||||
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
|
||||
>
|
||||
{INDICATOR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Operator */}
|
||||
<select
|
||||
style={{ ...selectStyle, width: 100 }}
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Value */}
|
||||
<input
|
||||
style={{ ...inputStyle, width: 80 }}
|
||||
type="number"
|
||||
step="any"
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(idx, 'value', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
{/* Timeframe */}
|
||||
<select
|
||||
style={{ ...selectStyle, width: 70 }}
|
||||
value={cond.timeframe || ''}
|
||||
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
|
||||
>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Extra fields for volume */}
|
||||
{cond.indicator === 'volume' && (
|
||||
<>
|
||||
<select
|
||||
style={{ ...selectStyle, width: 110 }}
|
||||
value={cond.type || 'absolute'}
|
||||
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
|
||||
>
|
||||
<option value="absolute">Absolute</option>
|
||||
<option value="avg_multiplier">Avg Multiplier</option>
|
||||
</select>
|
||||
{cond.type === 'avg_multiplier' && (
|
||||
<input
|
||||
style={{ ...inputStyle, width: 60 }}
|
||||
type="number"
|
||||
placeholder="Period"
|
||||
value={cond.period || 20}
|
||||
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Extra for sma/ema */}
|
||||
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
|
||||
<input
|
||||
style={{ ...inputStyle, width: 60 }}
|
||||
type="number"
|
||||
placeholder="Period"
|
||||
value={cond.period || 20}
|
||||
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
style={{ ...dangerBtn, padding: '2px 8px', fontSize: 10 }}
|
||||
onClick={() => removeCondition(idx)}
|
||||
disabled={formConditions.length <= 1}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError && (
|
||||
<div style={{ padding: '6px 10px', background: 'rgba(248,81,73,0.15)', borderRadius: 4, marginBottom: 12, fontSize: 11, color: '#f85149' }}>
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button style={btnStyle} onClick={closeModal}>Cancel</button>
|
||||
<button
|
||||
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : editTarget ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { fetchCurrentUser } from '../auth/authSlice';
|
||||
import { logoutThunk } from '../auth/authSlice';
|
||||
import { useT } from '../../translations';
|
||||
|
||||
// ── Mock/temporary analytics data ──
|
||||
// These would be replaced by real API calls. For now, generate realistic demo data.
|
||||
interface DailyPnL {
|
||||
date: string;
|
||||
pnl: number;
|
||||
isSignal: boolean;
|
||||
}
|
||||
|
||||
interface AnalyticsStats {
|
||||
totalPnL: number;
|
||||
winRate: number;
|
||||
bestTrade: number;
|
||||
worstTrade: number;
|
||||
profitFactor: number;
|
||||
maxDrawdown: number;
|
||||
totalTrades: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
}
|
||||
|
||||
function generateDemoData(isSignal: boolean): { dailyPnL: DailyPnL[]; stats: AnalyticsStats } {
|
||||
const dailyPnL: DailyPnL[] = [];
|
||||
let equity = 10000;
|
||||
let peak = 10000;
|
||||
let maxDd = 0;
|
||||
let totalWins = 0;
|
||||
let totalLosses = 0;
|
||||
let totalWinAmount = 0;
|
||||
let totalLossAmount = 0;
|
||||
let bestTrade = 0;
|
||||
let worstTrade = 0;
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const basePnL = (Math.random() - 0.48) * (isSignal ? 300 : 200);
|
||||
const pnl = Math.round(basePnL * 100) / 100;
|
||||
dailyPnL.push({ date: date.toISOString().slice(0, 10), pnl, isSignal });
|
||||
equity += pnl;
|
||||
if (equity > peak) peak = equity;
|
||||
const dd = ((peak - equity) / peak) * 100;
|
||||
if (dd > maxDd) maxDd = dd;
|
||||
if (pnl >= 0) {
|
||||
totalWins++;
|
||||
totalWinAmount += pnl;
|
||||
if (pnl > bestTrade) bestTrade = pnl;
|
||||
} else {
|
||||
totalLosses++;
|
||||
totalLossAmount += Math.abs(pnl);
|
||||
if (pnl < worstTrade) worstTrade = pnl;
|
||||
}
|
||||
}
|
||||
|
||||
const totalTrades = totalWins + totalLosses;
|
||||
const winRate = totalTrades > 0 ? (totalWins / totalTrades) * 100 : 0;
|
||||
const profitFactor = totalLossAmount > 0 ? totalWinAmount / totalLossAmount : totalWinAmount > 0 ? Infinity : 0;
|
||||
|
||||
return {
|
||||
dailyPnL,
|
||||
stats: {
|
||||
totalPnL: Math.round((equity - 10000) * 100) / 100,
|
||||
winRate: Math.round(winRate * 10) / 10,
|
||||
bestTrade: Math.round(bestTrade * 100) / 100,
|
||||
worstTrade: Math.round(worstTrade * 100) / 100,
|
||||
profitFactor: profitFactor === Infinity ? 999 : Math.round(profitFactor * 100) / 100,
|
||||
maxDrawdown: Math.round(maxDd * 10) / 10,
|
||||
totalTrades,
|
||||
wins: totalWins,
|
||||
losses: totalLosses,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Simple SVG Bar Chart ──
|
||||
function BarChart({
|
||||
data,
|
||||
height = 200,
|
||||
colorUp = '#3fb950',
|
||||
colorDown = '#f85149',
|
||||
}: {
|
||||
data: DailyPnL[];
|
||||
height?: number;
|
||||
colorUp?: string;
|
||||
colorDown?: string;
|
||||
}) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const maxAbs = Math.max(...data.map((d) => Math.abs(d.pnl)), 1);
|
||||
const barWidth = Math.max(4, Math.min(12, (800 - 40) / data.length - 2));
|
||||
const chartWidth = Math.max(800, data.length * (barWidth + 2) + 40);
|
||||
|
||||
return (
|
||||
<svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||
{/* Zero line */}
|
||||
<line x1={0} y1={height / 2} x2={chartWidth} y2={height / 2} stroke="#30363d" strokeWidth={1} />
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const barHeight = (Math.abs(d.pnl) / maxAbs) * (height / 2 - 4);
|
||||
const y = d.pnl >= 0 ? height / 2 - barHeight : height / 2;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={20 + i * (barWidth + 2)}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={Math.max(barHeight, 1)}
|
||||
fill={d.pnl >= 0 ? colorUp : colorDown}
|
||||
rx={1}
|
||||
opacity={0.85}
|
||||
>
|
||||
<title>{`${d.date}: ${d.pnl >= 0 ? '+' : ''}$${d.pnl.toFixed(2)}`}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Simple SVG Line Chart (Equity Curve) ──
|
||||
function LineChart({
|
||||
equityData,
|
||||
height = 200,
|
||||
lineColor = '#58a6ff',
|
||||
}: {
|
||||
equityData: number[];
|
||||
height?: number;
|
||||
lineColor?: string;
|
||||
}) {
|
||||
if (equityData.length < 2) return null;
|
||||
|
||||
const min = Math.min(...equityData);
|
||||
const max = Math.max(...equityData);
|
||||
const range = max - min || 1;
|
||||
const padding = 20;
|
||||
const chartWidth = Math.max(800, equityData.length * 4);
|
||||
|
||||
const points = equityData
|
||||
.map((val, i) => {
|
||||
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
|
||||
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
// Area under the curve
|
||||
const areaPoints = `${padding},${height - padding} ${points} ${chartWidth - padding},${height - padding}`;
|
||||
|
||||
return (
|
||||
<svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="equity-gradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={lineColor} stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Area fill */}
|
||||
<polygon points={areaPoints} fill="url(#equity-gradient)" />
|
||||
{/* Line */}
|
||||
<polyline points={points} fill="none" stroke={lineColor} strokeWidth={2} strokeLinejoin="round" />
|
||||
{/* Dots on line */}
|
||||
{equityData.map((val, i) => {
|
||||
if (i % 5 !== 0 && i !== equityData.length - 1) return null;
|
||||
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
|
||||
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||
return (
|
||||
<circle key={i} cx={x} cy={y} r={3} fill={lineColor} opacity={0.8}>
|
||||
<title>{`Day ${i + 1}: $${val.toFixed(2)}`}</title>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat Card ──
|
||||
function StatCard({ label, value, color, isCurrency }: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
isCurrency?: boolean;
|
||||
}) {
|
||||
const displayValue = isCurrency
|
||||
? `${Number(value) >= 0 ? '+' : ''}$${Number(value).toFixed(2)}`
|
||||
: value;
|
||||
return (
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
||||
color: color || '#f0f6fc',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================
|
||||
export default function AnalyticsPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [dataMode, setDataMode] = useState<'signal' | 'real'>('signal');
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// Responsive
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Auth guard
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) { navigate('/login', { replace: true }); return; }
|
||||
if (!user) {
|
||||
dispatch(fetchCurrentUser())
|
||||
.unwrap()
|
||||
.catch(() => navigate('/login', { replace: true }));
|
||||
return;
|
||||
}
|
||||
setInitializing(false);
|
||||
}, [isAuthenticated, user, dispatch, navigate]);
|
||||
|
||||
// Generate demo data based on toggle
|
||||
const demoData = useMemo(() => generateDemoData(dataMode === 'signal'), [dataMode]);
|
||||
const equityCurve = useMemo(() => {
|
||||
let eq = 10000;
|
||||
return demoData.dailyPnL.map((d) => {
|
||||
eq += d.pnl;
|
||||
return Math.round(eq * 100) / 100;
|
||||
});
|
||||
}, [demoData]);
|
||||
|
||||
const { t } = useT(); // eslint-disable-line
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (initializing || isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#8b949e' }}>
|
||||
Loading analytics...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navBtn: React.CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: '#21262d',
|
||||
color: '#c9d1d9',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const toggleBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: active ? '#1f6feb' : '#21262d',
|
||||
color: active ? '#fff' : '#c9d1d9',
|
||||
cursor: 'pointer',
|
||||
fontWeight: active ? 600 : 400,
|
||||
transition: 'all 0.15s ease',
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const s = demoData.stats;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
{/* ─── NAV ─── */}
|
||||
<nav style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
background: '#161b22',
|
||||
borderBottom: '1px solid #30363d',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', flexShrink: 0 }} onClick={() => navigate('/dashboard')}>
|
||||
<span style={{ fontSize: 18 }}>📈</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Analytics</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>👤 {user?.username}</span>
|
||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/profile')}>Profile</button>
|
||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }} onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ─── PAGE CONTENT ─── */}
|
||||
<div style={{ padding: isMobile ? 12 : 20, maxWidth: 960, margin: '0 auto' }}>
|
||||
{/* Header + Toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap' as const,
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc', margin: 0 }}>
|
||||
📊 Performance Overview
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
style={toggleBtn(dataMode === 'signal')}
|
||||
onClick={() => setDataMode('signal')}
|
||||
>
|
||||
📈 Signal
|
||||
</button>
|
||||
<button
|
||||
style={toggleBtn(dataMode === 'real')}
|
||||
onClick={() => setDataMode('real')}
|
||||
>
|
||||
💹 Real
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── STATS CARDS GRID ─── */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(6, 1fr)',
|
||||
gap: isMobile ? 8 : 10,
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<StatCard label="Total P&L" value={s.totalPnL} color={s.totalPnL >= 0 ? '#3fb950' : '#f85149'} isCurrency />
|
||||
<StatCard label="Win Rate" value={`${s.winRate}%`} color={s.winRate >= 50 ? '#3fb950' : '#f85149'} />
|
||||
<StatCard label="Best Trade" value={s.bestTrade} color="#3fb950" isCurrency />
|
||||
<StatCard label="Worst Trade" value={s.worstTrade} color="#f85149" isCurrency />
|
||||
<StatCard label="Profit Factor" value={s.profitFactor} color={s.profitFactor >= 1 ? '#3fb950' : '#f85149'} />
|
||||
<StatCard label="Max Drawdown" value={`${s.maxDrawdown}%`} color="#d29922" />
|
||||
</div>
|
||||
|
||||
{/* ─── PnL Bar Chart ─── */}
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 10,
|
||||
padding: '16px 12px',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
|
||||
📊 Daily P&L (Last 30 Days)
|
||||
</h3>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<BarChart data={demoData.dailyPnL} height={180} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Equity Curve ─── */}
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 10,
|
||||
padding: '16px 12px',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
|
||||
📈 Equity Curve
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'baseline', marginBottom: 8, marginLeft: 4 }}>
|
||||
<span style={{ fontSize: 12, color: '#8b949e' }}>Starting: <strong style={{ color: '#c9d1d9' }}>$10,000.00</strong></span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: s.totalPnL >= 0 ? '#3fb950' : '#f85149' }}>
|
||||
{s.totalPnL >= 0 ? '+' : ''}${s.totalPnL.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<LineChart equityData={equityCurve} height={200} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Summary Trades Table ─── */}
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 10,
|
||||
padding: '16px',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 0' }}>
|
||||
📋 Trade Summary
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(4, 1fr)',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Total Trades</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{s.totalTrades}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Wins</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{s.wins}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Losses</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f85149' }}>{s.losses}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Data Source</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#58a6ff' }}>
|
||||
{dataMode === 'signal' ? '📈 Signal' : '💹 Real'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Footer note ─── */}
|
||||
<div style={{ fontSize: 11, color: '#484f58', textAlign: 'center' as const, padding: '16px 0' }}>
|
||||
Analytics data is based on {dataMode === 'signal' ? 'simulated signal' : 'real exchange'} trades.
|
||||
Charts update in real-time as new trades are executed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
/** API functions for user-defined multi-condition alerts. */
|
||||
|
||||
export interface ConditionObject {
|
||||
indicator: string;
|
||||
operator: string;
|
||||
value: number;
|
||||
timeframe?: string;
|
||||
type?: string;
|
||||
period?: number;
|
||||
}
|
||||
|
||||
export interface AlertItem {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
conditions: ConditionObject[];
|
||||
notify_platform: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertListResponse {
|
||||
alerts: AlertItem[];
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAlerts(): Promise<AlertItem[]> {
|
||||
const res = await fetch('/api/v1/alerts', { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch alerts: ${res.status}`);
|
||||
const data: AlertListResponse = await res.json();
|
||||
return data.alerts;
|
||||
}
|
||||
|
||||
export async function createAlert(data: {
|
||||
name: string;
|
||||
conditions: ConditionObject[];
|
||||
notify_platform: string;
|
||||
}): Promise<AlertItem> {
|
||||
const res = await fetch('/api/v1/alerts', {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAlert(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
conditions?: ConditionObject[];
|
||||
notify_platform?: string;
|
||||
is_active?: boolean;
|
||||
},
|
||||
): Promise<AlertItem> {
|
||||
const res = await fetch(`/api/v1/alerts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAlert(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/alerts/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
import type { Exchange, Symbol, Candle, Indicators, User } from '../../types/trading';
|
||||
|
||||
const BASE_URL = '/api/v1';
|
||||
|
||||
interface ApiError {
|
||||
detail?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class ApiServiceError extends Error {
|
||||
status: number;
|
||||
data: unknown;
|
||||
|
||||
constructor(message: string, status: number, data: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiServiceError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
function setTokens(access: string, refresh: string): void {
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
}
|
||||
|
||||
function clearTokens(): void {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newAccess = data.access_token || data.accessToken;
|
||||
const newRefresh = data.refresh_token || data.refreshToken;
|
||||
|
||||
if (newAccess) {
|
||||
setTokens(newAccess, newRefresh || refreshToken);
|
||||
return newAccess;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getAccessToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
} as Record<string, string>;
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
let response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// If 401, try refreshing the token
|
||||
if (response.status === 401 && token) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: ApiError | null = null;
|
||||
try {
|
||||
errorData = await response.json() as ApiError;
|
||||
} catch {
|
||||
// ignore parse failure
|
||||
}
|
||||
throw new ApiServiceError(
|
||||
errorData?.detail || errorData?.message || `HTTP ${response.status}`,
|
||||
response.status,
|
||||
errorData,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// --- Auth endpoints ---
|
||||
|
||||
export async function login(username: string, password: string): Promise<{ access_token: string; refresh_token: string; token_type?: string }> {
|
||||
const result = await apiFetch<{ access_token: string; refresh_token: string; token_type?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
setTokens(result.access_token, result.refresh_token);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function register(username: string, email: string, password: string): Promise<{ id: number; username: string; email: string }> {
|
||||
return apiFetch('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<{ id: number; username: string; email: string; is_active: boolean }> {
|
||||
return apiFetch('/auth/me');
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
await apiFetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: refreshToken || '' }),
|
||||
});
|
||||
} finally {
|
||||
clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exchange/Symbol endpoints ---
|
||||
|
||||
export async function getExchanges(): Promise<Exchange[]> {
|
||||
return apiFetch<Exchange[]>('/exchanges');
|
||||
}
|
||||
|
||||
export async function getSymbols(exchange: number, activeOnly = true): Promise<Symbol[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('exchange_id', String(exchange));
|
||||
if (activeOnly) params.append('active', 'true');
|
||||
return apiFetch<Symbol[]>(`/symbols?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getCandles(
|
||||
symbol: string,
|
||||
exchange: number,
|
||||
timeframe = '1h',
|
||||
limit = 200,
|
||||
): Promise<Candle[]> {
|
||||
const params = new URLSearchParams({
|
||||
exchange_id: String(exchange),
|
||||
timeframe,
|
||||
limit: String(limit),
|
||||
});
|
||||
return apiFetch<Candle[]>(`/symbols/${symbol}/candles?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getIndicators(
|
||||
symbol: string,
|
||||
exchange: number,
|
||||
timeframe = '1h',
|
||||
): Promise<Indicators> {
|
||||
const params = new URLSearchParams({
|
||||
exchange_id: String(exchange),
|
||||
timeframe,
|
||||
});
|
||||
return apiFetch<Indicators>(`/symbols/${symbol}/indicators?${params.toString()}`);
|
||||
}
|
||||
|
||||
// --- Admin endpoints ---
|
||||
|
||||
export async function adminGetUsers(): Promise<User[]> {
|
||||
return apiFetch<User[]>('/admin/users');
|
||||
}
|
||||
|
||||
export async function adminUpdateUser(userId: string, data: { is_active?: boolean; is_admin?: boolean; email?: string; display_name?: string | null }): Promise<User> {
|
||||
return apiFetch<User>(`/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminGetExchanges(): Promise<Exchange[]> {
|
||||
return apiFetch<Exchange[]>('/admin/exchanges');
|
||||
}
|
||||
|
||||
export async function adminCreateExchange(data: { name: string; display_name: string; base_url: string; ws_url: string }): Promise<Exchange> {
|
||||
return apiFetch<Exchange>('/admin/exchanges', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminUpdateExchange(id: number, data: { display_name?: string; is_active?: boolean; base_url?: string; ws_url?: string }): Promise<Exchange> {
|
||||
return apiFetch<Exchange>(`/admin/exchanges/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminGetHealth(): Promise<{ status: string; version: string; uptime: number; db: { connected: boolean; latency_ms: number; pool_size: number }; exchange_connections: { exchange: string; connected: boolean }[] }> {
|
||||
return apiFetch('/admin/health/detailed');
|
||||
}
|
||||
|
||||
// --- User Management ---
|
||||
|
||||
export async function adminCreateUser(data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
is_admin?: boolean;
|
||||
role?: string;
|
||||
}): Promise<User> {
|
||||
return apiFetch<User>('/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminDeleteUser(userId: string): Promise<{ message: string }> {
|
||||
return apiFetch<{ message: string }>(`/admin/users/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function adminResetPassword(userId: string, newPassword: string): Promise<{ message: string }> {
|
||||
return apiFetch<{ message: string }>(`/admin/users/${userId}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export { ApiServiceError, clearTokens, setTokens };
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
/** API functions for real trades. */
|
||||
|
||||
export interface RealTrade {
|
||||
id: number;
|
||||
user_id: number;
|
||||
exchange: string;
|
||||
symbol: string;
|
||||
side: string;
|
||||
order_type: string;
|
||||
amount: number;
|
||||
price: number | null;
|
||||
filled_amount: number;
|
||||
status: string;
|
||||
pnl: number | null;
|
||||
pnl_percent: number | null;
|
||||
order_id: string | null;
|
||||
created_at: string;
|
||||
closed_at: string | null;
|
||||
}
|
||||
|
||||
export interface RealTradeListResponse {
|
||||
trades: RealTrade[];
|
||||
total: number;
|
||||
total_pnl: number | null;
|
||||
win_rate: number | null;
|
||||
}
|
||||
|
||||
export interface WinRatePeriod {
|
||||
trades: number;
|
||||
wins: number;
|
||||
win_rate: number;
|
||||
}
|
||||
|
||||
export interface WinRateResponse {
|
||||
daily: WinRatePeriod;
|
||||
weekly: WinRatePeriod;
|
||||
monthly: WinRatePeriod;
|
||||
}
|
||||
|
||||
export async function fetchRealTrades(
|
||||
symbol?: string,
|
||||
status?: string,
|
||||
limit = 100,
|
||||
): Promise<RealTradeListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (symbol) params.set('symbol', symbol);
|
||||
if (status) params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch(`/api/v1/real-trades?${params}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch real trades: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchWinRate(): Promise<WinRateResponse> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch('/api/v1/real-trades/win-rate', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch win rate: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
/** API functions for trading signals and hypothetical trades. */
|
||||
|
||||
export interface Signal {
|
||||
id: number;
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
timeframe: string;
|
||||
signal_type: string;
|
||||
strength: string;
|
||||
price: number;
|
||||
timestamp: string;
|
||||
indicators_snapshot: string | null;
|
||||
status: string;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
id: number;
|
||||
signal_id: number | null;
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
timeframe: string;
|
||||
direction: string;
|
||||
entry_price: number;
|
||||
entry_time: string;
|
||||
entry_reason: string | null;
|
||||
exit_price: number | null;
|
||||
exit_time: string | null;
|
||||
exit_reason: string | null;
|
||||
quantity: number;
|
||||
pnl: number | null;
|
||||
pnl_percent: number | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
closed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SignalListResponse {
|
||||
signals: Signal[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TradeListResponse {
|
||||
trades: Trade[];
|
||||
total: number;
|
||||
total_pnl: number | null;
|
||||
win_rate: number | null;
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSignals(symbol?: string, limit = 50): Promise<SignalListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (symbol) params.set('symbol', symbol);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const res = await fetch(`/api/v1/signals?${params}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch signals: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTrades(symbol?: string, status?: string, limit = 100): Promise<TradeListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (symbol) params.set('symbol', symbol);
|
||||
if (status) params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const res = await fetch(`/api/v1/signals/trades?${params}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch trades: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Signal type display config */
|
||||
export const SIGNAL_CONFIG: Record<string, { label: string; icon: string; color: string; bg: string }> = {
|
||||
STRONG_BUY: { label: 'Strong Buy', icon: '🟢', color: '#3fb950', bg: 'rgba(63, 185, 80, 0.15)' },
|
||||
BUY: { label: 'Buy', icon: '✅', color: '#26a69a', bg: 'rgba(38, 166, 154, 0.12)' },
|
||||
STRONG_SELL: { label: 'Strong Sell', icon: '🔴', color: '#f85149', bg: 'rgba(248, 81, 73, 0.15)' },
|
||||
SELL: { label: 'Sell', icon: '❌', color: '#ef5350', bg: 'rgba(239, 83, 80, 0.12)' },
|
||||
CAUTION_LONG: { label: 'Caution Long', icon: '⚠️', color: '#d29922', bg: 'rgba(210, 153, 34, 0.15)' },
|
||||
CAUTION_SHORT: { label: 'Caution Short', icon: '⚠️', color: '#d29922', bg: 'rgba(210, 153, 34, 0.15)' },
|
||||
SQUEEZE_ALERT: { label: 'BB Squeeze', icon: '🔥', color: '#ff6b35', bg: 'rgba(255, 107, 53, 0.15)' },
|
||||
};
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
if (price >= 1000) return price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (price >= 1) return price.toFixed(4);
|
||||
return price.toFixed(6);
|
||||
}
|
||||
|
||||
export function formatPnL(pnl: number | null): string {
|
||||
if (pnl === null) return '—';
|
||||
const prefix = pnl >= 0 ? '+' : '';
|
||||
return `${prefix}$${pnl.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatPnLPercent(pct: number | null): string {
|
||||
if (pct === null) return '—';
|
||||
const prefix = pct >= 0 ? '+' : '';
|
||||
return `${prefix}${pct.toFixed(2)}%`;
|
||||
}
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
/** API functions for user watchlist management. */
|
||||
|
||||
export interface WatchlistItem {
|
||||
id: string;
|
||||
symbol_id: number;
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
label: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface SymbolOption {
|
||||
id: number;
|
||||
exchange_id: number;
|
||||
symbol: string;
|
||||
base: string;
|
||||
quote: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWatchlist(): Promise<WatchlistItem[]> {
|
||||
const res = await fetch('/api/v1/watchlist', { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch watchlist: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addToWatchlist(symbolId: number, label?: string): Promise<WatchlistItem> {
|
||||
const res = await fetch('/api/v1/watchlist', {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ symbol_id: symbolId, label: label || null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `Failed to add: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeFromWatchlist(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/watchlist/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to remove: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchAllSymbols(exchange = 'mexc', q = ''): Promise<SymbolOption[]> {
|
||||
const params = new URLSearchParams({ exchange });
|
||||
if (q) params.set('q', q);
|
||||
const res = await fetch(`/api/v1/watchlist/all-symbols?${params}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch symbols: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
type CandleCallback = (data: {
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
timeframe: string;
|
||||
timestamp: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}) => void;
|
||||
|
||||
type SignalCallback = (data: {
|
||||
id: number;
|
||||
symbol: string;
|
||||
signal_type: string;
|
||||
strength: string;
|
||||
price: number;
|
||||
timestamp: string;
|
||||
indicators_snapshot: string | null;
|
||||
}) => void;
|
||||
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = false;
|
||||
private candleCallback: CandleCallback | null = null;
|
||||
private signalCallback: SignalCallback | null = null;
|
||||
private subscriptions: Array<{ symbol: string; timeframe: string; exchange: string }> = [];
|
||||
private token = '';
|
||||
|
||||
connect(token: string): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
this.token = token;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.doConnect();
|
||||
}
|
||||
|
||||
private doConnect(): void {
|
||||
if (!this.token) return;
|
||||
|
||||
const wsUrl = `/ws/v1/candles?token=${encodeURIComponent(this.token)}`;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const fullUrl = `${protocol}//${window.location.host}${wsUrl}`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(fullUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
// Re-subscribe to all channels
|
||||
for (const sub of this.subscriptions) {
|
||||
this.sendSubscribe(sub);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'candle' && msg.data && this.candleCallback) {
|
||||
this.candleCallback(msg.data);
|
||||
}
|
||||
if (msg.type === 'signal' && msg.data && this.signalCallback) {
|
||||
this.signalCallback(msg.data);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror
|
||||
};
|
||||
} catch {
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.subscriptions = [];
|
||||
this.candleCallback = null;
|
||||
this.signalCallback = null;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
subscribe(symbol: string, timeframe: string, exchange: string): void {
|
||||
const sub = { symbol, timeframe, exchange };
|
||||
this.subscriptions.push(sub);
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.sendSubscribe(sub);
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe(symbol: string, timeframe: string, exchange: string): void {
|
||||
this.subscriptions = this.subscriptions.filter(
|
||||
(s) => !(s.symbol === symbol && s.timeframe === timeframe && s.exchange === exchange)
|
||||
);
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
action: 'unsubscribe',
|
||||
symbol,
|
||||
timeframe,
|
||||
exchange,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
onCandle(callback: CandleCallback): void {
|
||||
this.candleCallback = callback;
|
||||
}
|
||||
|
||||
onSignal(callback: SignalCallback): void {
|
||||
this.signalCallback = callback;
|
||||
}
|
||||
|
||||
private sendSubscribe(sub: { symbol: string; timeframe: string; exchange: string }): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
symbol: sub.symbol,
|
||||
timeframe: sub.timeframe,
|
||||
exchange: sub.exchange,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.shouldReconnect = false;
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (this.shouldReconnect) {
|
||||
this.doConnect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
export const wsService = new WebSocketService();
|
||||
export default WebSocketService;
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
import { useAppDispatch } from '../../app/hooks';
|
||||
import { loginThunk } from './authSlice';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useT } from '../../translations';
|
||||
|
||||
export default function LoginPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useT();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false); // P2-12
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true); // P2-12: show loading state
|
||||
try {
|
||||
await dispatch(loginThunk({ username, password })).unwrap();
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : t('login.error'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ maxWidth: '100vw', boxSizing: 'border-box' }}>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<h2>{t('login.subtitle')}</h2>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">{t('login.username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('login.username')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">{t('login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('login.password')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={isSubmitting} style={{ minHeight: 44, touchAction: 'manipulation' }}>
|
||||
{isSubmitting ? t('login.signing') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{/* Register temporarily hidden while features stabilize */}
|
||||
{/* <p className="auth-link"> */}
|
||||
{/* Don't have an account? <a href="/register">Register</a> */}
|
||||
{/* </p> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
import { useAppDispatch } from '../../app/hooks';
|
||||
import { registerThunk } from './authSlice';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(registerThunk({ username, email, password })).unwrap();
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ maxWidth: '100vw', boxSizing: 'border-box' }}>
|
||||
<h1>Trading Portal</h1>
|
||||
<h2>Create Account</h2>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-full" style={{ minHeight: 44, touchAction: 'manipulation' }}>
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-link">
|
||||
Already have an account? <a href="/login">Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { AuthState, User } from '../../types/trading';
|
||||
import * as api from '../api/apiService';
|
||||
|
||||
function loadInitialState(): AuthState {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
user: null,
|
||||
accessToken: token,
|
||||
refreshToken: localStorage.getItem('refresh_token'),
|
||||
isAuthenticated: !!token,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const loginThunk = createAsyncThunk(
|
||||
'auth/login',
|
||||
async ({ username, password }: { username: string; password: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const result = await api.login(username, password);
|
||||
localStorage.setItem('access_token', result.access_token);
|
||||
localStorage.setItem('refresh_token', result.refresh_token);
|
||||
// Fetch user info after login
|
||||
const user = await api.getMe();
|
||||
return { user, accessToken: result.access_token, refreshToken: result.refresh_token };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return rejectWithValue(err.message);
|
||||
}
|
||||
return rejectWithValue('Login failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const registerThunk = createAsyncThunk(
|
||||
'auth/register',
|
||||
async (
|
||||
{ username, email, password }: { username: string; email: string; password: string },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
await api.register(username, email, password);
|
||||
// Auto-login after registration
|
||||
const result = await api.login(username, password);
|
||||
localStorage.setItem('access_token', result.access_token);
|
||||
localStorage.setItem('refresh_token', result.refresh_token);
|
||||
const user = await api.getMe();
|
||||
return { user, accessToken: result.access_token, refreshToken: result.refresh_token };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return rejectWithValue(err.message);
|
||||
}
|
||||
return rejectWithValue('Registration failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchCurrentUser = createAsyncThunk(
|
||||
'auth/fetchCurrentUser',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
return user as User;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return rejectWithValue(err.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch user');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const logoutThunk = createAsyncThunk(
|
||||
'auth/logout',
|
||||
async () => {
|
||||
await api.logout();
|
||||
},
|
||||
);
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: loadInitialState(),
|
||||
reducers: {
|
||||
setCredentials(state, action: PayloadAction<{ user: User; accessToken: string; refreshToken: string }>) {
|
||||
state.user = action.payload.user;
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.isAuthenticated = true;
|
||||
state.error = null;
|
||||
},
|
||||
clearCredentials(state) {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.isAuthenticated = false;
|
||||
state.error = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
},
|
||||
clearError(state) {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Login
|
||||
.addCase(loginThunk.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginThunk.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.user = action.payload.user;
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.isAuthenticated = true;
|
||||
})
|
||||
.addCase(loginThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Login failed';
|
||||
})
|
||||
// Register
|
||||
.addCase(registerThunk.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(registerThunk.fulfilled, (state) => {
|
||||
state.isLoading = false;
|
||||
})
|
||||
.addCase(registerThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Registration failed';
|
||||
})
|
||||
// Fetch current user
|
||||
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
|
||||
state.user = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
})
|
||||
.addCase(fetchCurrentUser.rejected, (state) => {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.isAuthenticated = false;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
})
|
||||
// Logout
|
||||
.addCase(logoutThunk.fulfilled, (state) => {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.isAuthenticated = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCredentials, clearCredentials, clearError } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useT } from '../../translations';
|
||||
|
||||
interface SignalCounts {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
interface TradeInfo {
|
||||
direction: string;
|
||||
entry_price: number;
|
||||
exit_price: number;
|
||||
pnl: number;
|
||||
entry_signal: string;
|
||||
}
|
||||
|
||||
interface Trade {
|
||||
direction: string;
|
||||
entry_price: number;
|
||||
exit_price?: number;
|
||||
entry_time?: string;
|
||||
pnl?: number;
|
||||
entry_signal: string;
|
||||
exit_reason?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface BacktestResult {
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
timeframe: string;
|
||||
days: number;
|
||||
candles_count: number;
|
||||
signal_counts: SignalCounts;
|
||||
total_signals: number;
|
||||
recent_signals: Array<{ time: string; signal: string; strength: string; price: number }>;
|
||||
trades: {
|
||||
total: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
win_rate: number;
|
||||
total_pnl: number;
|
||||
profit_factor: number | null;
|
||||
avg_win: number | null;
|
||||
avg_loss: number | null;
|
||||
best_trade: TradeInfo | null;
|
||||
worst_trade: TradeInfo | null;
|
||||
per_signal: Record<string, { trades: number; wins: number; pnl: number }>;
|
||||
recent: Trade[];
|
||||
};
|
||||
}
|
||||
|
||||
const SIGNAL_ICONS: Record<string, string> = {
|
||||
'STRONG_BUY': '🚀', 'BUY': '📈',
|
||||
'STRONG_SELL': '🔻', 'SELL': '📉',
|
||||
'CAUTION_LONG': '⚠️', 'CAUTION_SHORT': '⚠️',
|
||||
'SQUEEZE_ALERT': '⚡',
|
||||
};
|
||||
|
||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h'];
|
||||
|
||||
export default function BacktestPage() {
|
||||
const { t } = useT();
|
||||
const [exchange, setExchange] = useState('binance');
|
||||
const [symbol, setSymbol] = useState('BTC/USDT');
|
||||
const [symbols, setSymbols] = useState<string[]>([]);
|
||||
const [timeframe, setTimeframe] = useState('30m');
|
||||
const [days, setDays] = useState(7);
|
||||
const [tradeSize, setTradeSize] = useState(10);
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Load symbols when exchange changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadSymbols() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/backtest/symbols?exchange=${encodeURIComponent(exchange)}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
const names = (data.symbols || []).map((s: any) => s.symbol);
|
||||
setSymbols(names);
|
||||
// Keep current symbol if in list, else pick first
|
||||
if (names.length > 0 && !names.includes(symbol)) {
|
||||
setSymbol(names[0]);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
loadSymbols();
|
||||
return () => { cancelled = true; };
|
||||
}, [exchange]);
|
||||
|
||||
const runBacktest = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [symbol, timeframe, days, tradeSize]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
<h1>📊 {t('Backtest')}</h1>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '20px', alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Exchange</label><br />
|
||||
<select value={exchange} onChange={e => setExchange(e.target.value)}
|
||||
style={selectStyle}>
|
||||
{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Symbol ({symbols.length})</label><br />
|
||||
<select value={symbol} onChange={e => setSymbol(e.target.value)}
|
||||
style={{ ...selectStyle, minWidth: '140px' }}>
|
||||
{symbols.length > 0
|
||||
? symbols.map(s => <option key={s} value={s}>{s}</option>)
|
||||
: <option value="">Loading...</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Timeframe</label><br />
|
||||
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} style={selectStyle}>
|
||||
{TIMEFRAMES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Days</label><br />
|
||||
<select value={days} onChange={e => setDays(Number(e.target.value))} style={selectStyle}>
|
||||
{[3, 7, 14, 30, 90, 180, 365].map(d => <option key={d} value={d}>{d}d</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Trade Size (USDT)</label><br />
|
||||
<input type="number" value={tradeSize} onChange={e => setTradeSize(Number(e.target.value))}
|
||||
style={{ ...selectStyle, width: '80px' }} min={1} />
|
||||
</div>
|
||||
<button onClick={runBacktest} disabled={loading}
|
||||
style={{
|
||||
padding: '8px 24px', background: '#2563eb', color: '#fff', border: 'none',
|
||||
borderRadius: '6px', cursor: loading ? 'wait' : 'pointer', fontWeight: 600,
|
||||
}}>
|
||||
{loading ? '⏳ Running...' : '▶ Run Backtest'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#ef4444', marginBottom: '16px' }}>❌ {error}</div>}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
|
||||
<SummaryCard label="Trades" value={result.trades.total.toString()} />
|
||||
<SummaryCard label="Win Rate" value={`${result.trades.win_rate}%`}
|
||||
color={result.trades.win_rate >= 50 ? '#22c55e' : '#ef4444'} />
|
||||
<SummaryCard label="Total PnL" value={`$${result.trades.total_pnl.toFixed(2)}`}
|
||||
color={result.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444'} />
|
||||
<SummaryCard label="Profit Factor" value={result.trades.profit_factor?.toFixed(2) ?? '∞'}
|
||||
color={result.trades.profit_factor && result.trades.profit_factor >= 1 ? '#22c55e' : '#ef4444'} />
|
||||
<SummaryCard label="Wins/Losses" value={`${result.trades.wins}W / ${result.trades.losses}L`} />
|
||||
<SummaryCard label="Candles" value={result.candles_count.toString()} />
|
||||
</div>
|
||||
|
||||
{/* Signal Breakdown */}
|
||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>📋 Signal Breakdown</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(result.signal_counts).map(([sig, count]) => {
|
||||
const icon = SIGNAL_ICONS[sig] || '❓';
|
||||
const sigTrades = result.trades.per_signal[sig] || { trades: 0, wins: 0, pnl: 0 };
|
||||
return (
|
||||
<div key={sig} style={{
|
||||
padding: '8px 14px', background: '#16213e', borderRadius: '6px',
|
||||
border: '1px solid #0f3460',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>
|
||||
{icon} {sig.replace('_', ' ')}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#aaa' }}>
|
||||
{count} signals → {sigTrades.trades} trades
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px', fontWeight: 600,
|
||||
color: sigTrades.pnl >= 0 ? '#22c55e' : '#ef4444'
|
||||
}}>
|
||||
${sigTrades.pnl.toFixed(2)} ({sigTrades.trades ? Math.round(sigTrades.wins / sigTrades.trades * 100) : 0}% WR)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Best/Worst Trades */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
|
||||
{result.trades.best_trade && (
|
||||
<div style={{ background: '#064e3b', borderRadius: '8px', padding: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#6ee7b7' }}>🏆 Best Trade</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</div>
|
||||
<div style={{ color: '#22c55e', fontWeight: 700 }}>+${result.trades.best_trade.pnl.toFixed(2)}</div>
|
||||
</div>
|
||||
)}
|
||||
{result.trades.worst_trade && (
|
||||
<div style={{ background: '#450a0a', borderRadius: '8px', padding: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#fca5a5' }}>💸 Worst Trade</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</div>
|
||||
<div style={{ color: '#ef4444', fontWeight: 700 }}>{result.trades.worst_trade.pnl.toFixed(2)}$</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Signals */}
|
||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Signals (last 15)</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #333' }}>
|
||||
<th style={thStyle}>Time</th>
|
||||
<th style={thStyle}>Signal</th>
|
||||
<th style={thStyle}>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.recent_signals.map((s, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={tdStyle}>{s.time.slice(5, 16)}</td>
|
||||
<td style={tdStyle}>
|
||||
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
|
||||
</td>
|
||||
<td style={tdStyle}>${s.price.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Trades */}
|
||||
{result.trades.recent.length > 0 && (
|
||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Trades (last 10)</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #333' }}>
|
||||
<th style={thStyle}>Time</th>
|
||||
<th style={thStyle}>Dir</th>
|
||||
<th style={thStyle}>Entry</th>
|
||||
<th style={thStyle}>Exit</th>
|
||||
<th style={thStyle}>PnL</th>
|
||||
<th style={thStyle}>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.trades.recent.slice().reverse().map((t, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={tdStyle}>{t.entry_time?.slice(5, 16) || '-'}</td>
|
||||
<td style={tdStyle}>{t.direction}</td>
|
||||
<td style={tdStyle}>${t.entry_price.toFixed(2)}</td>
|
||||
<td style={tdStyle}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ ...tdStyle, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>
|
||||
${t.pnl?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td style={tdStyle}>{t.exit_reason || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '6px 10px', borderRadius: '6px', border: '1px solid #333',
|
||||
background: '#1a1a2e', color: '#e0e0e0', fontSize: '14px',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left', padding: '8px 12px', color: '#888', fontWeight: 500,
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
};
|
||||
|
||||
function SummaryCard({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1a2e', borderRadius: '8px', padding: '14px',
|
||||
border: '1px solid #0f3460',
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>{label}</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700, color: color || '#e0e0e0' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
createChart,
|
||||
createSeriesMarkers,
|
||||
CrosshairMode,
|
||||
CandlestickSeries,
|
||||
HistogramSeries,
|
||||
LineSeries,
|
||||
ColorType,
|
||||
LineStyle,
|
||||
} from 'lightweight-charts';
|
||||
import type {
|
||||
IChartApi,
|
||||
ISeriesApi,
|
||||
CandlestickData,
|
||||
HistogramData,
|
||||
LineData,
|
||||
UTCTimestamp,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
interface Candle {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface MarketStructure {
|
||||
swing_highs: (number | null)[];
|
||||
swing_lows: (number | null)[];
|
||||
bos: string | null;
|
||||
choch: string | null;
|
||||
order_blocks: any[];
|
||||
trend: string;
|
||||
last_swing_high: number | null;
|
||||
last_swing_low: number | null;
|
||||
}
|
||||
|
||||
interface IndicatorResponse {
|
||||
sma_20?: number[];
|
||||
sma_50?: number[];
|
||||
ema_12?: number[];
|
||||
ema_26?: number[];
|
||||
rsi_14?: number[];
|
||||
stoch_rsi?: { k: number[]; d: number[] };
|
||||
macd?: { macd_line: number[]; signal_line: number[]; histogram: number[] };
|
||||
bollinger_bands?: {
|
||||
upper: number[];
|
||||
middle: number[];
|
||||
lower: number[];
|
||||
upper_1?: number[];
|
||||
lower_1?: number[];
|
||||
};
|
||||
vwap?: number[];
|
||||
market_structure?: MarketStructure;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
timeframe: string;
|
||||
onPriceUpdate?: (price: number | null) => void;
|
||||
}
|
||||
|
||||
export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpdate }: Props) {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const seriesRef = useRef<{
|
||||
candle?: ISeriesApi<'Candlestick'>;
|
||||
volume?: ISeriesApi<'Histogram'>;
|
||||
sma?: ISeriesApi<'Line'>;
|
||||
ema?: ISeriesApi<'Line'>;
|
||||
bbUpper2?: ISeriesApi<'Line'>;
|
||||
bbLower2?: ISeriesApi<'Line'>;
|
||||
bbUpper1?: ISeriesApi<'Line'>;
|
||||
bbLower1?: ISeriesApi<'Line'>;
|
||||
bbMiddle?: ISeriesApi<'Line'>;
|
||||
stochK?: ISeriesApi<'Line'>;
|
||||
stochD?: ISeriesApi<'Line'>;
|
||||
obLine?: ISeriesApi<'Line'>;
|
||||
osLine?: ISeriesApi<'Line'>;
|
||||
markersPlugin?: { setMarkers: (m: any[]) => void; detach: () => void };
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return;
|
||||
|
||||
// Use explicit dimensions from container (important for iOS Safari)
|
||||
const containerRect = chartContainerRef.current.getBoundingClientRect();
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: Math.max(containerRect.width, 400),
|
||||
height: Math.max(containerRect.height, 300),
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: '#0d1117' },
|
||||
textColor: '#c9d1d9',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#1a2332' },
|
||||
horzLines: { color: '#1a2332' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#30363d',
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#30363d',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
handleScroll: true,
|
||||
handleScale: true,
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
|
||||
// Reserve space for Stoch RSI + Volume panes below candlesticks
|
||||
chart.priceScale('right').applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0, // candlesticks start at top
|
||||
bottom: 0.45, // leave bottom 45% for Stoch RSI (25%) + Volume (20%)
|
||||
},
|
||||
});
|
||||
|
||||
// Candlestick series (main pane)
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderUpColor: '#26a69a',
|
||||
borderDownColor: '#ef5350',
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350',
|
||||
});
|
||||
|
||||
// Volume histogram (separate pane)
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: 'volume',
|
||||
});
|
||||
chart.priceScale('volume').applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.8,
|
||||
bottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// SMA line series
|
||||
const smaSeries = chart.addSeries(LineSeries, {
|
||||
color: '#f0b90b',
|
||||
lineWidth: 2,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'SMA 20',
|
||||
});
|
||||
|
||||
// EMA line series
|
||||
const emaSeries = chart.addSeries(LineSeries, {
|
||||
color: '#ff9800',
|
||||
lineWidth: 2,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'EMA 12',
|
||||
});
|
||||
|
||||
// Bollinger Bands: 2-sigma (red) & 1-sigma (yellow)
|
||||
const bbUpper2 = chart.addSeries(LineSeries, {
|
||||
color: '#ef5350',
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'BB 2σ Upper',
|
||||
});
|
||||
const bbLower2 = chart.addSeries(LineSeries, {
|
||||
color: '#ef5350',
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'BB 2σ Lower',
|
||||
});
|
||||
const bbUpper1 = chart.addSeries(LineSeries, {
|
||||
color: '#ffd54f',
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'BB 1σ Upper',
|
||||
});
|
||||
const bbLower1 = chart.addSeries(LineSeries, {
|
||||
color: '#ffd54f',
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'BB 1σ Lower',
|
||||
});
|
||||
const bbMiddle = chart.addSeries(LineSeries, {
|
||||
color: '#90caf9',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'BB Middle (SMA 20)',
|
||||
});
|
||||
|
||||
// Stoch RSI series (separate pane) — %K and %D lines
|
||||
const stochKSeries = chart.addSeries(LineSeries, {
|
||||
color: '#7c3aed',
|
||||
lineWidth: 2,
|
||||
priceScaleId: 'stoch_rsi',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'Stoch RSI %K',
|
||||
});
|
||||
const stochDSeries = chart.addSeries(LineSeries, {
|
||||
color: '#ff9800',
|
||||
lineWidth: 2,
|
||||
priceScaleId: 'stoch_rsi',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'Stoch RSI %D',
|
||||
});
|
||||
chart.priceScale('stoch_rsi').applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.55, // Stoch RSI in 55%-75% region
|
||||
bottom: 0.25, // leave bottom 25% for volume
|
||||
},
|
||||
});
|
||||
|
||||
// Overbought / Oversold lines for Stoch RSI
|
||||
const obLine = chart.addSeries(LineSeries, {
|
||||
color: '#ef5350',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
priceScaleId: 'stoch_rsi',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'Overbought 80',
|
||||
});
|
||||
const osLine = chart.addSeries(LineSeries, {
|
||||
color: '#26a69a',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
priceScaleId: 'stoch_rsi',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: 'Oversold 20',
|
||||
});
|
||||
|
||||
seriesRef.current = {
|
||||
candle: candleSeries as ISeriesApi<'Candlestick'>,
|
||||
volume: volumeSeries as ISeriesApi<'Histogram'>,
|
||||
sma: smaSeries as ISeriesApi<'Line'>,
|
||||
ema: emaSeries as ISeriesApi<'Line'>,
|
||||
bbUpper2: bbUpper2 as ISeriesApi<'Line'>,
|
||||
bbLower2: bbLower2 as ISeriesApi<'Line'>,
|
||||
bbUpper1: bbUpper1 as ISeriesApi<'Line'>,
|
||||
bbLower1: bbLower1 as ISeriesApi<'Line'>,
|
||||
bbMiddle: bbMiddle as ISeriesApi<'Line'>,
|
||||
stochK: stochKSeries as ISeriesApi<'Line'>,
|
||||
stochD: stochDSeries as ISeriesApi<'Line'>,
|
||||
obLine: obLine as ISeriesApi<'Line'>,
|
||||
osLine: osLine as ISeriesApi<'Line'>,
|
||||
};
|
||||
|
||||
// Resize observer
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
chart.applyOptions({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(chartContainerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (seriesRef.current.markersPlugin) {
|
||||
try { seriesRef.current.markersPlugin.detach(); } catch {}
|
||||
}
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch candles & indicators on symbol/exchange/timeframe change
|
||||
useEffect(() => {
|
||||
if (!symbol || !exchange || !timeframe) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Fetch candles — use query params to avoid encoding issues
|
||||
const candleRes = await fetch(
|
||||
`/api/v1/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
|
||||
{ headers }
|
||||
);
|
||||
if (!candleRes.ok) throw new Error(`Candle fetch error: ${candleRes.status}`);
|
||||
const candleResponse = await candleRes.json();
|
||||
// API returns {candles: [...], cursor: "...", has_more: bool} — unwrap it
|
||||
const rawCandles: any[] = candleResponse.candles || candleResponse || [];
|
||||
// Reverse so oldest candle comes first (lightweight-charts needs ASC order)
|
||||
rawCandles.reverse();
|
||||
const candleData: Candle[] = rawCandles.map((c: any) => ({
|
||||
time: Math.floor(new Date(c.timestamp).getTime() / 1000) as UTCTimestamp,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
volume: c.volume,
|
||||
}));
|
||||
|
||||
// Update candle series
|
||||
const candleSeries = seriesRef.current.candle;
|
||||
if (candleSeries && candleData.length > 0) {
|
||||
const candlestickData: CandlestickData<UTCTimestamp>[] = candleData.map((c) => ({
|
||||
time: c.time as UTCTimestamp,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
}));
|
||||
candleSeries.setData(candlestickData);
|
||||
|
||||
// Notify parent of latest close price
|
||||
const latest = candlestickData[candlestickData.length - 1];
|
||||
if (onPriceUpdate) {
|
||||
onPriceUpdate(latest.close);
|
||||
}
|
||||
}
|
||||
|
||||
// Update volume series
|
||||
const volumeSeries = seriesRef.current.volume;
|
||||
if (volumeSeries && candleData.length > 0) {
|
||||
const volumeData: HistogramData<UTCTimestamp>[] = candleData.map((c) => ({
|
||||
time: c.time as UTCTimestamp,
|
||||
value: c.volume,
|
||||
color: c.close >= c.open ? '#26a69a66' : '#ef535066',
|
||||
}));
|
||||
volumeSeries.setData(volumeData);
|
||||
}
|
||||
|
||||
// Fetch indicators
|
||||
const indicatorsRes = await fetch(
|
||||
`/api/v1/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
|
||||
{ headers }
|
||||
);
|
||||
if (!indicatorsRes.ok) throw new Error(`Indicator fetch error: ${indicatorsRes.status}`);
|
||||
const indicatorData: IndicatorResponse = await indicatorsRes.json();
|
||||
|
||||
// Helper to build time-annotated line data from indicator array
|
||||
function buildLineData(values: number[], times: Candle[]): LineData<UTCTimestamp>[] {
|
||||
const sliced = values.slice(-times.length);
|
||||
const result: LineData<UTCTimestamp>[] = [];
|
||||
for (let i = 0; i < sliced.length; i++) {
|
||||
const val = sliced[i];
|
||||
if (val !== null && val !== undefined) {
|
||||
result.push({ time: times[i].time as UTCTimestamp, value: val });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// SMA
|
||||
const smaSeries = seriesRef.current.sma;
|
||||
if (smaSeries && indicatorData.sma_20 && candleData.length > 0) {
|
||||
smaSeries.setData(buildLineData(indicatorData.sma_20, candleData));
|
||||
}
|
||||
|
||||
// EMA
|
||||
const emaSeries = seriesRef.current.ema;
|
||||
if (emaSeries && indicatorData.ema_12 && candleData.length > 0) {
|
||||
emaSeries.setData(buildLineData(indicatorData.ema_12, candleData));
|
||||
}
|
||||
|
||||
// Bollinger Bands (double BB)
|
||||
const bb = indicatorData.bollinger_bands;
|
||||
if (bb && candleData.length > 0) {
|
||||
const bbUpper2 = seriesRef.current.bbUpper2;
|
||||
const bbLower2 = seriesRef.current.bbLower2;
|
||||
const bbUpper1 = seriesRef.current.bbUpper1;
|
||||
const bbLower1 = seriesRef.current.bbLower1;
|
||||
const bbMiddle = seriesRef.current.bbMiddle;
|
||||
|
||||
if (bbUpper2) bbUpper2.setData(buildLineData(bb.upper, candleData));
|
||||
if (bbLower2) bbLower2.setData(buildLineData(bb.lower, candleData));
|
||||
if (bbMiddle) bbMiddle.setData(buildLineData(bb.middle, candleData));
|
||||
if (bbUpper1 && bb.upper_1) bbUpper1.setData(buildLineData(bb.upper_1, candleData));
|
||||
if (bbLower1 && bb.lower_1) bbLower1.setData(buildLineData(bb.lower_1, candleData));
|
||||
}
|
||||
|
||||
// Stoch RSI
|
||||
const stochKSeries = seriesRef.current.stochK;
|
||||
const stochDSeries = seriesRef.current.stochD;
|
||||
const obLine = seriesRef.current.obLine;
|
||||
const osLine = seriesRef.current.osLine;
|
||||
const srsi = indicatorData.stoch_rsi;
|
||||
if (stochKSeries && stochDSeries && srsi && srsi.k && srsi.d && candleData.length > 0) {
|
||||
const kData = buildLineData(srsi.k, candleData);
|
||||
const dData = buildLineData(srsi.d, candleData);
|
||||
stochKSeries.setData(kData);
|
||||
stochDSeries.setData(dData);
|
||||
|
||||
// Set overbought/oversold lines at constant 80/20
|
||||
if (kData.length > 0 && obLine && osLine) {
|
||||
const obData: LineData<UTCTimestamp>[] = kData.map((d) => ({
|
||||
time: d.time,
|
||||
value: 80,
|
||||
}));
|
||||
const osData: LineData<UTCTimestamp>[] = kData.map((d) => ({
|
||||
time: d.time,
|
||||
value: 20,
|
||||
}));
|
||||
obLine.setData(obData);
|
||||
osLine.setData(osData);
|
||||
}
|
||||
}
|
||||
|
||||
// Fit content
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
|
||||
// SMC: Market Structure markers on candles
|
||||
const ms = indicatorData.market_structure;
|
||||
if (ms && seriesRef.current.candle && candleData.length > 0) {
|
||||
const smcCandleSeries = seriesRef.current.candle!;
|
||||
const smcMarkers: any[] = [];
|
||||
|
||||
// Swing Highs (red arrow above candle)
|
||||
const sh_aligned = ms.swing_highs.slice(-candleData.length);
|
||||
for (let i = 0; i < sh_aligned.length; i++) {
|
||||
if (sh_aligned[i] !== null) {
|
||||
smcMarkers.push({
|
||||
time: candleData[i].time as UTCTimestamp,
|
||||
position: 'aboveBar' as const,
|
||||
color: '#ef5350',
|
||||
shape: 'arrowDown' as const,
|
||||
size: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Swing Lows (green arrow below candle)
|
||||
const sl_aligned = ms.swing_lows.slice(-candleData.length);
|
||||
for (let i = 0; i < sl_aligned.length; i++) {
|
||||
if (sl_aligned[i] !== null) {
|
||||
smcMarkers.push({
|
||||
time: candleData[i].time as UTCTimestamp,
|
||||
position: 'belowBar' as const,
|
||||
color: '#26a69a',
|
||||
shape: 'arrowUp' as const,
|
||||
size: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!seriesRef.current.markersPlugin) {
|
||||
seriesRef.current.markersPlugin = createSeriesMarkers(smcCandleSeries, smcMarkers, {});
|
||||
} else {
|
||||
seriesRef.current.markersPlugin.setMarkers(smcMarkers);
|
||||
}
|
||||
|
||||
// Log trend + BOS/CHoCH to console for reference
|
||||
if (ms.trend !== 'NEUTRAL') {
|
||||
const bosText = ms.bos ? ` BOS:${ms.bos}` : '';
|
||||
const chochText = ms.choch ? ` CHoCH:${ms.choch}` : '';
|
||||
console.log(`[SMC] ${symbol} trend=${ms.trend}${bosText}${chochText}`);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('fetchData error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [symbol, exchange, timeframe, onPriceUpdate]);
|
||||
|
||||
// WebSocket real-time updates
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
|
||||
import('../api/websocketService').then(({ wsService }) => {
|
||||
wsService.connect(token);
|
||||
|
||||
wsService.onCandle((data) => {
|
||||
// Only process candles for the current symbol/exchange/timeframe
|
||||
if (data.symbol !== symbol || data.exchange !== exchange || data.timeframe !== timeframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candleSeries = seriesRef.current.candle;
|
||||
const volumeSeries = seriesRef.current.volume;
|
||||
|
||||
if (candleSeries) {
|
||||
const time = Math.floor(new Date(data.timestamp).getTime() / 1000) as UTCTimestamp;
|
||||
candleSeries.update({
|
||||
time,
|
||||
open: data.open,
|
||||
high: data.high,
|
||||
low: data.low,
|
||||
close: data.close,
|
||||
});
|
||||
|
||||
if (onPriceUpdate) {
|
||||
onPriceUpdate(data.close);
|
||||
}
|
||||
}
|
||||
|
||||
if (volumeSeries) {
|
||||
const time = Math.floor(new Date(data.timestamp).getTime() / 1000) as UTCTimestamp;
|
||||
volumeSeries.update({
|
||||
time,
|
||||
value: data.volume,
|
||||
color: data.close >= data.open ? '#26a69a66' : '#ef535066',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
wsService.subscribe(symbol, timeframe, exchange);
|
||||
});
|
||||
|
||||
return () => {
|
||||
import('../api/websocketService').then(({ wsService }) => {
|
||||
wsService.unsubscribe(symbol, timeframe, exchange);
|
||||
});
|
||||
};
|
||||
}, [symbol, exchange, timeframe, onPriceUpdate]);
|
||||
|
||||
return (
|
||||
<div style={styles.wrapper}>
|
||||
{loading && <div style={styles.overlay}>Loading chart data...</div>}
|
||||
{error && <div style={styles.errorOverlay}>Error: {error}</div>}
|
||||
<div ref={chartContainerRef} style={styles.chartContainer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
wrapper: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
chartContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#8b949e',
|
||||
fontSize: 14,
|
||||
zIndex: 10,
|
||||
background: '#0d1117cc',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
},
|
||||
errorOverlay: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#ef5350',
|
||||
fontSize: 14,
|
||||
zIndex: 10,
|
||||
background: '#0d1117cc',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
},
|
||||
};
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Exchange {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface SymbolData {
|
||||
id: number;
|
||||
symbol: string;
|
||||
base: string;
|
||||
quote: string;
|
||||
active: boolean;
|
||||
exchange_id: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
exchanges: Exchange[];
|
||||
symbol: string;
|
||||
exchange: string;
|
||||
timeframe: string;
|
||||
onSymbolChange: (symbol: string) => void;
|
||||
onExchangeChange: (exchange: string) => void;
|
||||
onTimeframeChange: (timeframe: string) => void;
|
||||
}
|
||||
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
||||
|
||||
export default function ChartToolbar({
|
||||
exchanges,
|
||||
symbol,
|
||||
exchange,
|
||||
timeframe,
|
||||
onSymbolChange,
|
||||
onExchangeChange,
|
||||
onTimeframeChange,
|
||||
}: Props) {
|
||||
const [symbols, setSymbols] = useState<SymbolData[]>([]);
|
||||
const [loadingSymbols, setLoadingSymbols] = useState(false);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Fetch symbols when exchange changes
|
||||
useEffect(() => {
|
||||
if (exchange) {
|
||||
fetchSymbols(exchange);
|
||||
}
|
||||
}, [exchange]);
|
||||
|
||||
async function fetchSymbols(exch: string) {
|
||||
setLoadingSymbols(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/symbols?exchange=${exch}&active_only=true`,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` } }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch symbols');
|
||||
const data: SymbolData[] = await res.json();
|
||||
setSymbols(data);
|
||||
} catch (err) {
|
||||
console.error('fetchSymbols error:', err);
|
||||
} finally {
|
||||
setLoadingSymbols(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...styles.toolbar, gap: isMobile ? 8 : 16 }}>
|
||||
{/* Exchange + Symbol row */}
|
||||
<div style={{ ...styles.selectRow, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 6 : 12 }}>
|
||||
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
|
||||
<label style={styles.label}>Exchange</label>
|
||||
<select
|
||||
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
|
||||
value={exchange}
|
||||
onChange={(e) => onExchangeChange(e.target.value)}
|
||||
>
|
||||
{exchanges.map((ex) => (
|
||||
<option key={ex.name} value={ex.name.toLowerCase()}>
|
||||
{ex.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
|
||||
<label style={styles.label}>Symbol</label>
|
||||
<select
|
||||
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
|
||||
value={symbol}
|
||||
onChange={(e) => onSymbolChange(e.target.value)}
|
||||
disabled={loadingSymbols}
|
||||
>
|
||||
{symbols.map((s) => (
|
||||
<option key={s.id} value={s.symbol}>
|
||||
{s.symbol}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeframe - horizontal scroll on mobile */}
|
||||
<div style={{ ...styles.timeframeSection, flex: isMobile ? 'none' : 1 }}>
|
||||
<label style={styles.label}>TF</label>
|
||||
<div style={styles.timeframeScroll} className="scrollable-x">
|
||||
<div style={styles.timeframeGroup}>
|
||||
{TIMEFRAMES.map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
style={{
|
||||
...styles.timeframeButton,
|
||||
...(tf === timeframe ? styles.timeframeActive : {}),
|
||||
padding: isMobile ? '2px 6px' : '3px 8px',
|
||||
fontSize: isMobile ? 10 : 11,
|
||||
}}
|
||||
onClick={() => onTimeframeChange(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '6px 16px',
|
||||
background: '#161b22',
|
||||
borderBottom: '1px solid #30363d',
|
||||
flexWrap: 'wrap',
|
||||
// Responsive: on small screens, items stack
|
||||
flexDirection: 'row' as const,
|
||||
},
|
||||
selectRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
group: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
select: {
|
||||
padding: '3px 8px',
|
||||
fontSize: 11,
|
||||
color: '#c9d1d9',
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
minWidth: 100,
|
||||
maxWidth: 140,
|
||||
height: 24,
|
||||
boxSizing: 'border-box' as const,
|
||||
lineHeight: '16px',
|
||||
},
|
||||
timeframeSection: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
timeframeScroll: {
|
||||
overflowX: 'auto' as const,
|
||||
overflowY: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch' as any,
|
||||
scrollBehavior: 'smooth',
|
||||
paddingBottom: 2,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
timeframeGroup: {
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
timeframeButton: {
|
||||
padding: '3px 6px',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
minWidth: 32,
|
||||
textAlign: 'center' as const,
|
||||
height: 24,
|
||||
lineHeight: '16px',
|
||||
boxSizing: 'border-box' as const,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
timeframeActive: {
|
||||
color: '#f0f6fc',
|
||||
background: '#1f6feb',
|
||||
borderColor: '#1f6feb',
|
||||
},
|
||||
};
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { logoutThunk } from '../auth/authSlice';
|
||||
import { fetchCurrentUser } from '../auth/authSlice';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { useT } from '../../translations';
|
||||
import ChartContainer from './ChartContainer';
|
||||
import ChartToolbar from './ChartToolbar';
|
||||
import WatchlistPanel from './WatchlistPanel';
|
||||
import OrderPanel from './OrderPanel';
|
||||
import SignalPanel from './SignalPanel';
|
||||
import { DashboardSkeleton } from '../../components/Skeleton'; // P3-8
|
||||
|
||||
// ── Types matching backend API ──
|
||||
interface Exchange {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface BalanceData {
|
||||
asset: string;
|
||||
free: number;
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface BalanceResponse {
|
||||
exchange: string;
|
||||
balances: BalanceData[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ── Styles (kept from original) ──
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: { minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' },
|
||||
nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 },
|
||||
grid: { display: 'grid', gridTemplateColumns: '1fr 340px', gap: 0, height: 'calc(100vh - 44px)' },
|
||||
sidePanel: { background: '#0d1117', borderLeft: '1px solid #30363d', display: 'flex', flexDirection: 'column', overflow: 'hidden' },
|
||||
mainContent: { display: 'flex', flexDirection: 'column', overflow: 'hidden' },
|
||||
btn: { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 },
|
||||
activeBtn: { padding: '6px 16px', fontSize: 12, borderRadius: 6, background: '#1f6feb', color: '#fff', border: '1px solid #1f6feb', cursor: 'pointer' },
|
||||
sectionTitle: { fontSize: 12, fontWeight: 600, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
// Chart state — use user's default exchange from preferences
|
||||
const defaultExchange = user?.preferences?.default_exchange || 'mexc';
|
||||
const [symbol, setSymbol] = useState('BTC/USDT');
|
||||
const [exchange, setExchange] = useState(defaultExchange);
|
||||
const [timeframe, setTimeframe] = useState(user?.preferences?.default_timeframe || '1h');
|
||||
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||
const [lastPrice, setLastPrice] = useState<number | null>(null);
|
||||
|
||||
// Balance state
|
||||
const [balance, setBalance] = useState<BalanceData[] | null>(null);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidePanel, setShowSidePanel] = useState(false);
|
||||
const [showNavDropdown, setShowNavDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
if (!mobile) setShowSidePanel(false);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Auth guard + fetch user on refresh
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) { navigate('/login', { replace: true }); return; }
|
||||
if (!user) {
|
||||
dispatch(fetchCurrentUser())
|
||||
.unwrap()
|
||||
.catch(() => navigate('/login', { replace: true }));
|
||||
return;
|
||||
}
|
||||
setInitializing(false);
|
||||
}, [isAuthenticated, user, dispatch, navigate]);
|
||||
|
||||
// Fetch available exchanges for ChartToolbar
|
||||
useEffect(() => {
|
||||
const loadExchanges = async () => {
|
||||
try {
|
||||
const data = await apiFetch<Exchange[]>('/exchanges');
|
||||
setExchanges(data);
|
||||
if (data.length > 0) {
|
||||
// Use user's saved default exchange, or fall back to first available
|
||||
const savedExchange = user?.preferences?.default_exchange;
|
||||
const match = savedExchange
|
||||
? data.find((e: Exchange) => e.name.toLowerCase() === savedExchange.toLowerCase())
|
||||
: null;
|
||||
setExchange(match ? match.name.toLowerCase() : data[0].name.toLowerCase());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load exchanges:', err);
|
||||
}
|
||||
};
|
||||
loadExchanges();
|
||||
}, []);
|
||||
|
||||
// Fetch real balance for current exchange
|
||||
useEffect(() => {
|
||||
if (!exchange) return;
|
||||
const loadBalance = async () => {
|
||||
try {
|
||||
const data = await apiFetch<BalanceResponse>(`/orders/balance?exchange_name=${exchange}`);
|
||||
setBalance(data.balances);
|
||||
} catch (err) {
|
||||
console.error('Failed to load balance (may not have API key):', err);
|
||||
setBalance(null);
|
||||
}
|
||||
};
|
||||
loadBalance();
|
||||
}, [exchange]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (initializing || isLoading) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
const userName = user?.username || '?';
|
||||
const { t } = useT();
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
{/* ─── NAV ─── */}
|
||||
<nav style={styles.nav}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }} onClick={() => navigate('/')}>
|
||||
<span style={{ fontSize: 18 }}>📊</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>{t('nav.tradingPortal')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Hamburger: toggle side panel */}
|
||||
<button
|
||||
onClick={() => { setShowSidePanel(!showSidePanel); setShowNavDropdown(false); }}
|
||||
style={{
|
||||
...styles.btn,
|
||||
fontSize: 18,
|
||||
padding: '8px 14px',
|
||||
lineHeight: 1,
|
||||
minHeight: 36,
|
||||
minWidth: 36,
|
||||
touchAction: 'manipulation',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title={t('nav.panelToggle')}
|
||||
>
|
||||
{showSidePanel ? '✕' : '☰'}
|
||||
</button>
|
||||
{/* Dropdown toggle for nav links */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowNavDropdown(!showNavDropdown)}
|
||||
style={{
|
||||
...styles.btn,
|
||||
fontSize: 16,
|
||||
padding: '8px 14px',
|
||||
lineHeight: 1,
|
||||
minHeight: 36,
|
||||
minWidth: 36,
|
||||
touchAction: 'manipulation',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
background: showNavDropdown ? '#1f6feb' : '#21262d',
|
||||
borderColor: showNavDropdown ? '#1f6feb' : '#30363d',
|
||||
}}
|
||||
title="Menu"
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>☰</span>
|
||||
<span style={{ fontSize: 10 }}>▼</span>
|
||||
</button>
|
||||
{showNavDropdown && (
|
||||
<>
|
||||
{/* Backdrop to close on outside click */}
|
||||
<div
|
||||
onClick={() => setShowNavDropdown(false)}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
zIndex: 90, background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0,
|
||||
marginTop: 4, zIndex: 100,
|
||||
background: '#161b22', border: '1px solid #30363d',
|
||||
borderRadius: 8, padding: 4,
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
minWidth: 160,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
|
||||
onClick={() => { navigate('/profile'); setShowNavDropdown(false); }}
|
||||
>
|
||||
{t('nav.profile', { name: userName })}
|
||||
</button>
|
||||
{(user?.role === 'admin' || user?.is_admin) && (
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
|
||||
onClick={() => { navigate('/admin'); setShowNavDropdown(false); }}
|
||||
>
|
||||
🛡️ Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
|
||||
onClick={() => { navigate('/analytics'); setShowNavDropdown(false); }}
|
||||
>
|
||||
📈 Analytics
|
||||
</button>
|
||||
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%', color: '#f85149' }}
|
||||
onClick={async () => {
|
||||
setShowNavDropdown(false);
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/profile')}>
|
||||
{t('nav.profile', { name: userName })}
|
||||
</button>
|
||||
{(user?.role === 'admin' || user?.is_admin) && (
|
||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/admin')}>
|
||||
🛡️ Admin
|
||||
</button>
|
||||
)}
|
||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/analytics')}>
|
||||
📈 Analytics
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...styles.btn,
|
||||
touchAction: 'manipulation',
|
||||
minHeight: isMobile ? 36 : undefined,
|
||||
color: '#f85149',
|
||||
borderColor: '#f8514933',
|
||||
}}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ─── GRID LAYOUT: 1fr (main) | 280px (side) ─── */}
|
||||
<div style={{ ...styles.grid, gridTemplateColumns: isMobile ? '1fr' : '1fr 340px' }}>
|
||||
{/* ═══ MAIN CONTENT (LEFT) ═══ */}
|
||||
<div style={styles.mainContent}>
|
||||
{/* Toolbar: exchange / symbol / timeframe selectors */}
|
||||
<ChartToolbar
|
||||
exchanges={exchanges}
|
||||
symbol={symbol}
|
||||
exchange={exchange}
|
||||
timeframe={timeframe}
|
||||
onSymbolChange={setSymbol}
|
||||
onExchangeChange={setExchange}
|
||||
onTimeframeChange={setTimeframe}
|
||||
/>
|
||||
|
||||
{/* Chart area: fills remaining vertical space */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{symbol ? (
|
||||
<ChartContainer
|
||||
symbol={symbol}
|
||||
exchange={exchange}
|
||||
timeframe={timeframe}
|
||||
onPriceUpdate={setLastPrice}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#484f58',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
Select a symbol from watchlist
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signal panel: below chart, only when symbol selected */}
|
||||
{symbol && (
|
||||
<div style={{
|
||||
borderTop: '1px solid #30363d',
|
||||
height: isMobile ? 200 : 280,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<SignalPanel symbol={symbol} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ SIDE PANEL (RIGHT) ═══ */}
|
||||
{!isMobile && (
|
||||
<div style={styles.sidePanel}>
|
||||
{/* Watchlist at top of side panel */}
|
||||
<WatchlistPanel
|
||||
currentSymbol={symbol}
|
||||
onSymbolChange={setSymbol}
|
||||
exchange={exchange}
|
||||
/>
|
||||
|
||||
{/* Order panel below watchlist */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
|
||||
{user?.role === 'viewer' ? (
|
||||
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
|
||||
👁️ View-only mode — trading is disabled
|
||||
</div>
|
||||
) : (
|
||||
<OrderPanel
|
||||
symbol={symbol || '—'}
|
||||
lastPrice={lastPrice}
|
||||
balance={balance}
|
||||
isConnected={balance !== null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ MOBILE SIDE PANEL (overlay slide-in) ═══ */}
|
||||
{isMobile && showSidePanel && (
|
||||
<>
|
||||
<div className="dashboard-side-overlay" onClick={() => setShowSidePanel(false)} />
|
||||
<div className="dashboard-side-panel-mobile">
|
||||
<WatchlistPanel
|
||||
currentSymbol={symbol}
|
||||
onSymbolChange={(s) => { setSymbol(s); setShowSidePanel(false); }}
|
||||
exchange={exchange}
|
||||
/>
|
||||
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
|
||||
{user?.role === 'viewer' ? (
|
||||
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
|
||||
👁️ View-only mode — trading is disabled
|
||||
</div>
|
||||
) : (
|
||||
<OrderPanel
|
||||
symbol={symbol || '—'}
|
||||
lastPrice={lastPrice}
|
||||
balance={balance}
|
||||
isConnected={balance !== null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { useAppSelector } from '../../app/hooks';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
lastPrice: number | null;
|
||||
balance: {asset: string; free: number; used: number; total: number}[] | null;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
// Format price: fewer decimals for very small prices to avoid overflow
|
||||
function formatPrice(price: number): string {
|
||||
if (price === 0) return '$0.00';
|
||||
const abs = Math.abs(price);
|
||||
// < 0.0001: show 8 decimals (scientific-style)
|
||||
if (abs < 0.0001) return '$' + price.toFixed(8);
|
||||
// < 0.01: show 6 decimals
|
||||
if (abs < 0.01) return '$' + price.toFixed(6);
|
||||
// < 1: show 4 decimals
|
||||
if (abs < 1) return '$' + price.toFixed(4);
|
||||
// normal: 2 decimals
|
||||
return '$' + price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function OrderPanel({ symbol, lastPrice, balance, isConnected }: Props) {
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
const defaultTradeSize = user?.preferences?.trade_size || 10;
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [amount, setAmount] = useState(String(defaultTradeSize));
|
||||
const [orderLoading, setOrderLoading] = useState(false);
|
||||
const [orderError, setOrderError] = useState<string | null>(null);
|
||||
const [orderSuccess, setOrderSuccess] = useState<string | null>(null);
|
||||
// P1-21: Confirmation state before placing trade
|
||||
const [pendingOrder, setPendingOrder] = useState<{ side: 'buy' | 'sell'; amount: number } | null>(null);
|
||||
|
||||
// Parse symbol: "BTC/USDT" or "BTCUSDT" → base="BTC", quote="USDT"
|
||||
const slashIdx = symbol.indexOf('/');
|
||||
const baseToken = slashIdx >= 0 ? symbol.slice(0, slashIdx) : symbol.replace(/USDT|USD|BUSD|USDC$/, '');
|
||||
const quoteToken = slashIdx >= 0 ? symbol.slice(slashIdx + 1) : 'USDT';
|
||||
|
||||
// Price change direction tracking
|
||||
const prevPrice = useRef<number | null>(null);
|
||||
const [priceDirection, setPriceDirection] = useState<'up' | 'down' | 'same' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastPrice !== null && prevPrice.current !== null && prevPrice.current !== lastPrice) {
|
||||
if (lastPrice > prevPrice.current) {
|
||||
setPriceDirection('up');
|
||||
} else if (lastPrice < prevPrice.current) {
|
||||
setPriceDirection('down');
|
||||
}
|
||||
} else if (lastPrice !== null && prevPrice.current === null) {
|
||||
setPriceDirection(null);
|
||||
}
|
||||
if (lastPrice !== null) {
|
||||
prevPrice.current = lastPrice;
|
||||
}
|
||||
}, [lastPrice]);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Auto-update clock every second
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const placeOrder = async (side: 'buy' | 'sell') => {
|
||||
const amt = parseFloat(amount);
|
||||
if (!amount || isNaN(amt) || amt <= 0) {
|
||||
setOrderError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
// P1-21: Require confirmation before placing real trade
|
||||
setPendingOrder({ side, amount: amt });
|
||||
};
|
||||
|
||||
const confirmOrder = async () => {
|
||||
if (!pendingOrder) return;
|
||||
const { side, amount: amt } = pendingOrder;
|
||||
setPendingOrder(null);
|
||||
setOrderLoading(true);
|
||||
setOrderError(null);
|
||||
setOrderSuccess(null);
|
||||
|
||||
try {
|
||||
const data = await apiFetch<any>('/orders/place', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
symbol,
|
||||
side,
|
||||
order_type: 'market',
|
||||
amount: amt,
|
||||
}),
|
||||
});
|
||||
setOrderSuccess(`${side === 'buy' ? 'Buy' : 'Sell'} order placed! ID: ${data.id}`);
|
||||
setAmount('');
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Failed to place order';
|
||||
setOrderError(msg);
|
||||
} finally {
|
||||
setOrderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const btnCursor = isConnected ? 'pointer' : 'not-allowed';
|
||||
const btnOpacity = isConnected ? 1 : 0.5;
|
||||
|
||||
return (
|
||||
<div style={{ ...styles.panel, padding: isMobile ? '8px 12px' : '16px 20px' }}>
|
||||
{/* Symbol info */}
|
||||
<div style={styles.symbolSection}>
|
||||
<h2 style={{ ...styles.symbolTitle, fontSize: isMobile ? 18 : 22 }}>{symbol}</h2>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 8 }}>
|
||||
Ordering <strong style={{ color: '#8b949e' }}>{baseToken}</strong> with <strong style={{ color: '#8b949e' }}>{quoteToken}</strong>
|
||||
</div>
|
||||
<div style={styles.priceRow}>
|
||||
<span style={styles.priceLabel}>Last Price</span>
|
||||
{lastPrice !== null ? (
|
||||
<span style={{
|
||||
...styles.priceValue,
|
||||
fontSize: isMobile ? 15 : 18,
|
||||
color: priceDirection === 'up' ? '#26a69a' : priceDirection === 'down' ? '#ef5350' : '#26a69a',
|
||||
}}>
|
||||
{priceDirection === 'up' ? '▲ ' : priceDirection === 'down' ? '▼ ' : '● '}
|
||||
{formatPrice(lastPrice)}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ ...styles.priceLoading, fontSize: isMobile ? 13 : 14 }}>Loading price...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time + Balance row (50/50) - stacks on mobile */}
|
||||
<div style={{ ...styles.infoRow, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<div style={styles.infoBlock}>
|
||||
<h3 style={styles.sectionTitle}>Time</h3>
|
||||
<div style={{ ...styles.clock, fontSize: isMobile ? 14 : 15 }}>{currentTime.toLocaleTimeString()}</div>
|
||||
<div style={styles.date}>
|
||||
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...styles.infoDivider, width: isMobile ? 'auto' : 1, height: isMobile ? 1 : 'auto', margin: isMobile ? '6px 0' : 0 }} />
|
||||
<div style={styles.infoBlock}>
|
||||
<h3 style={styles.sectionTitle}>Account</h3>
|
||||
<div style={{
|
||||
...styles.balanceValue,
|
||||
fontSize: isMobile ? 14 : 15,
|
||||
color: (balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? '#3fb950' : '#8b949e',
|
||||
}}>
|
||||
${(balance?.find(b => b.asset === 'USDT')?.total ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<div style={styles.balanceLabel}>Available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Order */}
|
||||
<div style={styles.section}>
|
||||
<h3 style={styles.sectionTitle}>Quick Order · {baseToken}/{quoteToken}</h3>
|
||||
|
||||
{/* Amount input */}
|
||||
<div style={styles.inputRow}>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
placeholder={`Amount in ${quoteToken}`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ ...styles.amountInput, padding: isMobile ? '8px 10px' : '10px 12px', fontSize: isMobile ? 13 : 14 }}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginTop: 4, fontStyle: 'italic' }}>
|
||||
{quoteToken} amount · Buy = spend {quoteToken} · Sell = sell {baseToken}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buy / Sell buttons */}
|
||||
<div style={styles.orderButtons}>
|
||||
<button
|
||||
style={{ ...styles.buyButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
|
||||
disabled={!isConnected || orderLoading}
|
||||
onClick={() => placeOrder('buy')}
|
||||
>
|
||||
{orderLoading ? 'Placing...' : `Buy ${baseToken}`}
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.sellButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
|
||||
disabled={!isConnected || orderLoading}
|
||||
onClick={() => placeOrder('sell')}
|
||||
>
|
||||
{orderLoading ? 'Placing...' : `Sell ${baseToken}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection hint */}
|
||||
{!isConnected && (
|
||||
<p style={styles.placeholder}>Connect exchange API in Profile → API Keys to enable trading</p>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{orderSuccess && (
|
||||
<div style={styles.successBanner}>
|
||||
✅ {orderSuccess}
|
||||
<button style={styles.dismissBtn} onClick={() => setOrderSuccess(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{orderError && (
|
||||
<div style={styles.errorBanner}>
|
||||
❌ {orderError}
|
||||
<button style={styles.dismissBtn} onClick={() => setOrderError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* P1-21: Confirmation dialog before placing real trade */}
|
||||
{pendingOrder && (
|
||||
<div style={styles.confirmOverlay}>
|
||||
<div style={styles.confirmDialog}>
|
||||
<div style={{ fontSize: 14, color: '#f0f6fc', marginBottom: 8 }}>
|
||||
⚠️ Confirm {pendingOrder.side.toUpperCase()} order?
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#8b949e', marginBottom: 12 }}>
|
||||
{pendingOrder.amount} {quoteToken} {pendingOrder.side === 'buy' ? '→' : '←'} {symbol}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={styles.confirmBtn} onClick={confirmOrder}>
|
||||
✅ {pendingOrder.side === 'buy' ? 'Buy' : 'Sell'}
|
||||
</button>
|
||||
<button style={styles.cancelBtn} onClick={() => setPendingOrder(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
panel: {
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
},
|
||||
symbolSection: {
|
||||
paddingBottom: 16,
|
||||
borderBottom: '1px solid #30363d',
|
||||
},
|
||||
symbolTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
margin: '0 0 8px',
|
||||
},
|
||||
priceRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
priceLabel: {
|
||||
fontSize: 13,
|
||||
color: '#8b949e',
|
||||
},
|
||||
priceValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: '#26a69a',
|
||||
},
|
||||
priceLoading: {
|
||||
fontSize: 13,
|
||||
color: '#8b949e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
section: {
|
||||
paddingBottom: 16,
|
||||
borderBottom: '1px solid #30363d',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
margin: '0 0 8px',
|
||||
},
|
||||
infoRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
gap: 0,
|
||||
background: '#161b22',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #30363d',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
infoBlock: {
|
||||
flex: 1,
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoDivider: {
|
||||
width: 1,
|
||||
background: '#30363d',
|
||||
flexShrink: 0,
|
||||
},
|
||||
clock: {
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: '#c9d1d9',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 4,
|
||||
},
|
||||
date: {
|
||||
fontSize: 11,
|
||||
color: '#8b949e',
|
||||
},
|
||||
balanceValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
marginBottom: 2,
|
||||
},
|
||||
balanceLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8b949e',
|
||||
},
|
||||
inputRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
amountInput: {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
color: '#c9d1d9',
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box' as const,
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
orderButtons: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
},
|
||||
buyButton: {
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: '#26a69a',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.5,
|
||||
},
|
||||
sellButton: {
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: '#ef5350',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.5,
|
||||
},
|
||||
placeholder: {
|
||||
fontSize: 11,
|
||||
color: '#484f58',
|
||||
textAlign: 'center',
|
||||
margin: '6px 0 0',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
successBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 12,
|
||||
color: '#26a69a',
|
||||
background: '#0a2e29',
|
||||
border: '1px solid #26a69a',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
marginTop: 8,
|
||||
},
|
||||
errorBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 12,
|
||||
color: '#f85149',
|
||||
background: '#2d0e0e',
|
||||
border: '1px solid #f85149',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
marginTop: 8,
|
||||
},
|
||||
dismissBtn: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
padding: 0,
|
||||
marginLeft: 8,
|
||||
},
|
||||
// P1-21: Order confirmation dialog styles
|
||||
confirmOverlay: {
|
||||
position: 'fixed' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
confirmDialog: {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 360,
|
||||
width: '90%',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
confirmBtn: {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
},
|
||||
cancelBtn: {
|
||||
background: '#21262d',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
},
|
||||
};
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { fetchSignals, fetchTrades, SIGNAL_CONFIG, formatPrice, formatPnL, formatPnLPercent } from '../api/signalApi';
|
||||
import type { Signal, Trade } from '../api/signalApi';
|
||||
interface Props {
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
type Tab = 'signals' | 'trades';
|
||||
|
||||
export default function SignalPanel({ symbol }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('signals');
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
const [trades, setTrades] = useState<Trade[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalPnl, setTotalPnl] = useState<number | null>(null);
|
||||
const [winRate, setWinRate] = useState<number | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [sigRes, tradeRes] = await Promise.all([
|
||||
fetchSignals(symbol, 20),
|
||||
fetchTrades(symbol, undefined, 50),
|
||||
]);
|
||||
setSignals(sigRes.signals);
|
||||
setTrades(tradeRes.trades);
|
||||
setTotalPnl(tradeRes.total_pnl);
|
||||
setWinRate(tradeRes.win_rate);
|
||||
} catch (err) {
|
||||
console.error('Failed to load signal data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [symbol]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// Auto-refresh every 60s
|
||||
const interval = setInterval(loadData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
return (
|
||||
<div style={styles.panel}>
|
||||
{/* Header + Summary */}
|
||||
<div style={styles.header}>
|
||||
<h3 style={styles.title}>📊 Signals & Trades</h3>
|
||||
<button style={styles.refreshBtn} onClick={loadData} disabled={loading}>
|
||||
{loading ? '…' : '↻'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PnL Summary */}
|
||||
{totalPnl !== null && (
|
||||
<div style={styles.summary}>
|
||||
<span style={styles.summaryLabel}>Total P&L</span>
|
||||
<span style={{ ...styles.summaryValue, color: totalPnl >= 0 ? '#3fb950' : '#f85149' }}>
|
||||
{formatPnL(totalPnl)}
|
||||
</span>
|
||||
{winRate !== null && (
|
||||
<span style={styles.winRate}>
|
||||
Win Rate: {winRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={styles.tabs}>
|
||||
<button
|
||||
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'signals' ? styles.tabActive : {}) }}
|
||||
onClick={() => setActiveTab('signals')}
|
||||
>
|
||||
Signals ({signals.length})
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'trades' ? styles.tabActive : {}) }}
|
||||
onClick={() => setActiveTab('trades')}
|
||||
>
|
||||
Trades ({trades.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={styles.content}>
|
||||
{activeTab === 'signals' ? (
|
||||
signals.length === 0 ? (
|
||||
<div style={styles.empty}>No signals yet — waiting for candle data</div>
|
||||
) : (
|
||||
signals.map((sig) => {
|
||||
const cfg = SIGNAL_CONFIG[sig.signal_type] || { label: sig.signal_type, icon: '📊', color: '#8b949e', bg: 'transparent' };
|
||||
return (
|
||||
<div key={sig.id} style={styles.signalRow}>
|
||||
<div style={styles.signalLeft}>
|
||||
<span style={{ fontSize: 16 }}>{cfg.icon}</span>
|
||||
<div>
|
||||
<div style={{ ...styles.signalType, color: cfg.color }}>{cfg.label}</div>
|
||||
<div style={styles.signalMeta}>
|
||||
{sig.symbol} · {sig.timeframe} · {sig.strength}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.signalRight}>
|
||||
<div style={styles.signalPrice}>${formatPrice(sig.price)}</div>
|
||||
<div style={styles.signalTime}>
|
||||
{new Date(sig.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
trades.length === 0 ? (
|
||||
<div style={styles.empty}>No trades yet</div>
|
||||
) : (
|
||||
trades.map((trade) => (
|
||||
<div key={trade.id} style={styles.tradeRow}>
|
||||
<div style={styles.tradeHeader}>
|
||||
<span style={{ ...styles.tradeDirection, color: trade.direction === 'LONG' ? '#3fb950' : '#f85149' }}>
|
||||
{trade.direction === 'LONG' ? '▲' : '▼'} {trade.direction}
|
||||
</span>
|
||||
<span style={styles.tradeSymbol}>{trade.symbol}</span>
|
||||
<span style={{
|
||||
...styles.tradeStatus,
|
||||
color: trade.status === 'OPEN' ? '#58a6ff' : (trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149'),
|
||||
}}>
|
||||
{trade.status === 'OPEN' ? '🟢 OPEN' : `🔒 ${formatPnL(trade.pnl)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.tradeDetails}>
|
||||
<span>Entry: ${formatPrice(trade.entry_price)}</span>
|
||||
{trade.exit_price && <span> → Exit: ${formatPrice(trade.exit_price)}</span>}
|
||||
{trade.pnl_percent !== null && (
|
||||
<span style={{ color: trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149', fontWeight: 600 }}>
|
||||
{formatPnLPercent(trade.pnl_percent)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.tradeReason}>
|
||||
{trade.entry_reason && <span>📥 {trade.entry_reason}</span>}
|
||||
{trade.exit_reason && <span> 📤 {trade.exit_reason}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
panel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px 8px',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
margin: 0,
|
||||
},
|
||||
refreshBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: '#161b22',
|
||||
color: '#8b949e',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'manipulation',
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
},
|
||||
summary: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 16px 8px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8b949e',
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
fontFamily: "'SF Mono', monospace",
|
||||
},
|
||||
winRate: {
|
||||
fontSize: 12,
|
||||
color: '#8b949e',
|
||||
marginLeft: 'auto' as const,
|
||||
},
|
||||
tabs: {
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
},
|
||||
tabActive: {
|
||||
color: '#58a6ff',
|
||||
borderBottomColor: '#58a6ff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflowY: 'auto' as const,
|
||||
padding: '4px 0',
|
||||
},
|
||||
empty: {
|
||||
padding: '24px 16px',
|
||||
textAlign: 'center' as const,
|
||||
color: '#484f58',
|
||||
fontSize: 13,
|
||||
},
|
||||
signalRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
},
|
||||
signalLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
signalType: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
},
|
||||
signalMeta: {
|
||||
fontSize: 11,
|
||||
color: '#6e7681',
|
||||
marginTop: 1,
|
||||
},
|
||||
signalRight: {
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
signalPrice: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#c9d1d9',
|
||||
fontFamily: "'SF Mono', monospace",
|
||||
},
|
||||
signalTime: {
|
||||
fontSize: 11,
|
||||
color: '#484f58',
|
||||
},
|
||||
tradeRow: {
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
tradeHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
tradeDirection: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
},
|
||||
tradeSymbol: {
|
||||
fontSize: 12,
|
||||
color: '#c9d1d9',
|
||||
},
|
||||
tradeStatus: {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
marginLeft: 'auto' as const,
|
||||
},
|
||||
tradeDetails: {
|
||||
fontSize: 11,
|
||||
color: '#8b949e',
|
||||
marginTop: 4,
|
||||
fontFamily: "'SF Mono', monospace",
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
tradeReason: {
|
||||
fontSize: 10,
|
||||
color: '#484f58',
|
||||
marginTop: 2,
|
||||
},
|
||||
};
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { fetchWatchlist, addToWatchlist, removeFromWatchlist, fetchAllSymbols } from '../api/watchlistApi';
|
||||
import type { WatchlistItem, SymbolOption } from '../api/watchlistApi';
|
||||
import { fetchSignals, SIGNAL_CONFIG } from '../api/signalApi';
|
||||
import type { Signal } from '../api/signalApi';
|
||||
|
||||
interface Props {
|
||||
currentSymbol: string;
|
||||
onSymbolChange: (symbol: string) => void;
|
||||
/** Currently selected exchange for adding new symbols */
|
||||
exchange: string;
|
||||
}
|
||||
|
||||
export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange }: Props) {
|
||||
const [items, setItems] = useState<WatchlistItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Add symbol state
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [symbols, setSymbols] = useState<SymbolOption[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchWatchlist();
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load watchlist:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSignals = useCallback(async () => {
|
||||
try {
|
||||
const sigData = await fetchSignals(undefined, 50);
|
||||
setSignals(sigData.signals);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchlist();
|
||||
loadSignals();
|
||||
const interval = setInterval(() => {
|
||||
loadSignals();
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadWatchlist, loadSignals]);
|
||||
|
||||
// Search symbols when add panel opens
|
||||
useEffect(() => {
|
||||
if (showAdd) {
|
||||
searchSymbols('');
|
||||
setTimeout(() => searchRef.current?.focus(), 100);
|
||||
}
|
||||
}, [showAdd]);
|
||||
|
||||
async function searchSymbols(q: string) {
|
||||
setSearching(true);
|
||||
try {
|
||||
const data = await fetchAllSymbols(exchange, q);
|
||||
setSymbols(data);
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(sym: SymbolOption) {
|
||||
try {
|
||||
setError('');
|
||||
await addToWatchlist(sym.id);
|
||||
await loadWatchlist();
|
||||
setShowAdd(false);
|
||||
setSearch('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add symbol');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
try {
|
||||
await removeFromWatchlist(id);
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Remove error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function getSignalForSymbol(sym: string): Signal | undefined {
|
||||
return signals.find((s) => s.symbol === sym && s.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
// Filter out symbols already in watchlist
|
||||
const watchlistSymbolIds = new Set(items.map((i) => i.symbol_id));
|
||||
const availableSymbols = symbols.filter((s) => !watchlistSymbolIds.has(s.id));
|
||||
|
||||
return (
|
||||
<div style={styles.panel}>
|
||||
<style>{`
|
||||
.wl-remove-btn:hover { opacity: 1 !important; color: #f85149 !important; background: rgba(248,81,73,0.1) !important; }
|
||||
.wl-item:hover .wl-remove-btn { opacity: 0.65 !important; }
|
||||
`}</style>
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<h3 style={styles.title}>👁️ Watchlist</h3>
|
||||
<button style={styles.addBtn} onClick={() => setShowAdd(!showAdd)}>
|
||||
{showAdd ? '✕' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Symbol Panel */}
|
||||
{showAdd && (
|
||||
<div style={styles.addPanel}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
style={styles.searchInput}
|
||||
placeholder="Search symbols..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
searchSymbols(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
<div style={styles.symbolList}>
|
||||
{searching ? (
|
||||
<div style={styles.loadingText}>Searching...</div>
|
||||
) : availableSymbols.length === 0 ? (
|
||||
<div style={styles.emptyText}>No symbols found</div>
|
||||
) : (
|
||||
availableSymbols.slice(0, 30).map((sym) => (
|
||||
<button
|
||||
key={sym.id}
|
||||
style={styles.symbolOption}
|
||||
onClick={() => handleAdd(sym)}
|
||||
>
|
||||
<span style={styles.symName}>{sym.symbol}</span>
|
||||
<span style={styles.symExchange}>{exchange}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watchlist Items */}
|
||||
<div style={styles.list}>
|
||||
{loading ? (
|
||||
<div style={styles.loadingText}>Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={styles.emptyText}>
|
||||
No symbols watched yet. Click + to add.
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const sig = getSignalForSymbol(item.symbol);
|
||||
const cfg = sig ? SIGNAL_CONFIG[sig.signal_type] : null;
|
||||
const isActive = item.symbol === currentSymbol;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="wl-item"
|
||||
style={{
|
||||
...styles.item,
|
||||
...(isActive ? styles.itemActive : {}),
|
||||
padding: isMobile ? '5px 10px' : '7px 14px',
|
||||
}}
|
||||
onClick={() => onSymbolChange(item.symbol)}
|
||||
>
|
||||
{/* Symbol info */}
|
||||
<div style={styles.itemLeft}>
|
||||
<div style={{ ...styles.itemSymbol, fontSize: isMobile ? 12 : 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{item.symbol}</div>
|
||||
{item.label && <div style={styles.itemLabel}>{item.label}</div>}
|
||||
</div>
|
||||
|
||||
{/* Signal indicator */}
|
||||
<div style={styles.itemRight}>
|
||||
{cfg && (
|
||||
<span style={{ fontSize: 14, marginRight: 4 }} title={cfg.label}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="wl-remove-btn"
|
||||
style={styles.removeBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(item.id);
|
||||
}}
|
||||
title="Remove from watchlist"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
panel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px 6px',
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
margin: 0,
|
||||
},
|
||||
addBtn: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: '#161b22',
|
||||
color: '#58a6ff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
},
|
||||
addPanel: {
|
||||
padding: '6px 14px 10px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
searchInput: {
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
color: '#c9d1d9',
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
outline: 'none',
|
||||
marginBottom: 6,
|
||||
},
|
||||
error: {
|
||||
fontSize: 11,
|
||||
color: '#f85149',
|
||||
marginBottom: 4,
|
||||
},
|
||||
symbolList: {
|
||||
maxHeight: 180,
|
||||
overflowY: 'auto' as const,
|
||||
},
|
||||
symbolOption: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
textAlign: 'left' as const,
|
||||
},
|
||||
symName: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
symExchange: {
|
||||
fontSize: 10,
|
||||
color: '#484f58',
|
||||
},
|
||||
list: {
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto' as const,
|
||||
},
|
||||
loadingText: {
|
||||
padding: '12px 14px',
|
||||
fontSize: 12,
|
||||
color: '#484f58',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
emptyText: {
|
||||
padding: '12px 14px',
|
||||
fontSize: 12,
|
||||
color: '#484f58',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '7px 14px',
|
||||
cursor: 'pointer',
|
||||
borderLeft: '3px solid transparent',
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
itemActive: {
|
||||
borderLeftColor: '#58a6ff',
|
||||
background: 'rgba(88, 166, 255, 0.08)',
|
||||
},
|
||||
itemLeft: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemSymbol: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#c9d1d9',
|
||||
},
|
||||
itemLabel: {
|
||||
fontSize: 10,
|
||||
color: '#6e7681',
|
||||
},
|
||||
itemRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
removeBtn: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#484f58',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
opacity: 0.4,
|
||||
transition: 'opacity 0.15s ease, color 0.15s ease, background 0.15s ease',
|
||||
zIndex: 5,
|
||||
},
|
||||
};
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppSelector } from '../../app/hooks';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { useT } from '../../translations';
|
||||
|
||||
// ── Shared ──
|
||||
// Thin wrapper that strips the /api/v1 prefix and delegates to the canonical apiFetch
|
||||
// which handles token refresh, consistent error formatting, and proper BASE_URL prefix.
|
||||
function authFetch<T = any>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const apiUrl = url.replace(/^\/api\/v1/, '');
|
||||
return apiFetch<T>(apiUrl, options);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 };
|
||||
const inputStyle: React.CSSProperties = { padding: '6px 10px', borderRadius: 6, border: '1px solid #30363d', background: '#0d1117', color: '#c9d1d9', fontSize: 13 };
|
||||
const labelStyle: React.CSSProperties = { fontSize: 11, color: '#8b949e', marginBottom: 4 };
|
||||
const cardStyle: React.CSSProperties = { background: '#161b22', borderRadius: 8, padding: 14, border: '1px solid #21262d' };
|
||||
|
||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||
const TFS_BT = ['15m', '30m', '1h', '4h'];
|
||||
const DAYS = [3, 7, 14, 30, 90, 180, 365];
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ code: 'vi', label: '🇻🇳 Tiếng Việt' },
|
||||
{ code: 'en', label: '🇺🇸 English' },
|
||||
{ code: 'zh', label: '🇨🇳 中文' },
|
||||
{ code: 'ko', label: '🇰🇷 한국어' },
|
||||
];
|
||||
|
||||
function parseUA(ua: string | null): { browser: string; os: string; device: 'desktop' | 'mobile' | 'unknown' } {
|
||||
if (!ua) return { browser: 'Unknown', os: 'Unknown', device: 'unknown' };
|
||||
const lower = ua.toLowerCase();
|
||||
let browser = 'Unknown';
|
||||
if (lower.includes('edg/')) browser = 'Edge';
|
||||
else if (lower.includes('chrome/')) browser = 'Chrome';
|
||||
else if (lower.includes('firefox/')) browser = 'Firefox';
|
||||
else if (lower.includes('safari/') && !lower.includes('chrome/')) browser = 'Safari';
|
||||
else if (lower.includes('opr/')) browser = 'Opera';
|
||||
let os = 'Unknown';
|
||||
if (lower.includes('windows nt 10')) os = 'Windows 10';
|
||||
else if (lower.includes('windows nt 11')) os = 'Windows 11';
|
||||
else if (lower.includes('mac os x')) os = 'macOS';
|
||||
else if (lower.includes('android')) os = 'Android';
|
||||
else if (lower.includes('iphone') || lower.includes('ipad')) os = 'iOS';
|
||||
else if (lower.includes('linux')) os = 'Linux';
|
||||
const device = lower.includes('mobile') || lower.includes('iphone') || lower.includes('android') ? 'mobile' : 'desktop';
|
||||
return { browser, os, device };
|
||||
}
|
||||
const deviceIcon: Record<string, string> = { desktop: '🖥️', mobile: '📱', unknown: '🌐' };
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div onClick={() => onChange(!value)} style={{ width: 40, height: 22, borderRadius: 11, cursor: 'pointer', position: 'relative', background: value ? '#238636' : '#21262d', transition: 'background 0.2s' }}>
|
||||
<div style={{ width: 18, height: 18, borderRadius: '50%', background: '#fff', position: 'absolute', top: 2, left: value ? 20 : 2, transition: 'all 0.2s' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ INFO TAB ═══════════════
|
||||
function InfoTab() {
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [form, setForm] = useState({ display_name: '', email: '' });
|
||||
const [pwModal, setPwModal] = useState(false);
|
||||
const [pwForm, setPwForm] = useState({ old_password: '', new_password: '', confirm: '' });
|
||||
const [pwMsg, setPwMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const u = await authFetch('/api/v1/auth/me'); setProfile(u); setForm({ display_name: u.display_name || '', email: u.email || '' }); } catch { setProfile(null); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async () => { setMsg(''); try { const u = await authFetch('/api/v1/auth/me', { method: 'PUT', body: JSON.stringify(form) }); setProfile(u); setEditing(false); setMsg('✅ Saved'); } catch (e: any) { setMsg('❌ ' + e.message); } };
|
||||
const changePw = async () => {
|
||||
setPwMsg(''); if (pwForm.new_password !== pwForm.confirm) { setPwMsg('❌ Passwords do not match'); return; }
|
||||
try { await authFetch('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify({ old_password: pwForm.old_password, new_password: pwForm.new_password }) }); setPwMsg('✅ Password changed'); setPwModal(false); setPwForm({ old_password: '', new_password: '', confirm: '' }); } catch (e: any) { setPwMsg('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (!profile) return <div style={{ color: '#ef4444' }}>❌ Không tải được profile</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('✅') ? '#22c55e' : '#ef4444' }}>{msg}</div>}
|
||||
<div style={{ display: 'grid', gap: 10, maxWidth: 400 }}>
|
||||
<div><div style={labelStyle}>Username</div><input disabled value={profile.username} style={{ ...inputStyle, width: '100%', opacity: 0.6 }} /></div>
|
||||
{editing ? (<>
|
||||
<div><div style={labelStyle}>Display Name</div><input value={form.display_name} onChange={e => setForm({ ...form, display_name: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div><div style={labelStyle}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}><button onClick={save} style={btnStyle}>💾 Save</button><button onClick={() => setEditing(false)} style={btnStyle}>Cancel</button></div>
|
||||
</>) : (<>
|
||||
<div><div style={labelStyle}>Display Name</div><div style={{ color: '#c9d1d9' }}>{profile.display_name || '-'}</div></div>
|
||||
<div><div style={labelStyle}>Email</div><div style={{ color: '#c9d1d9' }}>{profile.email}</div></div>
|
||||
<div><div style={labelStyle}>Role</div><div style={{ color: '#c9d1d9' }}>{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
|
||||
<div><div style={labelStyle}>Member since</div><div style={{ color: '#c9d1d9' }}>{profile.created_at?.slice(0, 10)}</div></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}><button onClick={() => setEditing(true)} style={btnStyle}>✏️ Edit</button><button onClick={() => setPwModal(true)} style={{ ...btnStyle, color: '#d29922', borderColor: '#d29922' }}>🔑 Change Password</button></div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{pwModal && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#161b22', borderRadius: 12, padding: 24, border: '1px solid #30363d', maxWidth: 360, width: '90%' }}>
|
||||
<h3 style={{ color: '#f0f6fc', margin: '0 0 16px' }}>🔑 Change Password</h3>
|
||||
{pwMsg && <div style={{ marginBottom: 10, color: pwMsg.startsWith('✅') ? '#22c55e' : '#ef4444', fontSize: 12 }}>{pwMsg}</div>}
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div><div style={labelStyle}>Old Password</div><input type="password" value={pwForm.old_password} onChange={e => setPwForm({ ...pwForm, old_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div><div style={labelStyle}>New Password</div><input type="password" value={pwForm.new_password} onChange={e => setPwForm({ ...pwForm, new_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div><div style={labelStyle}>Confirm New Password</div><input type="password" value={pwForm.confirm} onChange={e => setPwForm({ ...pwForm, confirm: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}><button onClick={changePw} style={{ ...btnStyle, background: '#d29922', color: '#000', borderColor: '#d29922' }}>Change Password</button><button onClick={() => setPwModal(false)} style={btnStyle}>Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ KEYS TAB ═══════════════
|
||||
function KeysTab() {
|
||||
const [keys, setKeys] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ exchange_name: 'binance', api_key: '', api_secret: '', passphrase: '' });
|
||||
const [msg, setMsg] = useState('');
|
||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({});
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [keyDetails, setKeyDetails] = useState<Record<string, { balances: any[]; usdt_balance: number; orders: any[]; positions: any[]; loading: boolean; error: string }>>({});
|
||||
|
||||
const load = useCallback(async () => { setLoading(true); try { setKeys(await authFetch('/api/v1/credentials')); } catch {} setLoading(false); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addKey = async () => { setMsg(''); try { await authFetch('/api/v1/credentials', { method: 'POST', body: JSON.stringify(form) }); setShowAdd(false); setForm({ exchange_name: 'binance', api_key: '', api_secret: '', passphrase: '' }); load(); } catch (e: any) { setMsg('❌ ' + e.message); } };
|
||||
const deleteKey = async (id: string) => { try { await authFetch(`/api/v1/credentials/${id}`, { method: 'DELETE' }); load(); } catch {} };
|
||||
const testKey = async (id: string) => {
|
||||
try { const r = await authFetch<{ success: boolean; message: string }>(`/api/v1/credentials/${id}/test`, { method: 'POST' }); setTestResults(prev => ({ ...prev, [id]: r })); setTimeout(() => setTestResults(prev => { const n = { ...prev }; delete n[id]; return n; }), 8000); } catch (e: any) { setTestResults(prev => ({ ...prev, [id]: { success: false, message: e.message } })); }
|
||||
};
|
||||
|
||||
const toggleExpand = async (id: string) => {
|
||||
if (expandedId === id) { setExpandedId(null); return; }
|
||||
setExpandedId(id);
|
||||
if (keyDetails[id]?.balances) return;
|
||||
setKeyDetails(prev => ({ ...prev, [id]: { balances: [], usdt_balance: 0, orders: [], positions: [], loading: true, error: '' } }));
|
||||
try {
|
||||
const [balData, ordData] = await Promise.all([
|
||||
authFetch<any>(`/api/v1/credentials/${id}/balance`).catch(() => null),
|
||||
authFetch<any>(`/api/v1/credentials/${id}/orders`).catch(() => null),
|
||||
]);
|
||||
setKeyDetails(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
balances: balData?.balances || [],
|
||||
usdt_balance: balData?.usdt_balance || 0,
|
||||
orders: ordData?.orders || [],
|
||||
positions: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
},
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setKeyDetails(prev => ({ ...prev, [id]: { ...prev[id], loading: false, error: e.message } }));
|
||||
}
|
||||
};
|
||||
|
||||
const activeExchanges = keys.filter(k => k.is_active).map(k => k.exchange_name).filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('❌') ? '#ef4444' : '#22c55e', fontSize: 12 }}>{msg}</div>}
|
||||
<div style={{ marginBottom: 12, color: activeExchanges.length > 0 ? '#3fb950' : '#8b949e', fontWeight: 600 }}>
|
||||
{activeExchanges.length > 0 ? `🔗 Connected to ${activeExchanges.map(e => e?.toUpperCase()).join(', ')}` : '🔌 No exchanges connected'}
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => setShowAdd(!showAdd)} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
|
||||
</div>
|
||||
{showAdd && (
|
||||
<div style={{ ...cardStyle, marginBottom: 16, maxWidth: 400 }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div><div style={labelStyle}>Exchange</div><select value={form.exchange_name} onChange={e => setForm({ ...form, exchange_name: e.target.value })} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div><div style={labelStyle}>API Key</div><input value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API key" /></div>
|
||||
<div><div style={labelStyle}>API Secret</div><input type="password" value={form.api_secret} onChange={e => setForm({ ...form, api_secret: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API secret" /></div>
|
||||
<div><div style={labelStyle}>Passphrase (optional)</div><input type="password" value={form.passphrase} onChange={e => setForm({ ...form, passphrase: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Exchange passphrase" /></div>
|
||||
<button onClick={addKey} style={{ ...btnStyle, background: '#22c55e', color: '#000', borderColor: '#22c55e' }}>💾 Save Key</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{keys.length === 0 ? (
|
||||
<div style={{ color: '#8b949e' }}>Chưa có API key nào. Thêm key để bot có thể trade.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{keys.map((k: any) => (
|
||||
<div key={k.id} style={{ ...cardStyle }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleExpand(k.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: '#8b949e', fontSize: 10, transition: 'transform 0.2s', transform: expandedId === k.id ? 'rotate(90deg)' : 'rotate(0deg)' }}>▶</span>
|
||||
<span style={{ color: '#f0f6fc', fontWeight: 600 }}>{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
|
||||
</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 12, marginLeft: 16 }}>Key: {k.api_key}</div>
|
||||
<div style={{ fontSize: 11, color: k.is_active ? '#3fb950' : '#484f58', marginTop: 2, marginLeft: 16 }}>
|
||||
{k.is_active ? '● Connected' : '● Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => testKey(k.id)} style={{ ...btnStyle, padding: '4px 10px', fontSize: 11 }}>🔍 Test</button>
|
||||
<button onClick={() => deleteKey(k.id)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', padding: '4px 10px', fontSize: 11 }}>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
{testResults[k.id] && (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: testResults[k.id].success ? '#3fb950' : '#ef4444' }}>
|
||||
{testResults[k.id].success ? '✅' : '❌'} {testResults[k.id].message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable details: token balances + open orders */}
|
||||
{expandedId === k.id && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', borderTop: '1px solid #21262d', background: '#0d1117', borderRadius: '0 0 8px 8px' }}>
|
||||
{keyDetails[k.id]?.loading ? (
|
||||
<div style={{ color: '#8b949e', fontSize: 12 }}>⏳ Loading balances & orders...</div>
|
||||
) : keyDetails[k.id]?.error ? (
|
||||
<div style={{ color: '#f85149', fontSize: 12 }}>❌ {keyDetails[k.id].error}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{/* Balances */}
|
||||
<div>
|
||||
<div style={{ color: '#58a6ff', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
|
||||
💰 Balances — ≈ ${keyDetails[k.id]?.usdt_balance?.toFixed(2) || '0.00'} USDT
|
||||
</div>
|
||||
{keyDetails[k.id]?.balances?.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 150, overflowY: 'auto' }}>
|
||||
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).slice(0, 30).map((b: any) => (
|
||||
<span key={b.asset} style={{ padding: '3px 8px', background: '#21262d', borderRadius: 6, fontSize: 11, color: '#c9d1d9', border: '1px solid #30363d', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: '#58a6ff' }}>{b.asset}</span>{' '}
|
||||
<span style={{ color: '#8b949e' }}>{Number(b.total).toFixed(4)}</span>
|
||||
</span>
|
||||
))}
|
||||
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).length === 0 && (
|
||||
<span style={{ color: '#484f58', fontSize: 11 }}>No non-zero balances</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#484f58', fontSize: 11 }}>No balance data available</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Orders */}
|
||||
<div>
|
||||
<div style={{ color: '#d29922', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
|
||||
📋 Open Orders — {keyDetails[k.id]?.orders?.length || 0}
|
||||
</div>
|
||||
{keyDetails[k.id]?.orders?.length > 0 ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Symbol</th>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Side</th>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Type</th>
|
||||
<th style={{ textAlign: 'right', padding: 2 }}>Amount</th>
|
||||
<th style={{ textAlign: 'right', padding: 2 }}>Price</th>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{keyDetails[k.id].orders.map((o: any, idx: number) => (
|
||||
<tr key={o.order_id || idx} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: 2, color: '#c9d1d9' }}>{o.symbol}</td>
|
||||
<td style={{ padding: 2, color: o.side === 'buy' ? '#3fb950' : '#f85149' }}>{o.side}</td>
|
||||
<td style={{ padding: 2, color: '#8b949e' }}>{o.order_type}</td>
|
||||
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.amount}</td>
|
||||
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
|
||||
<td style={{ padding: 2, color: o.status === 'open' ? '#d29922' : '#8b949e' }}>{o.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#484f58', fontSize: 11 }}>No open orders</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ SESSIONS TAB ═══════════════
|
||||
function SessionsTab() {
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const load = useCallback(async () => { setLoading(true); try { setSessions(await authFetch('/api/v1/auth/sessions')); } catch {} setLoading(false); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const revoke = async (hash: string) => { try { await authFetch(`/api/v1/auth/sessions/${hash}`, { method: 'DELETE' }); load(); } catch {} };
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (sessions.length === 0) return <div style={{ color: '#8b949e', padding: 40, textAlign: 'center' }}>🔓 Không có session nào.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color: '#8b949e', fontSize: 12, marginBottom: 12 }}>🛡️ Active Sessions — {sessions.length}</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{sessions.map((s: any) => {
|
||||
const ua = parseUA(s.user_agent);
|
||||
const icon = deviceIcon[ua.device];
|
||||
const isCurrent = s.is_current;
|
||||
return (
|
||||
<div key={s.token_hash} style={{ ...cardStyle, border: isCurrent ? '1px solid #1f6feb44' : '1px solid #21262d', background: isCurrent ? 'linear-gradient(135deg, #161b22 0%, #0d1a28 100%)' : '#161b22', position: 'relative' }}>
|
||||
{isCurrent && <span style={{ position: 'absolute', top: 8, right: 10, fontSize: 10, fontWeight: 700, color: '#1f6feb', background: '#1f6feb15', padding: '2px 8px', borderRadius: 4 }}>CURRENT</span>}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, background: isCurrent ? '#1f6feb20' : '#21262d' }}>{icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>{ua.browser} · {ua.os}{isCurrent && <span style={{ color: '#1f6feb', fontSize: 11, marginLeft: 8 }}>● Active now</span>}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
|
||||
</div>
|
||||
{!isCurrent && <button onClick={() => revoke(s.token_hash)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', background: '#ef444410', padding: '4px 12px', fontSize: 11 }}>🚫 Revoke</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ SETTINGS TAB ═══════════════
|
||||
function SettingsTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [prefExchange, setPrefExchange] = useState('mexc');
|
||||
const [prefTimeframe, setPrefTimeframe] = useState('1h');
|
||||
const [prefTheme, setPrefTheme] = useState('dark');
|
||||
const [prefUpColor, setPrefUpColor] = useState('#3fb950');
|
||||
const [prefDownColor, setPrefDownColor] = useState('#f85149');
|
||||
const [prefNotifSignal, setPrefNotifSignal] = useState(true);
|
||||
const [prefNotifTrade, setPrefNotifTrade] = useState(false);
|
||||
const [prefLanguage, setPrefLanguage] = useState('vi');
|
||||
const [prefTradeSize, setPrefTradeSize] = useState('10');
|
||||
const [prefAutoTrade, setPrefAutoTrade] = useState(false);
|
||||
const [prefAutoTradeTokens, setPrefAutoTradeTokens] = useState<string[]>([]);
|
||||
const [tokenSearch, setTokenSearch] = useState('');
|
||||
const [allSymbols, setAllSymbols] = useState<{ symbol: string; base: string }[]>([]);
|
||||
const [strategyList, setStrategyList] = useState<{ name: string; display_name: string; enabled: boolean }[]>([]);
|
||||
const [enabledStrategies, setEnabledStrategies] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const u = await authFetch('/api/v1/auth/me');
|
||||
if (u.preferences) {
|
||||
setPrefExchange(u.preferences.default_exchange || 'mexc');
|
||||
setPrefTimeframe(u.preferences.default_timeframe || '1h');
|
||||
setPrefTheme(u.preferences.theme || 'dark');
|
||||
setPrefUpColor(u.preferences.up_color || '#3fb950');
|
||||
setPrefDownColor(u.preferences.down_color || '#f85149');
|
||||
setPrefNotifSignal(u.preferences.notif_signal !== false);
|
||||
setPrefNotifTrade(u.preferences.notif_trade === true);
|
||||
setPrefLanguage(u.preferences.language || 'vi');
|
||||
setPrefTradeSize(String(u.preferences.trade_size ?? 10));
|
||||
setPrefAutoTrade(u.preferences.auto_trade === true);
|
||||
setPrefAutoTradeTokens(u.preferences.auto_trade_tokens || []);
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); authFetch<{ symbol: string; base: string }[]>('/watchlist/all-symbols').then(d => setAllSymbols(d || [])).catch(() => {}); }, []);
|
||||
useEffect(() => { authFetch<{ strategies: { name: string; display_name: string; enabled: boolean }[] }>('/api/v1/strategies').then(d => { setStrategyList(d.strategies || []); setEnabledStrategies((d.strategies || []).filter(s => s.enabled).map(s => s.name)); }).catch(() => {}); }, []);
|
||||
|
||||
const save = async () => {
|
||||
setMsg('');
|
||||
try {
|
||||
await authFetch('/api/v1/auth/me', { method: 'PUT', body: JSON.stringify({ preferences: { default_exchange: prefExchange, default_timeframe: prefTimeframe, theme: prefTheme, up_color: prefUpColor, down_color: prefDownColor, notif_signal: prefNotifSignal, notif_trade: prefNotifTrade, language: prefLanguage, trade_size: Number(prefTradeSize) || 10, auto_trade: prefAutoTrade, auto_trade_tokens: prefAutoTradeTokens } }) });
|
||||
await authFetch('/api/v1/strategies', { method: 'PUT', body: JSON.stringify({ enabled_strategies: enabledStrategies }) });
|
||||
setMsg('✅ Settings saved ✓');
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
} catch (e: any) { setMsg('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
|
||||
const filteredSymbols = allSymbols.filter(s => !tokenSearch || s.base.toLowerCase().includes(tokenSearch.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && <div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 9999, padding: '20px 36px', borderRadius: 12, fontSize: 15, fontWeight: 600, color: msg.startsWith('✅') ? '#3fb950' : '#f85149', background: msg.startsWith('✅') ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', border: `1px solid ${msg.startsWith('✅') ? '#3fb95044' : '#f8514944'}`, backdropFilter: 'blur(8px)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', textAlign: 'center' }}>{msg}</div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button onClick={save} style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', fontWeight: 600 }}>💾 Save Settings</button>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Exchange + Timeframe */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🏛️ Exchange</div><select value={prefExchange} onChange={e => setPrefExchange(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>⏱️ Timeframe</div><select value={prefTimeframe} onChange={e => setPrefTimeframe(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{TIMEFRAMES.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Theme + Language */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎨 Theme</div><div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setPrefTheme('dark')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'dark' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'dark' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'dark' ? 700 : 400 }}>🌙 Dark</button>
|
||||
<button onClick={() => setPrefTheme('light')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'light' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'light' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'light' ? 700 : 400 }}>☀️ Light</button>
|
||||
</div></div>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🌍 Language</div><select value={prefLanguage} onChange={e => setPrefLanguage(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{LANGUAGE_OPTIONS.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}</select></div>
|
||||
</div>
|
||||
|
||||
{/* Chart Colors */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>📊 Chart Colors</div>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefUpColor }} /><input type="color" value={prefUpColor} onChange={e => setPrefUpColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🟢 Up</span></div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefDownColor }} /><input type="color" value={prefDownColor} onChange={e => setPrefDownColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🔴 Down</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🔔 Notifications</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>📡 Signal alerts</span><ToggleSwitch value={prefNotifSignal} onChange={setPrefNotifSignal} /></label>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>💹 Trade execution</span><ToggleSwitch value={prefNotifTrade} onChange={setPrefNotifTrade} /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy Toggles */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8 }}>📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
||||
{strategyList.map(s => (
|
||||
<label key={s.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', padding: '6px 10px', borderRadius: 6, background: enabledStrategies.includes(s.name) ? '#1f6feb10' : '#0d1117', border: `1px solid ${enabledStrategies.includes(s.name) ? '#1f6feb44' : '#21262d'}`, transition: 'all 0.2s' }}>
|
||||
<span style={{ color: enabledStrategies.includes(s.name) ? '#c9d1d9' : '#484f58', fontSize: 12 }}>{s.display_name}</span>
|
||||
<ToggleSwitch
|
||||
value={enabledStrategies.includes(s.name)}
|
||||
onChange={(on) => setEnabledStrategies(prev => on ? [...prev, s.name] : prev.filter(n => n !== s.name))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Size */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>💰 Trade Size (USDT)</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8, fontStyle: 'italic' }}>Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="number" value={prefTradeSize} onChange={e => setPrefTradeSize(e.target.value)} min={1} step={1} style={{ ...inputStyle, width: 80 }} /><span style={{ color: '#8b949e', fontSize: 13 }}>USDT</span></div>
|
||||
</div>
|
||||
|
||||
{/* Auto Trade */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>🤖 Auto Trade <span style={{ color: '#8b949e', fontSize: 10, background: '#21262d', padding: '1px 6px', borderRadius: 4 }}>SPOT</span></div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>Bot tự động trade tín hiệu cho các token đã chọn</div>
|
||||
</div>
|
||||
<ToggleSwitch value={prefAutoTrade} onChange={setPrefAutoTrade} />
|
||||
</div>
|
||||
{prefAutoTrade && <div style={{ marginTop: 8, color: '#d29922', fontSize: 11 }}>⚠️ Bot sẽ đặt lệnh thật. Chỉ bật khi đã backtest kỹ!</div>}
|
||||
|
||||
{/* Token Summary */}
|
||||
{prefAutoTradeTokens.length > 0 && (
|
||||
<div style={{ marginTop: 12, borderLeft: `3px solid ${prefAutoTrade ? '#238636' : '#30363d'}`, padding: '8px 12px', borderRadius: 4, background: '#0d1117', opacity: prefAutoTrade ? 1 : 0.4, transition: 'all 0.3s' }}>
|
||||
<div style={{ color: prefAutoTrade ? '#3fb950' : '#8b949e', fontSize: 12, fontWeight: 600 }}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>
|
||||
{prefAutoTrade ? `${prefAutoTradeTokens.length} cặp — click ✕ để bỏ theo dõi` : `Auto trade đang tắt — ${prefAutoTradeTokens.length} cặp đã lưu`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
|
||||
{prefAutoTradeTokens.map(token => (
|
||||
<span key={token} style={{ padding: '3px 8px 3px 10px', background: '#23863622', color: '#3fb950', border: '1px solid #23863644', borderRadius: 12, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
|
||||
{token}
|
||||
{prefAutoTrade && <span onClick={() => setPrefAutoTradeTokens(prev => prev.filter(s => s !== token))} style={{ cursor: 'pointer', color: '#58a6ff', opacity: 0.6 }}>✕</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Selector */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎯 Chọn token cho Auto Trade</div>
|
||||
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} style={{ ...inputStyle, width: '100%', marginBottom: 8 }} />
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{filteredSymbols.slice(0, 100).map(s => (
|
||||
<button key={s.symbol} onClick={() => setPrefAutoTradeTokens(prev => prev.includes(s.symbol) ? prev.filter(t => t !== s.symbol) : [...prev, s.symbol])}
|
||||
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6, border: `1px solid ${prefAutoTradeTokens.includes(s.symbol) ? '#238636' : '#30363d'}`, background: prefAutoTradeTokens.includes(s.symbol) ? '#23863622' : '#21262d', color: prefAutoTradeTokens.includes(s.symbol) ? '#3fb950' : '#8b949e', cursor: 'pointer' }}>
|
||||
{s.base} {prefAutoTradeTokens.includes(s.symbol) && '✓'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ HISTORY TAB ═══════════════
|
||||
function HistoryTab() {
|
||||
const [subTab, setSubTab] = useState<'signals' | 'real'>('signals');
|
||||
const [signalTrades, setSignalTrades] = useState<any[]>([]);
|
||||
const [signalWinRate, setSignalWinRate] = useState<number | null>(null);
|
||||
const [signalTotalPnl, setSignalTotalPnl] = useState<number | null>(null);
|
||||
const [realTrades, setRealTrades] = useState<any[]>([]);
|
||||
const [winRate, setWinRate] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Signal trades
|
||||
const st = await authFetch<any>('/api/v1/signals/trades?limit=100');
|
||||
setSignalTrades(st.trades || []);
|
||||
setSignalWinRate(st.win_rate ?? null);
|
||||
setSignalTotalPnl(st.total_pnl ?? null);
|
||||
} catch {}
|
||||
try {
|
||||
// Real trades
|
||||
const rt = await authFetch<any>('/api/v1/real-trades?limit=100');
|
||||
setRealTrades(rt.trades || rt || []);
|
||||
} catch {}
|
||||
try {
|
||||
const wr = await authFetch<any>('/api/v1/real-trades/win-rate');
|
||||
setWinRate(wr);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const renderWinCard = (label: string, data: any) => (
|
||||
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: (data?.win_rate ?? 0) >= 50 ? '#3fb950' : '#f85149' }}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
|
||||
<div style={{ fontSize: 11, color: '#484f58' }}>{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sub-tab bar */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||
<button onClick={() => setSubTab('signals')} style={{ ...btnStyle, background: subTab === 'signals' ? '#1f6feb' : '#21262d', color: subTab === 'signals' ? '#fff' : '#8b949e', borderColor: subTab === 'signals' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'signals' ? 700 : 400 }}>📈 Signal Trades</button>
|
||||
<button onClick={() => setSubTab('real')} style={{ ...btnStyle, background: subTab === 'real' ? '#1f6feb' : '#21262d', color: subTab === 'real' ? '#fff' : '#8b949e', borderColor: subTab === 'real' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'real' ? 700 : 400 }}>💹 Real Trades</button>
|
||||
</div>
|
||||
|
||||
{subTab === 'signals' && (<>
|
||||
{signalWinRate !== null && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
{renderWinCard('Win Rate', { win_rate: signalWinRate, wins: signalWinRate !== null ? Math.round((signalWinRate / 100) * signalTrades.length) : 0, trades: signalTrades.length })}
|
||||
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>TOTAL P&L</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: (signalTotalPnl ?? 0) >= 0 ? '#3fb950' : '#f85149' }}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{signalTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No signal trades yet.</div> : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{signalTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: 4 }}>{t.symbol}</td>
|
||||
<td style={{ padding: 4, color: t.direction === 'LONG' ? '#3fb950' : '#f85149' }}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
|
||||
<td style={{ padding: 4 }}>${t.entry_price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
|
||||
<td style={{ padding: 4, color: t.status === 'OPEN' ? '#d29922' : '#8b949e' }}>{t.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{subTab === 'real' && (<>
|
||||
{winRate && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
{renderWinCard('Daily', winRate.daily)}
|
||||
{renderWinCard('Weekly', winRate.weekly)}
|
||||
{renderWinCard('Monthly', winRate.monthly)}
|
||||
</div>
|
||||
)}
|
||||
{realTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No real trades yet. Place an order from the dashboard.</div> : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Side</th><th style={{ textAlign: 'left', padding: 4 }}>Amount</th><th style={{ textAlign: 'left', padding: 4 }}>Price</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{realTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: 4 }}>{t.symbol}</td>
|
||||
<td style={{ padding: 4 }}><span style={{ color: t.side === 'buy' ? '#3fb950' : '#f85149', fontWeight: 600 }}>{t.side?.toUpperCase()}</span></td>
|
||||
<td style={{ padding: 4 }}>{t.amount}</td>
|
||||
<td style={{ padding: 4 }}>${t.price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
|
||||
<td style={{ padding: 4, color: t.status === 'open' ? '#d29922' : '#8b949e' }}>{t.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ BACKTEST TAB ═══════════════
|
||||
function BacktestTab() {
|
||||
const [btExchange, setBtExchange] = useState('binance');
|
||||
const [btSymbols, setBtSymbols] = useState<string[]>([]);
|
||||
const [btSymbol, setBtSymbol] = useState('BTC/USDT');
|
||||
const [btTf, setBtTf] = useState('1h');
|
||||
const [btDays, setBtDays] = useState(30);
|
||||
const [btTradeSize, setBtTradeSize] = useState(100);
|
||||
const [btResult, setBtResult] = useState<any>(null);
|
||||
const [btLoading, setBtLoading] = useState(false);
|
||||
const [btError, setBtError] = useState('');
|
||||
const [btSaved, setBtSaved] = useState(false);
|
||||
const [btSaveError, setBtSaveError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiFetch<{symbols: Array<{symbol: string}>}>(`/backtest/symbols?exchange=${btExchange}`)
|
||||
.then(data => { if (cancelled || !data?.symbols?.length) return; const names = data.symbols.map(s => s.symbol); setBtSymbols(names); if (!names.includes(btSymbol)) setBtSymbol(names[0]); }).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [btExchange]);
|
||||
|
||||
const runBacktest = async () => { setBtLoading(true); setBtError(''); setBtResult(null); setBtSaved(false); try { const r = await authFetch<any>(`/api/v1/backtest/run?symbol=${encodeURIComponent(btSymbol)}&exchange=${btExchange}&timeframe=${btTf}&days=${btDays}&trade_size=${btTradeSize}`); setBtResult(r); } catch (e: any) { setBtError(e.message); } setBtLoading(false); };
|
||||
const saveBacktest = async () => { if (!btResult) return; setBtSaveError(''); try { const t = btResult.trades || {}; await authFetch(`/api/v1/backtest/save?symbol=${encodeURIComponent(btSymbol)}&exchange=${btExchange}&timeframe=${btTf}&days=${btDays}&trade_size=${btTradeSize}&total_trades=${t.total || 0}&wins=${t.wins || 0}&losses=${t.losses || 0}&win_rate=${t.win_rate || 0}&total_pnl=${t.total_pnl || 0}&profit_factor=${t.profit_factor || 0}&avg_win=${t.avg_win || 0}&avg_loss=${t.avg_loss || 0}`, { method: 'POST' }); setBtSaved(true); } catch (e: any) { setBtSaveError(e.message || 'Save failed'); } };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16, alignItems: 'end' }}>
|
||||
<div><div style={labelStyle}>Exchange</div><select value={btExchange} onChange={e => setBtExchange(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 100 }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div><div style={labelStyle}>Symbol ({btSymbols.length})</div><select value={btSymbol} onChange={e => setBtSymbol(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 140 }}>{btSymbols.length > 0 ? btSymbols.map(s => <option key={s} value={s}>{s}</option>) : <option value="">Loading...</option>}</select></div>
|
||||
<div><div style={labelStyle}>TF</div><select value={btTf} onChange={e => setBtTf(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 70 }}>{TFS_BT.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
|
||||
<div><div style={labelStyle}>Days</div><select value={btDays} onChange={e => setBtDays(Number(e.target.value))} style={{ ...inputStyle, cursor: 'pointer', width: 65 }}>{DAYS.map(d => <option key={d} value={d}>{d}d</option>)}</select></div>
|
||||
<div><div style={labelStyle}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} style={{ ...inputStyle, width: 70 }} min={1} /></div>
|
||||
<button onClick={runBacktest} disabled={btLoading} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb', padding: '6px 20px', fontWeight: 600 }}>{btLoading ? '⏳...' : '▶ Run'}</button>
|
||||
</div>
|
||||
{btError && <div style={{ color: '#ef4444', marginBottom: 12 }}>❌ {btError}</div>}
|
||||
{btResult && (<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}><span style={{ color: btSaved ? '#22c55e' : '#8b949e', fontSize: 12 }}>{btSaved ? '✅ Saved to history' : '💾'}</span>{!btSaved && <button onClick={saveBacktest} style={{ ...btnStyle, fontSize: 11, padding: '3px 10px' }}>Save to History</button>}{btSaveError && <span style={{ color: '#ef4444', fontSize: 11 }}>❌ {btSaveError}</span>}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10, marginBottom: 16 }}>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>{btResult.symbol} @ {btResult.exchange}</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.timeframe} {btResult.days}d</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Candles</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.candles_count?.toLocaleString()}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Signals</div><div style={{ fontSize: 18, fontWeight: 700, color: '#58a6ff' }}>{btResult.total_signals}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Trades</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.trades.total}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Wins</div><div style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{btResult.trades.wins}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Losses</div><div style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{btResult.trades.losses}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Win Rate</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.win_rate || 0) >= 50 ? '#22c55e' : '#ef4444' }}>{btResult.trades.win_rate}%</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>PnL</div><div style={{ fontSize: 18, fontWeight: 700, color: btResult.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444' }}>${btResult.trades.total_pnl?.toFixed(2)}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Profit Factor</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.profit_factor || 0) >= 1 ? '#22c55e' : '#ef4444' }}>{btResult.trades.profit_factor?.toFixed(2) || 'N/A'}</div></div>
|
||||
</div>
|
||||
{btResult?.trades?.recent?.length > 0 && (
|
||||
<div style={cardStyle}><h4 style={{ margin: '0 0 10px', color: '#f0f6fc', fontSize: 14 }}>📋 Recent Trades</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Time</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Reason</th></tr></thead>
|
||||
<tbody>{btResult.trades.recent.slice(-10).reverse().map((t: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}><td style={{ padding: 4 }}>{t.entry_time?.slice(5, 16)}</td><td style={{ padding: 4, color: t.direction === 'LONG' ? '#22c55e' : '#ef4444' }}>{t.direction}</td><td style={{ padding: 4 }}>${t.entry_price?.toFixed(2)}</td><td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td><td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>${t.pnl?.toFixed(2)}</td><td style={{ padding: 4, color: '#8b949e' }}>{t.exit_reason}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════ MAIN ═══════════════
|
||||
export default function ProfilePage() {
|
||||
const { t } = useT();
|
||||
const user = useAppSelector(s => s.auth.user);
|
||||
const tabs = ['info', 'keys', 'sessions', 'settings', 'history', 'backtest'];
|
||||
const [tab, setTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'info');
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<h2 style={{ color: '#f0f6fc', marginBottom: 16 }}>👤 {user?.username || 'Profile'}</h2>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{tabs.map(tb => (
|
||||
<button key={tb} onClick={() => setTab(tb)} style={{ ...btnStyle, fontWeight: tab === tb ? 700 : 400, background: tab === tb ? '#1f6feb' : '#21262d', color: tab === tb ? '#fff' : '#8b949e', borderColor: tab === tb ? '#1f6feb' : '#30363d' }}>
|
||||
{tb === 'info' ? 'ℹ️ Info' : tb === 'keys' ? '🔑 API Keys' : tb === 'sessions' ? '🔐 Sessions' : tb === 'settings' ? '⚙️ Settings' : tb === 'history' ? '📈 History' : '📊 Backtest'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'info' && <InfoTab />}
|
||||
{tab === 'keys' && <KeysTab />}
|
||||
{tab === 'sessions' && <SessionsTab />}
|
||||
{tab === 'settings' && <SettingsTab />}
|
||||
{tab === 'history' && <HistoryTab />}
|
||||
{tab === 'backtest' && <BacktestTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+935
@@ -0,0 +1,935 @@
|
||||
/* ===== Trading Portal - Global Styles ===== */
|
||||
|
||||
/* --- CSS Variables (Dark Theme) --- */
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-elevated: #1c2128;
|
||||
--bg-hover: #21262d;
|
||||
--border-default: #30363d;
|
||||
--border-muted: #21262d;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--green: #3fb950;
|
||||
--green-bg: rgba(63, 185, 80, 0.15);
|
||||
--red: #f85149;
|
||||
--red-bg: rgba(248, 81, 73, 0.15);
|
||||
--yellow: #d29922;
|
||||
--yellow-bg: rgba(210, 153, 34, 0.15);
|
||||
--text-primary: #c9d1d9;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--text-link: #58a6ff;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||
--mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Consolas, monospace;
|
||||
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color-scheme: dark;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* --- Global Reset --- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
/* Fix iOS Safari scroll issues */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.875em;
|
||||
padding: 0.2em 0.4em;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h1 { font-size: 1.75rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
|
||||
p {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Dark Scrollbar --- */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-default);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-default) var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for horizontal scrollable containers */
|
||||
.scrollable-x {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollable-x::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Form Styles --- */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: var(--sans);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group select.error {
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 0 0 3px var(--red-bg);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--red-bg);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 6px;
|
||||
color: var(--red);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* --- Button Styles --- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--sans);
|
||||
line-height: 1.25;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #ffffff;
|
||||
background: #238636;
|
||||
border-color: rgba(240, 246, 252, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2ea043;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--red);
|
||||
background: transparent;
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* --- Navbar --- */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
height: 56px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.navbar-user-link:hover .navbar-user {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.navbar-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* --- Card Styles --- */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* --- Table Styles --- */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Price colors */
|
||||
.price-up { color: var(--green); }
|
||||
.price-down { color: var(--red); }
|
||||
|
||||
/* --- Authentication Pages --- */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Dashboard --- */
|
||||
.dashboard-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
/* Prevent iOS from intercepting touch events */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.chart-wrapper canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Show remove button on watchlist item hover */
|
||||
.side-panel [class*="item"]:hover [class*="removeBtn"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* --- Bottom Sheet (Order Panel on Mobile) --- */
|
||||
.bottom-sheet-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.bottom-sheet-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bottom-sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 70vh;
|
||||
background: var(--bg-surface);
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
|
||||
z-index: 210;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bottom-sheet.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.bottom-sheet-handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 0 4px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.bottom-sheet-handle::before {
|
||||
content: '';
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.bottom-sheet-content {
|
||||
padding: 8px 20px 24px;
|
||||
}
|
||||
|
||||
/* --- Loading / Spinner --- */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-default);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse loading for placeholders */
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Fade-in animation */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* --- Mobile Nav Drawer --- */
|
||||
.mobile-nav-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
.mobile-nav-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
max-width: 80vw;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-default);
|
||||
z-index: 160;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mobile-nav.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobile-nav-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.mobile-nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-nav-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mobile-nav-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-nav-item.logout {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* --- Extra Responsive Improvements --- */
|
||||
|
||||
/* Navbar toggle: only show on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.navbar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-order-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0 1rem;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.mobile-order-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bottom-sheet-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0 0.5rem;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.navbar-right .btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.mobile-order-btn {
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 1rem;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
font-size: 0.9375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Responsive: Table → Card on mobile ===== */
|
||||
@media (max-width: 768px) {
|
||||
.responsive-table table thead {
|
||||
display: none;
|
||||
}
|
||||
.responsive-table table tbody tr {
|
||||
display: block;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
.responsive-table table tbody td {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
padding: 5px 0 !important;
|
||||
border-bottom: 1px solid var(--border-muted) !important;
|
||||
text-align: right !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.responsive-table table tbody td:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.responsive-table table tbody td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard side panel overlay on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-side-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
.dashboard-side-panel-mobile {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
max-width: 85vw;
|
||||
background: #0d1117;
|
||||
border-left: 1px solid #30363d;
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: -4px 0 24px rgba(0,0,0,0.5);
|
||||
animation: slideInRight 0.25s ease-out;
|
||||
}
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Profile tab bar responsive */
|
||||
@media (max-width: 768px) {
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.profile-tabs button {
|
||||
font-size: 11px !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.profile-tabs button {
|
||||
font-size: 10px !important;
|
||||
padding: 3px 8px !important;
|
||||
}
|
||||
.responsive-table table tbody tr {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.responsive-table table tbody td {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dark scrollbar ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0d1117;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
/* Firefox dark scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #30363d #0d1117;
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
Executable
+313
@@ -0,0 +1,313 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../app/hooks';
|
||||
import { fetchCurrentUser, logoutThunk } from '../features/auth/authSlice';
|
||||
import { apiFetch } from '../features/api/apiService';
|
||||
|
||||
// ── Types ──
|
||||
interface AuditLogEntry {
|
||||
id: number;
|
||||
user_id: string | null;
|
||||
action: string;
|
||||
resource: string;
|
||||
details: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuditLogListResponse {
|
||||
items: AuditLogEntry[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// ── API calls ──
|
||||
async function getAuditLogs(
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
action?: string,
|
||||
): Promise<AuditLogListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', String(limit));
|
||||
params.append('offset', String(offset));
|
||||
if (action) params.append('action', action);
|
||||
return apiFetch<AuditLogListResponse>(`/audit?${params.toString()}`);
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: '', label: 'All Actions' },
|
||||
{ value: 'trade_open', label: 'Trade Open' },
|
||||
{ value: 'trade_close', label: 'Trade Close' },
|
||||
{ value: 'signal_generated', label: 'Signal Generated' },
|
||||
{ value: 'user_login', label: 'User Login' },
|
||||
{ value: 'config_change', label: 'Config Change' },
|
||||
{ value: 'strategy_toggle', label: 'Strategy Toggle' },
|
||||
{ value: 'permission_change', label: 'Permission Change' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
|
||||
// ── Auth guard ──
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
dispatch(fetchCurrentUser())
|
||||
.unwrap()
|
||||
.catch(() => navigate('/login', { replace: true }))
|
||||
.finally(() => setInitializing(false));
|
||||
} else {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, [isAuthenticated, user, dispatch, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && user && !user.is_admin) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}, [initializing, user, navigate]);
|
||||
|
||||
// ── Data loader ──
|
||||
const loadLogs = useCallback(async () => {
|
||||
if (!user?.is_admin) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await getAuditLogs(PAGE_SIZE, offset, actionFilter || undefined);
|
||||
setLogs(resp.items);
|
||||
setTotal(resp.total);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load audit logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.is_admin, offset, actionFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [loadLogs]);
|
||||
|
||||
// ── Handlers ──
|
||||
const handleFilterChange = (newAction: string) => {
|
||||
setActionFilter(newAction);
|
||||
setOffset(0);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setOffset((page - 1) * PAGE_SIZE);
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
trade_open: '#3fb950',
|
||||
trade_close: '#f85149',
|
||||
signal_generated: '#d29922',
|
||||
user_login: '#58a6ff',
|
||||
config_change: '#bc8cff',
|
||||
strategy_toggle: '#79c0ff',
|
||||
permission_change: '#ff7b72',
|
||||
};
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const color = actionColors[action] || '#8b949e';
|
||||
return { color, fontWeight: 600 };
|
||||
};
|
||||
|
||||
const formatDetails = (details: Record<string, unknown> | null) => {
|
||||
if (!details) return <span style={{ color: '#484f58' }}>—</span>;
|
||||
const entries = Object.entries(details);
|
||||
if (entries.length === 0) return <span style={{ color: '#484f58' }}>—</span>;
|
||||
return (
|
||||
<span style={{ fontSize: 11, color: '#8b949e', wordBreak: 'break-all' }}>
|
||||
{entries.map(([k, v]) => `${k}: ${String(v ?? '')}`).join(', ')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Guard ──
|
||||
if (!user?.is_admin) return null;
|
||||
if (initializing || isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading audit logs...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
|
||||
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
|
||||
minHeight: 36, touchAction: 'manipulation',
|
||||
};
|
||||
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
|
||||
border: '1px solid #30363d', borderRadius: 4, outline: 'none', cursor: 'pointer',
|
||||
minWidth: 160,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
{/* ═══ Navbar ═══ */}
|
||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>📋</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Audit Log</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button style={btnStyle} onClick={() => navigate('/admin')}>← Admin Panel</button>
|
||||
<button style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }} onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ═══ Controls ═══ */}
|
||||
<div style={{ display: 'flex', gap: 12, padding: '12px 16px', borderBottom: '1px solid #21262d', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
style={selectStyle}
|
||||
value={actionFilter}
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span style={{ fontSize: 12, color: '#484f58' }}>
|
||||
{total > 0 ? `${total} entries` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ═══ Table ═══ */}
|
||||
<div style={{ padding: 16, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
{loading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading audit logs...</p>}
|
||||
{error && <p style={{ color: '#f85149', fontSize: 13 }}>{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{logs.length === 0 ? (
|
||||
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
|
||||
No audit log entries found.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Time</th>
|
||||
<th style={thStyle}>User</th>
|
||||
<th style={thStyle}>Action</th>
|
||||
<th style={thStyle}>Resource</th>
|
||||
<th style={thStyle}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((entry) => (
|
||||
<tr key={entry.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ ...tdStyle, color: '#484f58', whiteSpace: 'nowrap' }}>
|
||||
{formatTime(entry.created_at)}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{entry.user_id ? (
|
||||
<span style={{ color: '#8b949e', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
{entry.user_id.substring(0, 8)}...
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#484f58' }}>System</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={getActionBadge(entry.action)}>{entry.action}</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, color: '#f0f6fc', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{entry.resource}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{formatDetails(entry.details)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* ═══ Pagination ═══ */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', alignItems: 'center', marginTop: 16 }}>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => goToPage(1)}
|
||||
>
|
||||
««
|
||||
</button>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#8b949e', padding: '0 8px' }}>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => goToPage(totalPages)}
|
||||
>
|
||||
»»
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500,
|
||||
fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
};
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '8px 12px', verticalAlign: 'middle',
|
||||
};
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
const en: Record<string, string> = {
|
||||
// ── Nav ──
|
||||
'nav.tradingPortal': 'Trading Portal',
|
||||
'nav.profile': '👤 {name}',
|
||||
'nav.panelToggle': 'Toggle panel',
|
||||
'nav.logout': '🚪 Logout',
|
||||
|
||||
// ── Login ──
|
||||
'login.title': 'Trading Portal',
|
||||
'login.subtitle': 'Sign in to your account',
|
||||
'login.username': 'Username',
|
||||
'login.password': 'Password',
|
||||
'login.submit': 'Sign In',
|
||||
'login.signing': 'Signing in...',
|
||||
'login.error': 'Invalid username or password',
|
||||
|
||||
// ── Dashboard ──
|
||||
'dash.chart': '📈 Chart',
|
||||
'dash.watchlist': '👁️ Watchlist',
|
||||
'dash.order': '📝 Order',
|
||||
'dash.signals': '📡 Signals',
|
||||
'dash.quickBuy': '⚡ Quick Buy',
|
||||
'dash.quickSell': '⚡ Quick Sell',
|
||||
'dash.tradeSize': 'Trade Size (USDT)',
|
||||
|
||||
// ── ChartToolbar ──
|
||||
'chart.exchange': 'Exchange',
|
||||
'chart.symbol': 'Symbol',
|
||||
'chart.search': 'Search symbols...',
|
||||
'chart.loading': 'Loading...',
|
||||
'chart.noData': 'No data',
|
||||
'chart.error': 'Error loading symbols',
|
||||
|
||||
// ── Watchlist ──
|
||||
'wl.title': '👁️ Watchlist',
|
||||
'wl.search': 'Search symbols...',
|
||||
'wl.add': '+',
|
||||
'wl.remove': '✕',
|
||||
'wl.loading': 'Loading...',
|
||||
'wl.empty': 'No symbols in watchlist. Click + to add.',
|
||||
'wl.error': 'Failed to load watchlist',
|
||||
|
||||
// ── Order Panel ──
|
||||
'order.buy': 'Buy',
|
||||
'order.sell': 'Sell',
|
||||
'order.limit': 'Limit',
|
||||
'order.market': 'Market',
|
||||
'order.stop': 'Stop',
|
||||
'order.amount': 'Amount',
|
||||
'order.price': 'Price',
|
||||
'order.total': 'Total',
|
||||
'order.place': 'Place Order',
|
||||
'order.placing': 'Placing...',
|
||||
'order.success': '✅ Order placed successfully',
|
||||
'order.loginRequired': '🔒 Login required',
|
||||
'order.noKey': '⚠️ No API key configured',
|
||||
'order.balance': 'Balance: {amount}',
|
||||
'order.available': 'Available: {amount}',
|
||||
|
||||
// ── Signals ──
|
||||
'sig.title': '📡 Live Signals',
|
||||
'sig.strongBuy': '🚀 STRONG BUY',
|
||||
'sig.buy': '📈 BUY',
|
||||
'sig.strongSell': '🔻 STRONG SELL',
|
||||
'sig.sell': '📉 SELL',
|
||||
'sig.cautionLong': '⚠️ CAUTION (LONG)',
|
||||
'sig.cautionShort': '⚠️ CAUTION (SHORT)',
|
||||
'sig.squeeze': '⚡ SQUEEZE ALERT',
|
||||
'sig.noSignal': 'No signals yet — waiting for new candles...',
|
||||
'sig.loading': 'Loading signals...',
|
||||
'sig.price': 'Price',
|
||||
'sig.rsi': 'RSI',
|
||||
'sig.bb': 'BB',
|
||||
'sig.signal': 'Signal',
|
||||
'sig.time': 'Time',
|
||||
|
||||
// ── Profile ──
|
||||
'profile.tabs.info': 'ℹ️ Info',
|
||||
'profile.tabs.keys': '🔑 API Keys',
|
||||
'profile.tabs.history': '📜 History',
|
||||
'profile.tabs.prefs': '⚙️ Settings',
|
||||
'profile.tabs.sessions': '🔐 Sessions',
|
||||
|
||||
'profile.info.title': 'Profile Info',
|
||||
'profile.info.email': 'Email',
|
||||
'profile.info.displayName': 'Display Name',
|
||||
'profile.info.save': 'Save Profile',
|
||||
'profile.info.saved': '✅ Profile saved ✓',
|
||||
'profile.info.error': 'Save failed',
|
||||
|
||||
'profile.password.title': 'Change Password',
|
||||
'profile.password.old': 'Current Password',
|
||||
'profile.password.new': 'New Password',
|
||||
'profile.password.confirm': 'Confirm Password',
|
||||
'profile.password.btn': 'Change Password',
|
||||
'profile.password.validLength': '8+ characters',
|
||||
'profile.password.validNumber': 'Need 1 number',
|
||||
'profile.password.validSpecial': 'Need 1 special char',
|
||||
'profile.password.fillAll': 'Fill all fields',
|
||||
'profile.password.success': '✅ Password changed ✓',
|
||||
|
||||
'profile.keys.title': 'API Keys',
|
||||
'profile.keys.add': '+ Add Key',
|
||||
'profile.keys.edit': '✏️',
|
||||
'profile.keys.delete': '🗑️',
|
||||
'profile.keys.test': 'Test',
|
||||
'profile.keys.empty': 'No API keys configured',
|
||||
'profile.keys.exchange': 'Exchange',
|
||||
'profile.keys.key': 'API Key',
|
||||
'profile.keys.secret': 'Secret',
|
||||
'profile.keys.passphrase': 'Passphrase',
|
||||
'profile.keys.saving': 'Saving...',
|
||||
'profile.keys.addTitle': 'Add API Key',
|
||||
'profile.keys.editTitle': 'Edit API Key',
|
||||
'profile.keys.deleteConfirm': 'Are you sure you want to delete this key?',
|
||||
|
||||
'profile.prefs.title': '⚙️ Settings',
|
||||
'profile.prefs.exchange': 'Default Exchange',
|
||||
'profile.prefs.timeframe': 'Default Timeframe',
|
||||
'profile.prefs.theme': 'Theme',
|
||||
'profile.prefs.upColor': 'Up Color',
|
||||
'profile.prefs.downColor': 'Down Color',
|
||||
'profile.prefs.notifSignal': '🔔 Signal Notifications',
|
||||
'profile.prefs.notifTrade': '💬 Trade Execution',
|
||||
'profile.prefs.notifTradeDesc': 'Gửi thông báo Telegram khi có lệnh thật được đặt',
|
||||
'profile.prefs.language': 'Language',
|
||||
'profile.prefs.tradeSize': '💰 Trade Size (USDT)',
|
||||
'profile.prefs.tradeSizeDesc': 'Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade',
|
||||
'profile.prefs.autoTrade': '🤖 Auto Trade',
|
||||
'profile.prefs.autoTradeDesc': 'Tự động đặt lệnh Spot mua/bán thật — cần chọn token bên dưới',
|
||||
'profile.prefs.autoTradeWarning': '⚠️ Auto Trade đang BẬT — hệ thống sẽ tự động đặt lệnh market thật khi có signal',
|
||||
'profile.prefs.autoTradeTokens': '🪙 Auto Trade Tokens',
|
||||
'profile.prefs.autoTradeTokensDesc': 'Chọn token muốn Auto Trade — chỉ những token được chọn mới được auto trade',
|
||||
'profile.prefs.searchToken': '🔍 Tìm token...',
|
||||
'profile.prefs.noTokens': '⚠️ Chưa chọn token nào — Auto Trade sẽ không hoạt động',
|
||||
'profile.prefs.tracking': '📊 Auto Trades đang theo dõi',
|
||||
'profile.prefs.trackingDesc': '{n} cặp — click ✕ để bỏ theo dõi',
|
||||
'profile.prefs.trackingPaused': '📊 Auto Trades (tạm dừng)',
|
||||
'profile.prefs.trackingPausedDesc': 'Auto trade đang tắt — {n} cặp đã lưu',
|
||||
'profile.prefs.save': '💾 Save Settings',
|
||||
'profile.prefs.saved': '✅ Settings saved ✓',
|
||||
'profile.prefs.spot': 'SPOT',
|
||||
|
||||
'profile.sessions.title': 'Active Sessions',
|
||||
'profile.sessions.current': '(current)',
|
||||
'profile.sessions.revoke': 'Revoke',
|
||||
'profile.sessions.empty': 'No active sessions',
|
||||
'profile.sessions.browser': 'Browser',
|
||||
'profile.sessions.os': 'OS',
|
||||
'profile.sessions.device': 'Device',
|
||||
'profile.sessions.ip': 'IP',
|
||||
'profile.sessions.created': 'Created',
|
||||
|
||||
'profile.history.signals': '📈 Signal Trades',
|
||||
'profile.history.real': '💹 Real Trades',
|
||||
'profile.history.empty': 'No trades yet',
|
||||
'profile.history.side': 'Side',
|
||||
'profile.history.symbol': 'Symbol',
|
||||
'profile.history.price': 'Price',
|
||||
'profile.history.qty': 'Qty',
|
||||
'profile.history.pnl': 'PnL',
|
||||
'profile.history.time': 'Time',
|
||||
|
||||
// ── Signal History ──
|
||||
'sigHistory.pnl': 'Total PnL',
|
||||
'sigHistory.winRate': 'Win Rate',
|
||||
'sigHistory.trades': '{n} trades',
|
||||
};
|
||||
|
||||
export default en;
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
import { createContext, useContext, type ReactNode, type FC } from 'react';
|
||||
import en from './en';
|
||||
import vi from './vi';
|
||||
|
||||
const locales = { en, vi } as const;
|
||||
type Lang = keyof typeof locales;
|
||||
|
||||
type TContext = {
|
||||
lang: Lang;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
setLang: (l: Lang) => void;
|
||||
};
|
||||
|
||||
const Ctx = createContext<TContext>({
|
||||
lang: 'en',
|
||||
t: (k) => k,
|
||||
setLang: () => {},
|
||||
});
|
||||
|
||||
export const useT = () => useContext(Ctx);
|
||||
|
||||
type Props = { lang: Lang; setLang: (l: Lang) => void; children: ReactNode };
|
||||
export const TProvider: FC<Props> = ({ lang, setLang, children }) => {
|
||||
const t = (key: string, vars?: Record<string, string | number>): string => {
|
||||
const dict = locales[lang] ?? en;
|
||||
let msg = (dict as Record<string, string>)[key];
|
||||
if (msg === undefined) msg = (en as Record<string, string>)[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
msg = msg.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
return <Ctx.Provider value={{ lang, t, setLang }}>{children}</Ctx.Provider>;
|
||||
};
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
const vi: Record<string, string> = {
|
||||
// ── Nav ──
|
||||
'nav.tradingPortal': '📊 Trading Portal',
|
||||
'nav.profile': '👤 {name}',
|
||||
'nav.panelToggle': 'Mở/tắt bảng',
|
||||
'nav.logout': '🚪 Đăng xuất',
|
||||
|
||||
// ── Login ──
|
||||
'login.title': 'Trading Portal',
|
||||
'login.subtitle': 'Đăng nhập tài khoản',
|
||||
'login.username': 'Tên đăng nhập',
|
||||
'login.password': 'Mật khẩu',
|
||||
'login.submit': 'Đăng nhập',
|
||||
'login.signing': 'Đang đăng nhập...',
|
||||
'login.error': 'Sai tên đăng nhập hoặc mật khẩu',
|
||||
|
||||
// ── Dashboard ──
|
||||
'dash.chart': '📈 Biểu đồ',
|
||||
'dash.watchlist': '👁️ Danh sách',
|
||||
'dash.order': '📝 Đặt lệnh',
|
||||
'dash.signals': '📡 Tín hiệu',
|
||||
'dash.quickBuy': '⚡ Mua nhanh',
|
||||
'dash.quickSell': '⚡ Bán nhanh',
|
||||
'dash.tradeSize': 'Khối lượng (USDT)',
|
||||
|
||||
// ── ChartToolbar ──
|
||||
'chart.exchange': 'Sàn',
|
||||
'chart.symbol': 'Cặp',
|
||||
'chart.search': 'Tìm symbol...',
|
||||
'chart.loading': 'Đang tải...',
|
||||
'chart.noData': 'Không có dữ liệu',
|
||||
'chart.error': 'Lỗi tải symbol',
|
||||
|
||||
// ── Watchlist ──
|
||||
'wl.title': '👁️ Danh sách',
|
||||
'wl.search': 'Tìm symbol...',
|
||||
'wl.add': '+',
|
||||
'wl.remove': '✕',
|
||||
'wl.loading': 'Đang tải...',
|
||||
'wl.empty': 'Chưa có symbol nào. Click + để thêm.',
|
||||
'wl.error': 'Lỗi tải danh sách',
|
||||
|
||||
// ── Order Panel ──
|
||||
'order.buy': 'Mua',
|
||||
'order.sell': 'Bán',
|
||||
'order.limit': 'Giới hạn',
|
||||
'order.market': 'Thị trường',
|
||||
'order.stop': 'Dừng',
|
||||
'order.amount': 'Số lượng',
|
||||
'order.price': 'Giá',
|
||||
'order.total': 'Tổng',
|
||||
'order.place': 'Đặt lệnh',
|
||||
'order.placing': 'Đang đặt...',
|
||||
'order.success': '✅ Đặt lệnh thành công',
|
||||
'order.loginRequired': '🔒 Cần đăng nhập',
|
||||
'order.noKey': '⚠️ Chưa cấu hình API key',
|
||||
'order.balance': 'Số dư: {amount}',
|
||||
'order.available': 'Khả dụng: {amount}',
|
||||
|
||||
// ── Signals ──
|
||||
'sig.title': '📡 Tín hiệu trực tiếp',
|
||||
'sig.strongBuy': '🚀 MUA MẠNH',
|
||||
'sig.buy': '📈 MUA',
|
||||
'sig.strongSell': '🔻 BÁN MẠNH',
|
||||
'sig.sell': '📉 BÁN',
|
||||
'sig.cautionLong': '⚠️ THẬN TRỌNG (LONG)',
|
||||
'sig.cautionShort': '⚠️ THẬN TRỌNG (SHORT)',
|
||||
'sig.squeeze': '⚡ SQUEEZE ALERT',
|
||||
'sig.noSignal': 'Chưa có tín hiệu — đang chờ nến mới...',
|
||||
'sig.loading': 'Đang tải tín hiệu...',
|
||||
'sig.price': 'Giá',
|
||||
'sig.rsi': 'RSI',
|
||||
'sig.bb': 'BB',
|
||||
'sig.signal': 'Tín hiệu',
|
||||
'sig.time': 'Thời gian',
|
||||
|
||||
// ── Profile ──
|
||||
'profile.tabs.info': 'ℹ️ Thông tin',
|
||||
'profile.tabs.keys': '🔑 API Keys',
|
||||
'profile.tabs.history': '📜 Lịch sử',
|
||||
'profile.tabs.prefs': '⚙️ Cài đặt',
|
||||
'profile.tabs.sessions': '🔐 Phiên đăng nhập',
|
||||
|
||||
'profile.info.title': 'Thông tin cá nhân',
|
||||
'profile.info.email': 'Email',
|
||||
'profile.info.displayName': 'Tên hiển thị',
|
||||
'profile.info.save': 'Lưu thông tin',
|
||||
'profile.info.saved': '✅ Đã lưu thông tin ✓',
|
||||
'profile.info.error': 'Lỗi lưu thông tin',
|
||||
|
||||
'profile.password.title': 'Đổi mật khẩu',
|
||||
'profile.password.old': 'Mật khẩu hiện tại',
|
||||
'profile.password.new': 'Mật khẩu mới',
|
||||
'profile.password.confirm': 'Xác nhận mật khẩu',
|
||||
'profile.password.btn': 'Đổi mật khẩu',
|
||||
'profile.password.validLength': 'Tối thiểu 8 ký tự',
|
||||
'profile.password.validNumber': 'Cần ít nhất 1 số',
|
||||
'profile.password.validSpecial': 'Cần ít nhất 1 ký tự đặc biệt',
|
||||
'profile.password.fillAll': 'Điền đầy đủ thông tin',
|
||||
'profile.password.success': '✅ Đổi mật khẩu thành công ✓',
|
||||
|
||||
'profile.keys.title': 'API Keys',
|
||||
'profile.keys.add': '+ Thêm Key',
|
||||
'profile.keys.edit': '✏️',
|
||||
'profile.keys.delete': '🗑️',
|
||||
'profile.keys.test': 'Kiểm tra',
|
||||
'profile.keys.empty': 'Chưa có API key nào',
|
||||
'profile.keys.exchange': 'Sàn',
|
||||
'profile.keys.key': 'API Key',
|
||||
'profile.keys.secret': 'Secret Key',
|
||||
'profile.keys.passphrase': 'Passphrase',
|
||||
'profile.keys.saving': 'Đang lưu...',
|
||||
'profile.keys.addTitle': 'Thêm API Key',
|
||||
'profile.keys.editTitle': 'Sửa API Key',
|
||||
'profile.keys.deleteConfirm': 'Bạn chắc chắn muốn xoá key này?',
|
||||
|
||||
'profile.prefs.title': '⚙️ Cài đặt',
|
||||
'profile.prefs.exchange': 'Sàn mặc định',
|
||||
'profile.prefs.timeframe': 'Khung thời gian',
|
||||
'profile.prefs.theme': 'Giao diện',
|
||||
'profile.prefs.upColor': 'Màu tăng',
|
||||
'profile.prefs.downColor': 'Màu giảm',
|
||||
'profile.prefs.notifSignal': '🔔 Thông báo tín hiệu',
|
||||
'profile.prefs.notifTrade': '💬 Thực hiện lệnh',
|
||||
'profile.prefs.notifTradeDesc': 'Gửi thông báo Telegram khi có lệnh thật được đặt',
|
||||
'profile.prefs.language': 'Ngôn ngữ',
|
||||
'profile.prefs.tradeSize': '💰 Khối lượng (USDT)',
|
||||
'profile.prefs.tradeSizeDesc': 'Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade',
|
||||
'profile.prefs.autoTrade': '🤖 Auto Trade',
|
||||
'profile.prefs.autoTradeDesc': 'Tự động đặt lệnh Spot mua/bán thật — cần chọn token bên dưới',
|
||||
'profile.prefs.autoTradeWarning': '⚠️ Auto Trade đang BẬT — hệ thống sẽ tự động đặt lệnh market thật khi có signal',
|
||||
'profile.prefs.autoTradeTokens': '🪙 Token Auto Trade',
|
||||
'profile.prefs.autoTradeTokensDesc': 'Chọn token muốn Auto Trade — chỉ những token được chọn mới được auto trade',
|
||||
'profile.prefs.searchToken': '🔍 Tìm token...',
|
||||
'profile.prefs.noTokens': '⚠️ Chưa chọn token nào — Auto Trade sẽ không hoạt động',
|
||||
'profile.prefs.tracking': '📊 Auto Trades đang theo dõi',
|
||||
'profile.prefs.trackingDesc': '{n} cặp — click ✕ để bỏ theo dõi',
|
||||
'profile.prefs.trackingPaused': '📊 Auto Trades (tạm dừng)',
|
||||
'profile.prefs.trackingPausedDesc': 'Auto trade đang tắt — {n} cặp đã lưu',
|
||||
'profile.prefs.save': '💾 Lưu cài đặt',
|
||||
'profile.prefs.saved': '✅ Đã lưu cài đặt ✓',
|
||||
'profile.prefs.spot': 'SPOT',
|
||||
|
||||
'profile.sessions.title': 'Phiên đăng nhập',
|
||||
'profile.sessions.current': '(hiện tại)',
|
||||
'profile.sessions.revoke': 'Thu hồi',
|
||||
'profile.sessions.empty': 'Không có phiên nào',
|
||||
'profile.sessions.browser': 'Trình duyệt',
|
||||
'profile.sessions.os': 'HĐH',
|
||||
'profile.sessions.device': 'Thiết bị',
|
||||
'profile.sessions.ip': 'IP',
|
||||
'profile.sessions.created': 'Tạo lúc',
|
||||
|
||||
'profile.history.signals': '📈 Lệnh tín hiệu',
|
||||
'profile.history.real': '💹 Lệnh thật',
|
||||
'profile.history.empty': 'Chưa có lệnh nào',
|
||||
'profile.history.side': 'Lệnh',
|
||||
'profile.history.symbol': 'Cặp',
|
||||
'profile.history.price': 'Giá',
|
||||
'profile.history.qty': 'SL',
|
||||
'profile.history.pnl': 'PnL',
|
||||
'profile.history.time': 'Thời gian',
|
||||
|
||||
// ── Signal History ──
|
||||
'sigHistory.pnl': 'Tổng PnL',
|
||||
'sigHistory.winRate': 'Tỉ lệ thắng',
|
||||
'sigHistory.trades': '{n} lệnh',
|
||||
};
|
||||
|
||||
export default vi;
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
export interface Candle {
|
||||
time: number; // Unix timestamp in seconds
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface Symbol {
|
||||
id: number;
|
||||
symbol: string;
|
||||
base: string;
|
||||
quote: string;
|
||||
exchange_id: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
connected?: boolean;
|
||||
display_name?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface Indicators {
|
||||
[key: string]: number[] | number[][];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_admin?: boolean;
|
||||
role?: string;
|
||||
preferences?: Record<string, any>;
|
||||
created_at?: string;
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons.svg'],
|
||||
manifest: {
|
||||
name: 'Trading Portal',
|
||||
short_name: 'Trading',
|
||||
description: 'Trading Portal - Real-time trading signals and charts',
|
||||
theme_color: '#0d1117',
|
||||
background_color: '#0d1117',
|
||||
display: 'standalone',
|
||||
start_url: '/dashboard',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https?:\/\/.*\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user